dsh-ptc-plus 0.5.2 → 0.5.4
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 +4 -2
- package/README.md +4 -2
- package/client.cjs +3392 -2811
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- 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/durability-design.md +9 -2
- package/docs/installation.md +17 -10
- package/docs/panel-concept.md +171 -0
- package/docs/releases/v0.5.2.md +26 -14
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/releases/v0.5.4.md +75 -0
- package/docs/runtime-reference.md +21 -1
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +4 -4
- package/index.js +176 -41
- package/internal/binding-adoption.js +228 -0
- package/internal/capability-command-receipt.js +106 -8
- package/internal/capability-environment.js +1109 -312
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/config-spec.js +14 -5
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +44 -17
- package/internal/edit-transport-owner.js +6 -5
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/message-sources.js +21 -0
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +25 -5
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +118 -9
- package/internal/runtime-config.js +10 -0
- package/internal/runtime-messages.js +5 -0
- package/internal/session-journal-recovery.js +210 -32
- package/internal/session-journal-schema.js +3 -0
- package/internal/session-journal.js +46 -9
- package/internal/session-log-view.js +69 -7
- package/internal/session-runtime.js +106 -39
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +126 -6
- package/package.json +59 -37
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +874 -909
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +118 -272
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -16
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +49 -22
|
@@ -1,18 +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
|
+
/**
|
|
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.
|
|
21
|
+
*/
|
|
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 ?? '' })
|
|
54
|
+
}
|
|
55
|
+
|
|
12
56
|
export function capabilityCommandText(t, text) {
|
|
13
57
|
if (text?.key) return t(text.key, text.params)
|
|
14
58
|
const receipt = readCapabilityCommandReceipt(text)
|
|
15
|
-
return receipt ?
|
|
59
|
+
return receipt === undefined ? text : capabilityReceiptNotice(t, receipt, 'receipt')
|
|
16
60
|
}
|
|
17
61
|
|
|
18
62
|
// Host-owned context the model sees in PTC mode. The plugin never receives the host's
|
|
@@ -32,13 +76,13 @@ const PROMPT_TABLE_COLUMNS = PROMPT_TABLE_COLUMN_KEYS.length
|
|
|
32
76
|
function capabilityChoiceLabel(t, value) {
|
|
33
77
|
return t(value === 'inherit' ? 'capabilities.quick.noOverride'
|
|
34
78
|
: ['enabled', 'pending', 'unknown'].includes(value) ? `capabilities.quick.${value}`
|
|
35
|
-
: [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.${value}` : 'capabilities.quick.unknown')
|
|
79
|
+
: [...selections, ...CAPABILITY_DISCLOSURES].includes(value) ? `capabilities.word.${value}` : 'capabilities.quick.unknown')
|
|
36
80
|
}
|
|
37
81
|
|
|
38
|
-
function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.
|
|
82
|
+
function capabilityRuleLabel(t, rule, empty = 'capabilities.policy.layerAbove') {
|
|
39
83
|
const dimensions = ['availability', 'disclosure'].filter(dimension => rule?.[dimension] !== undefined)
|
|
40
84
|
return dimensions.length ? dimensions.map(dimension => `${t(`capabilities.policy.${dimension}`)}: ${rule[dimension] === 'automatic'
|
|
41
|
-
? t('capabilities.policy.
|
|
85
|
+
? t('capabilities.policy.layerAbove') : capabilityChoiceLabel(t, rule[dimension])}`).join(' · ') : t(empty)
|
|
42
86
|
}
|
|
43
87
|
|
|
44
88
|
function validCatalogRule(rule, temporary = false) {
|
|
@@ -48,6 +92,100 @@ function validCatalogRule(rule, temporary = false) {
|
|
|
48
92
|
? ['enabled', 'closed'].includes(choice) : CAPABILITY_DISCLOSURES.includes(choice))))
|
|
49
93
|
}
|
|
50
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
|
+
|
|
51
189
|
/** A Chat snapshot without a node store cannot order a receipt against the conversation. */
|
|
52
190
|
const noSnapshot = () => undefined
|
|
53
191
|
|
|
@@ -71,17 +209,67 @@ export function firstHumanMessageSeq(nodes) {
|
|
|
71
209
|
|
|
72
210
|
/**
|
|
73
211
|
* A capability receipt belongs to chat only after the conversation has opened.
|
|
74
|
-
* A blank Session has nothing to announce, and a receipt recorded
|
|
75
|
-
* first human message describes a previous attempt's publication. A
|
|
76
|
-
* Chat window cannot prove where the conversation opened, so its
|
|
77
|
-
* 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.
|
|
78
222
|
*/
|
|
79
|
-
export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true
|
|
223
|
+
export function capabilityReceiptVisible(node, { blank = false, firstUserSeq = null, windowComplete = true,
|
|
224
|
+
written = false } = {}) {
|
|
80
225
|
if (blank === true) return false
|
|
226
|
+
if (written === true) return true
|
|
81
227
|
if (windowComplete !== true) return true
|
|
82
228
|
return !(Number.isSafeInteger(node?.seq) && Number.isSafeInteger(firstUserSeq) && node.seq < firstUserSeq)
|
|
83
229
|
}
|
|
84
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
|
+
|
|
85
273
|
export function createCapabilityCommandCard(React) {
|
|
86
274
|
const h = React.createElement
|
|
87
275
|
return function CapabilityCommandCard({ node, t, useChat, useSession }) {
|
|
@@ -100,17 +288,37 @@ export function createCapabilityCommandCard(React) {
|
|
|
100
288
|
// not chat, so a node without that submitted input is not a user command and
|
|
101
289
|
// renders no notice: a fresh session has no change to announce.
|
|
102
290
|
if (typeof node.args !== 'string') return null
|
|
103
|
-
if (!capabilityReceiptVisible(node, { blank, firstUserSeq, windowComplete })) return null
|
|
104
|
-
const command = `/${node.name ?? 'capabilities'}${node.args ? ` ${String(node.args).trim()}` : ''}`
|
|
105
291
|
const text = node.outcome?.text
|
|
106
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()}` : ''}`
|
|
107
298
|
// Pending is the plan's publication state, not an outcome: it becomes true
|
|
108
299
|
// only at the next accepted model step, so a durable notice would state a
|
|
109
300
|
// future that a historical row can no longer verify.
|
|
110
301
|
if (receipt?.state === 'pending') return null
|
|
111
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
|
+
}
|
|
112
318
|
if (receipt || String(node.args ?? '').trim() === 'refresh' || legacyRefresh) {
|
|
113
|
-
|
|
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')
|
|
114
322
|
: legacyRefresh ? t('capabilities.card.recorded')
|
|
115
323
|
: node.outcome ? (typeof text === 'string' && !text.trim() ? undefined : capabilityCommandText(t, text))
|
|
116
324
|
?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
|
|
@@ -123,10 +331,13 @@ export function createCapabilityCommandCard(React) {
|
|
|
123
331
|
if (typeof text === 'string') {
|
|
124
332
|
try { json = JSON.parse(text) } catch { /* Plain command outcomes remain readable text. */ }
|
|
125
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.
|
|
126
337
|
const entries = json?.schema === 'ptc-capability-catalog/v1' && Array.isArray(json.entries)
|
|
127
338
|
&& json.entries.every(entry => entry && typeof entry.id === 'string'
|
|
128
|
-
&& ['name', 'description'
|
|
129
|
-
|
|
339
|
+
&& ['name', 'description'].every(key => entry[key] === undefined || typeof entry[key] === 'string')
|
|
340
|
+
&& ['saved', 'temporary'].every(key => entry[key] === undefined || validCatalogStatement(key, entry[key]))
|
|
130
341
|
&& ['savedRule', 'temporaryRule'].every(key => entry[key] === undefined || validCatalogRule(entry[key], key === 'temporaryRule'))) ? json.entries : null
|
|
131
342
|
const outcome = entries ? t('capabilities.card.catalogCount', { count: entries.length })
|
|
132
343
|
: json ? t('capabilities.card.details')
|
|
@@ -134,13 +345,10 @@ export function createCapabilityCommandCard(React) {
|
|
|
134
345
|
?? t(node.outcome.kind === 'error' ? 'capabilities.commandFailed' : 'capabilities.refreshAcknowledged')
|
|
135
346
|
: t('capabilities.refreshing')
|
|
136
347
|
const label = (entry, key) => {
|
|
137
|
-
|
|
138
|
-
const rule = entry[
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return key === 'effective' ? `${capabilityChoiceLabel(t, entry.effective)} (${dimensions})` : dimensions
|
|
142
|
-
}
|
|
143
|
-
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')
|
|
144
352
|
}
|
|
145
353
|
return h('section', { className: 'ptcPlusCapabilityReceipt', 'aria-label': t('capabilities.receipt.title') },
|
|
146
354
|
h('div', { className: 'ptcPlusCapabilityReceiptHead' },
|
|
@@ -152,12 +360,12 @@ export function createCapabilityCommandCard(React) {
|
|
|
152
360
|
entries?.length ? h('details', { className: 'ptcPlusCapabilityReceiptCatalog' },
|
|
153
361
|
h('summary', null, t('capabilities.card.browse')),
|
|
154
362
|
h('div', { className: 'ptcPlusCapabilityReceiptTable' }, h('table', null,
|
|
155
|
-
h('thead', null, h('tr', null, ...['member', 'quick.
|
|
363
|
+
h('thead', null, h('tr', null, ...['member', 'quick.globalSaved', 'quick.temporary']
|
|
156
364
|
.map(key => h('th', { key, scope: 'col' }, t(`capabilities.${key}`))))),
|
|
157
365
|
h('tbody', null, ...entries.map((entry, index) => h('tr', { key: `${entry.id}-${index}` },
|
|
158
366
|
h('td', null, h('strong', null, entry.name ?? entry.id),
|
|
159
367
|
entry.description ? h('small', null, entry.description) : null),
|
|
160
|
-
...['
|
|
368
|
+
...['saved', 'temporary'].map(key => h('td', { key }, label(entry, key))))))))) : null,
|
|
161
369
|
text ? h('details', { className: 'ptcPlusCapabilityReceiptSource',
|
|
162
370
|
onToggle: event => setShowSource(event.currentTarget.open) },
|
|
163
371
|
h('summary', null, t('capabilities.card.source')),
|
|
@@ -166,28 +374,8 @@ export function createCapabilityCommandCard(React) {
|
|
|
166
374
|
}
|
|
167
375
|
|
|
168
376
|
const selections = ['automatic', 'full', 'on-demand', 'closed']
|
|
169
|
-
const kinds = ['all', 'tool', 'skill', 'resource', 'other']
|
|
170
377
|
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
171
378
|
const configurationId = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
172
|
-
|
|
173
|
-
/** Catalog facts and saved preferences stay separate from frozen session results. */
|
|
174
|
-
export function capabilitySettingsEntries(value, catalog, plans, uncertain = false) {
|
|
175
|
-
const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
|
|
176
|
-
const entries = new Map((catalog ?? []).map(entry => [entry.id, { ...entry, available: true }]))
|
|
177
|
-
for (const plan of plans ?? []) for (const entry of plan.effective.entries) {
|
|
178
|
-
if (!entries.has(entry.id)) entries.set(entry.id, { id: entry.id, category: entry.category })
|
|
179
|
-
}
|
|
180
|
-
const policy = capabilityPolicyForConfig(value)
|
|
181
|
-
for (const id of [...baseline, ...Object.keys(policy.items)].filter(id => !id.startsWith('mcpServers.'))) if (!entries.has(id)) entries.set(id, { id })
|
|
182
|
-
return [...entries.values()].map(entry => ({ ...entry,
|
|
183
|
-
name: entry.name ?? entry.id,
|
|
184
|
-
kind: ['tool', 'skill'].includes(entry.category) ? entry.category
|
|
185
|
-
: ['resource', 'resource-template'].includes(entry.category) ? 'resource' : 'other',
|
|
186
|
-
availability: catalog === null || uncertain ? 'unknown' : entry.available ? 'available' : 'unavailable',
|
|
187
|
-
common: baseline.includes(entry.id), rule: policy.items[entry.id] ?? {}, resolvedRule: capabilityRule(policy, entry),
|
|
188
|
-
}))
|
|
189
|
-
}
|
|
190
|
-
|
|
191
379
|
export function capabilityPlanReason(t, entry) {
|
|
192
380
|
const known = ['manual-override', 'baseline-not-selected', 'baseline', 'coverage-below-threshold', 'statistical-coverage',
|
|
193
381
|
'mcp-server-disabled', 'contract-budget', 'management-disabled', 'statistics-disabled', 'statistics-settings-invalid', 'statistics-data-invalid',
|
|
@@ -196,7 +384,7 @@ export function capabilityPlanReason(t, entry) {
|
|
|
196
384
|
.map(reason => known.includes(reason) ? t(`capabilities.reason.${reason}`) : reason).join(' · ')
|
|
197
385
|
}
|
|
198
386
|
|
|
199
|
-
export function createCapabilitySettingsViews(React, {
|
|
387
|
+
export function createCapabilitySettingsViews(React, { Select, Modal = createFallbackModal(React) }) {
|
|
200
388
|
const h = React.createElement
|
|
201
389
|
const field = key => CONFIG_FIELDS.find(item => item.key === key)
|
|
202
390
|
// Each group owns one settings tab; the prompt text switch lives in the same
|
|
@@ -285,8 +473,13 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
285
473
|
)
|
|
286
474
|
}
|
|
287
475
|
|
|
288
|
-
function PolicyRules({ value,
|
|
289
|
-
|
|
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)
|
|
290
483
|
const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
291
484
|
const ruleControls = (rule, update, label, defaults = false) => h('div', { className: 'ptcPlusPolicyRule', key: label },
|
|
292
485
|
h('strong', null, label), ...['availability', 'disclosure'].map(dimension => h('div', { className: 'ptcPlusCapabilityFilter', key: dimension },
|
|
@@ -295,7 +488,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
295
488
|
value: rule[dimension] ?? 'automatic', onChange: choice => {
|
|
296
489
|
const next = { ...rule }; if (choice === 'automatic') delete next[dimension]; else next[dimension] = choice; update(next)
|
|
297
490
|
}, options: [...(defaults ? [] : ['automatic']), ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
298
|
-
.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}`) })) }))))
|
|
299
492
|
return h('section', { className: 'ptcPlusPolicyRules', 'aria-label': t('capabilities.policy.rules') },
|
|
300
493
|
h('h4', null, t('capabilities.inheritedRules')), h('p', { className: 'ptcPlusDetail' }, t('capabilities.inheritedRulesHelp')),
|
|
301
494
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.precedence')),
|
|
@@ -304,7 +497,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
304
497
|
...Object.values(CAPABILITY_INVENTORY_CATEGORIES).map(category => ruleControls(policy.categories[category] ?? {}, rule =>
|
|
305
498
|
write({ ...policy, categories: { ...policy.categories, [category]: rule } }), t(`capabilities.policy.category.${category}`)))),
|
|
306
499
|
h('details', null, h('summary', { tabIndex: 0 }, t('capabilities.sourceRules')),
|
|
307
|
-
...[...new Set(
|
|
500
|
+
...[...new Set(Object.keys(policy.mcpServers))].sort(compare)
|
|
308
501
|
.map(server => ruleControls(policy.mcpServers[server] ?? {}, rule =>
|
|
309
502
|
write(changeCapabilityRule(changeCapabilityRule(policy, `mcpServers.${server}`, 'availability', rule.availability ?? 'automatic'),
|
|
310
503
|
`mcpServers.${server}`, 'disclosure', rule.disclosure ?? 'automatic')), server))))
|
|
@@ -322,340 +515,23 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
322
515
|
footer ? h('div', { className: 'ptcPlusCapabilityDialogFooter' }, footer) : null)
|
|
323
516
|
}
|
|
324
517
|
|
|
325
|
-
function CapabilityBatchDialog({ operation, value, entries, disabled, onChange, onClose, t }) {
|
|
326
|
-
const [choices, setChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
|
|
327
|
-
const [pending, setPending] = React.useState(false)
|
|
328
|
-
const [failed, setFailed] = React.useState(false)
|
|
329
|
-
const saving = React.useRef(false)
|
|
330
|
-
const mounted = React.useRef(true)
|
|
331
|
-
React.useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
|
332
|
-
const targetIds = new Set(entries.filter(entry => entry.availability === 'available').map(entry => entry.id))
|
|
333
|
-
const missing = operation.targets.filter(entry => !targetIds.has(entry.id)).length
|
|
334
|
-
const blocked = disabled('capabilityPolicy') || pending
|
|
335
|
-
const changed = Object.values(choices).some(choice => choice !== 'keep')
|
|
336
|
-
const close = () => { if (!saving.current) onClose() }
|
|
337
|
-
const confirm = async () => {
|
|
338
|
-
if (saving.current || blocked || missing || !changed || !operation.targets.length) return
|
|
339
|
-
let next = capabilityPolicyForConfig(value)
|
|
340
|
-
for (const target of operation.targets) for (const dimension of ['availability', 'disclosure']) {
|
|
341
|
-
if (choices[dimension] !== 'keep') next = changeCapabilityRule(next, target.id, dimension, choices[dimension])
|
|
342
|
-
}
|
|
343
|
-
saving.current = true; setPending(true); setFailed(false)
|
|
344
|
-
try {
|
|
345
|
-
const saved = await onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
346
|
-
if (!mounted.current) return
|
|
347
|
-
if (saved === true) onClose()
|
|
348
|
-
else setFailed(true)
|
|
349
|
-
} catch {
|
|
350
|
-
if (mounted.current) setFailed(true)
|
|
351
|
-
} finally {
|
|
352
|
-
saving.current = false
|
|
353
|
-
if (mounted.current) setPending(false)
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return h(Modal, { open: true, onClose: close, title: t('capabilities.batchSettings'),
|
|
357
|
-
closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
|
|
358
|
-
h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
|
|
359
|
-
h('button', { type: 'button', className: 'ptcPlusDialogClose', disabled: pending,
|
|
360
|
-
'aria-label': t('capabilities.batch.cancel'), onClick: close }, '×')),
|
|
361
|
-
h('div', { className: 'ptcPlusCapabilityBatchBody' },
|
|
362
|
-
h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.targets', { count: operation.targets.length })),
|
|
363
|
-
operation.excluded ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.excluded', { count: operation.excluded })) : null,
|
|
364
|
-
operation.targets.length ? h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
|
|
365
|
-
...operation.targets.map(target => h('li', { key: target.id }, h('span', null, target.name), h('code', null, target.id))))
|
|
366
|
-
: h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.empty')),
|
|
367
|
-
h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
|
|
368
|
-
h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
369
|
-
h(Select, { value: choices[dimension], disabled: blocked || !operation.targets.length,
|
|
370
|
-
label: t(`capabilities.batch.${dimension}`), onChange: choice => setChoices(current => ({ ...current, [dimension]: choice })),
|
|
371
|
-
options: ['keep', 'automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
372
|
-
.map(choice => ({ value: choice, label: t(choice === 'keep' ? 'capabilities.batch.keep'
|
|
373
|
-
: choice === 'automatic' ? 'capabilities.policy.inheritRule'
|
|
374
|
-
: choice === 'enabled' ? 'capabilities.action.enable'
|
|
375
|
-
: choice === 'closed' ? 'capabilities.action.close' : `capabilities.${choice}`) })) })))),
|
|
376
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.effect')),
|
|
377
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sourceClosure')),
|
|
378
|
-
missing ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.changedTargets', { count: missing })) : null,
|
|
379
|
-
failed ? h('p', { className: 'ptcPlusMessage', role: 'alert' }, t('capabilities.batch.failed')) : null),
|
|
380
|
-
h('div', { className: 'ptcPlusCapabilityBatchFooter' },
|
|
381
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending, onClick: close }, t('capabilities.batch.cancel')),
|
|
382
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
383
|
-
disabled: blocked || Boolean(missing) || !changed || !operation.targets.length, onClick: confirm },
|
|
384
|
-
t(pending ? 'state.syncing' : 'capabilities.batch.confirm', { count: operation.targets.length }))))
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function CapabilityChoices({ value, catalog, catalogError, plans, plansError, disabled, onChange, onReload, t, selectedSessionId, onSessionSelect }) {
|
|
388
|
-
const root = React.useRef(null)
|
|
389
|
-
const [query, setQuery] = React.useState('')
|
|
390
|
-
const [kind, setKind] = React.useState('all')
|
|
391
|
-
const [source, setSource] = React.useState('all')
|
|
392
|
-
const [strategy, setStrategy] = React.useState('all')
|
|
393
|
-
const [availabilityRule, setAvailabilityRule] = React.useState('all')
|
|
394
|
-
const [onlyCommon, setOnlyCommon] = React.useState(false)
|
|
395
|
-
const [availability, setAvailability] = React.useState('all')
|
|
396
|
-
const [sort, setSort] = React.useState(null)
|
|
397
|
-
const [selected, setSelected] = React.useState(null)
|
|
398
|
-
const [picked, setPicked] = React.useState([])
|
|
399
|
-
const [filtersOpen, setFiltersOpen] = React.useState(false)
|
|
400
|
-
const [batchOperation, setBatchOperation] = React.useState(null)
|
|
401
|
-
const [inheritedRulesOpen, setInheritedRulesOpen] = React.useState(false)
|
|
402
|
-
const [localSessionId, setLocalSessionId] = React.useState('')
|
|
403
|
-
const sessionId = onSessionSelect ? selectedSessionId : localSessionId
|
|
404
|
-
const setSessionId = next => { setLocalSessionId(next); onSessionSelect?.(next) }
|
|
405
|
-
const baseline = JSON.parse(value.capabilityBaseline ?? CONFIG_DEFAULTS.capabilityBaseline)
|
|
406
|
-
const policy = capabilityPolicyForConfig(value)
|
|
407
|
-
const entries = capabilitySettingsEntries(value, catalog, plans, Boolean(catalogError))
|
|
408
|
-
const sourceKey = entry => JSON.stringify([entry.source?.kind ?? 'unknown', entry.source?.server ?? null,
|
|
409
|
-
entry.source?.bucket ?? null, entry.source?.provider ?? null])
|
|
410
|
-
const sourceLabel = entry => {
|
|
411
|
-
if (entry.source?.kind === 'skill') {
|
|
412
|
-
const bucket = entry.source.bucket
|
|
413
|
-
const known = ['project-dsh', 'project-agents', 'user-dsh', 'user-agents', 'runtime', 'bundled', 'custom']
|
|
414
|
-
const label = known.includes(bucket) ? t(`capabilities.source.${bucket}`)
|
|
415
|
-
: bucket ? `${t('capabilities.source.other-skill')} · ${bucket}` : t('capabilities.source.unknown')
|
|
416
|
-
return `${label}${entry.source.provider ? ` · ${entry.source.provider}` : ''}`
|
|
417
|
-
}
|
|
418
|
-
if (entry.source?.kind === 'mcp') return `MCP${entry.source.server ? ` · ${entry.source.server}` : ''}`
|
|
419
|
-
return t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
|
|
420
|
-
}
|
|
421
|
-
const matchesKind = (entry, type) => type === 'all' || (type === 'resource' ? entry.source?.kind === 'mcp'
|
|
422
|
-
: type === 'tool' ? entry.kind === 'tool' && entry.source?.kind !== 'mcp' : type === entry.kind)
|
|
423
|
-
const sources = new Map(entries.filter(entry => matchesKind(entry, kind)).map(entry => [sourceKey(entry), sourceLabel(entry)]))
|
|
424
|
-
const setType = next => { setKind(next); if (!entries.some(entry => matchesKind(entry, next) && sourceKey(entry) === source)) setSource('all') }
|
|
425
|
-
const setCompatibleSource = next => {
|
|
426
|
-
setSource(next)
|
|
427
|
-
if (next !== 'all' && !entries.some(entry => matchesKind(entry, kind) && sourceKey(entry) === next)) setKind('all')
|
|
428
|
-
}
|
|
429
|
-
const normalizedQuery = query.trim().toLowerCase()
|
|
430
|
-
const filtered = entries.filter(entry => matchesKind(entry, kind)
|
|
431
|
-
&& (source === 'all' || sourceKey(entry) === source)
|
|
432
|
-
&& (strategy === 'all' || (entry.rule.disclosure ?? 'automatic') === strategy)
|
|
433
|
-
&& (availabilityRule === 'all' || (entry.rule?.availability ?? 'automatic') === availabilityRule)
|
|
434
|
-
&& (!onlyCommon || entry.common)
|
|
435
|
-
&& (availability === 'all' || entry.availability === availability)
|
|
436
|
-
&& `${entry.name} ${entry.id} ${entry.description ?? ''} ${sourceLabel(entry)}`.toLowerCase().includes(normalizedQuery))
|
|
437
|
-
const defaultOrder = (a, b) => compare(a.name.toLowerCase(), b.name.toLowerCase()) || compare(a.id, b.id)
|
|
438
|
-
const state = item => item.availability === 'unknown' ? 'unknown' : item.availability === 'unavailable' ? 'unavailable'
|
|
439
|
-
: value.capabilityContextEnabled === false || value.capabilityManagementEnabled !== true ? 'unapplied'
|
|
440
|
-
: item.resolvedRule.availabilitySource.startsWith('mcp:') && item.resolvedRule.availability === 'closed' ? 'source-closed' : item.resolvedRule.availability
|
|
441
|
-
const type = item => item.source?.kind === 'mcp' ? 'resource' : item.kind
|
|
442
|
-
const displayedSource = item => item.source?.kind === 'mcp' ? item.source.server ?? sourceLabel(item) : sourceLabel(item)
|
|
443
|
-
const groups = new Map()
|
|
444
|
-
for (const entry of filtered) {
|
|
445
|
-
const group = entry.source?.kind === 'mcp' && entry.source.server ? `mcp:${entry.source.server}`
|
|
446
|
-
: entry.availability === 'unavailable' ? 'unavailable' : entry.kind
|
|
447
|
-
if (!groups.has(group)) groups.set(group, [])
|
|
448
|
-
groups.get(group).push(entry)
|
|
449
|
-
}
|
|
450
|
-
const groupOrder = ['tool', ...[...groups.keys()].filter(key => key.startsWith('mcp:')).sort(compare), 'skill', 'resource', 'other', 'unavailable']
|
|
451
|
-
const defaultEntries = groupOrder.flatMap(group => (groups.get(group) ?? []).sort(defaultOrder))
|
|
452
|
-
const visible = sortCapabilityEntries(defaultEntries, sort, (item, column) => column === 'name' ? item.name
|
|
453
|
-
: column === 'type' ? t(`capabilities.type.${type(item)}`)
|
|
454
|
-
: column === 'source' ? displayedSource(item)
|
|
455
|
-
: column === 'availability' ? t(`capabilities.rowState.${state(item)}`)
|
|
456
|
-
: t(`capabilities.${item.resolvedRule.disclosure}`))
|
|
457
|
-
const pickedIds = picked.filter(id => entries.some(item => item.id === id))
|
|
458
|
-
const pickedEntries = pickedIds.map(id => entries.find(item => item.id === id)).filter(Boolean)
|
|
459
|
-
const pickedTargets = pickedEntries.filter(item => item.availability === 'available')
|
|
460
|
-
const allVisiblePicked = visible.length > 0 && visible.every(item => pickedIds.includes(item.id))
|
|
461
|
-
const someVisiblePicked = visible.some(item => pickedIds.includes(item.id))
|
|
462
|
-
const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
463
|
-
const toggleAllVisible = () => setPicked(current => allVisiblePicked
|
|
464
|
-
? current.filter(id => !visible.some(item => item.id === id))
|
|
465
|
-
: [...new Set([...current, ...visible.map(item => item.id)])])
|
|
466
|
-
const entry = visible.find(item => item.id === selected)
|
|
467
|
-
const plan = sessionId ? plans?.find(item => item.sessionId === sessionId) : undefined
|
|
468
|
-
const effective = plan?.effective.entries.find(item => item.id === entry?.id)
|
|
469
|
-
const change = (key, next) => onChange(field(key), JSON.stringify(next))
|
|
470
|
-
const setManual = (id, selection) => change('capabilityPolicy', changeCapabilityRule(policy, id, 'disclosure', selection))
|
|
471
|
-
const clear = () => { setQuery(''); setKind('all'); setSource('all'); setStrategy('all'); setAvailabilityRule('all'); setOnlyCommon(false); setAvailability('all') }
|
|
472
|
-
const activeFilters = Number(kind !== 'all') + Number(source !== 'all') + Number(strategy !== 'all') + Number(availabilityRule !== 'all') + Number(onlyCommon) + Number(availability !== 'all')
|
|
473
|
-
const select = (label, current, update, options, blocked = false) => h('div', { className: 'ptcPlusCapabilityFilter' },
|
|
474
|
-
h('span', null, label), h(Select, { label, value: current, disabled: blocked, onChange: update,
|
|
475
|
-
options: options.map(([key, text]) => ({ value: key, label: text })) }))
|
|
476
|
-
const editorField = (label, control) => h('div', { className: 'ptcPlusCapabilityField' }, h('span', null, label), control)
|
|
477
|
-
const manualSelect = item => h('div', { className: 'ptcPlusCapabilityEditorFields' },
|
|
478
|
-
editorField(t('capabilities.enabledColumn'), h(Select, { value: item.rule.availability ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
479
|
-
label: t('capabilities.policy.availabilityFor', { name: item.id }),
|
|
480
|
-
onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', next)),
|
|
481
|
-
options: ['automatic', 'enabled', 'closed'].map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : choice === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${choice}`) })) })),
|
|
482
|
-
editorField(t('capabilities.contextColumn'), h(Select, { value: item.rule.disclosure ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
483
|
-
label: t('capabilities.disclosureFor', { name: item.id }),
|
|
484
|
-
onChange: next => setManual(item.id, next), options: ['automatic', ...CAPABILITY_DISCLOSURES]
|
|
485
|
-
.map(choice => ({ value: choice, label: t(choice === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${choice}`) })) })))
|
|
486
|
-
const commonSwitch = item => h('div', { className: 'ptcPlusCapabilitySwitchRow' },
|
|
487
|
-
h('div', { className: 'ptcPlusCapabilitySwitchText' },
|
|
488
|
-
h('span', { className: 'ptcPlusCapabilitySwitchLabel' }, t('capabilities.commonBaseline')),
|
|
489
|
-
h('span', { className: 'ptcPlusCapabilitySwitchHint' }, t(item.common ? 'capabilities.inBaseline' : 'capabilities.notInBaseline'))),
|
|
490
|
-
h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', checked: item.common,
|
|
491
|
-
disabled: disabled('capabilityBaseline'), 'aria-label': t('capabilities.baselineFor', { name: item.id }),
|
|
492
|
-
onChange: event => change('capabilityBaseline', event.target.checked
|
|
493
|
-
? [...baseline.filter(id => id !== item.id), item.id] : baseline.filter(id => id !== item.id)) }))
|
|
494
|
-
const filterLabels = [kind !== 'all' ? t(`capabilities.type.${kind}`) : null,
|
|
495
|
-
source !== 'all' ? sources.get(source) ?? t('capabilities.previousSource') : null,
|
|
496
|
-
strategy !== 'all' ? t(strategy === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${strategy}`) : null,
|
|
497
|
-
availabilityRule !== 'all' ? t(availabilityRule === 'automatic' ? 'capabilities.policy.inheritRule'
|
|
498
|
-
: availabilityRule === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed') : null,
|
|
499
|
-
onlyCommon ? t('capabilities.onlyCommon') : null,
|
|
500
|
-
availability !== 'all' ? t(`capabilities.availability.${availability}`) : null].filter(Boolean)
|
|
501
|
-
const closeEditor = () => {
|
|
502
|
-
const button = [...(root.current?.querySelectorAll('[data-capability-edit-id]') ?? [])].find(item => item.dataset.capabilityEditId === selected)
|
|
503
|
-
setSelected(null); button?.focus({ preventScroll: true })
|
|
504
|
-
}
|
|
505
|
-
return h('div', { className: 'ptcPlusCapabilityChoices', ref: root },
|
|
506
|
-
h('div', { className: 'ptcPlusCapabilityToolbar' },
|
|
507
|
-
h('label', { className: 'ptcPlusSearch' }, h(SearchIcon),
|
|
508
|
-
h('input', { type: 'search', value: query, 'aria-label': t('capabilities.search'), placeholder: t('capabilities.search'),
|
|
509
|
-
onChange: event => setQuery(event.target.value) })),
|
|
510
|
-
h('div', { className: 'ptcPlusCapabilityTypes', role: 'group', 'aria-label': t('capabilities.types') },
|
|
511
|
-
...kinds.filter(key => key !== 'other' || entries.some(item => item.kind === 'other')).map(key => h('button', {
|
|
512
|
-
key, type: 'button', 'aria-pressed': kind === key, onClick: () => setType(key),
|
|
513
|
-
}, t(`capabilities.type.${key}`)))),
|
|
514
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
515
|
-
onClick: () => setFiltersOpen(true) }, t('capabilities.filterAction')),
|
|
516
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
517
|
-
'aria-label': t('capabilities.batchSettings'),
|
|
518
|
-
disabled: disabled('capabilityPolicy') || pickedTargets.length === 0,
|
|
519
|
-
onClick: () => setBatchOperation({
|
|
520
|
-
targets: pickedTargets.map(item => ({ id: item.id, name: item.name })),
|
|
521
|
-
excluded: pickedIds.length - pickedTargets.length,
|
|
522
|
-
}) }, t('capabilities.batchSettings'),
|
|
523
|
-
pickedIds.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
|
|
524
|
-
title: t('capabilities.selectionCount', { count: pickedIds.length }) },
|
|
525
|
-
String(pickedIds.length)) : null),
|
|
526
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
527
|
-
onClick: () => setInheritedRulesOpen(true) }, t('capabilities.inheritedRules'))),
|
|
528
|
-
filtersOpen ? h(CapabilityDialog, { title: t('capabilities.filterAction'), onClose: () => setFiltersOpen(false), t,
|
|
529
|
-
className: 'ptcPlusCapabilityFilterDialog', footer: h('button', { type: 'button', className: 'ptcPlusButton',
|
|
530
|
-
'data-kind': 'primary', onClick: () => setFiltersOpen(false) }, t('capabilities.dialogDone')) },
|
|
531
|
-
h('div', { className: 'ptcPlusCapabilityFilterFields' },
|
|
532
|
-
select(t('capabilities.sourceFilter'), source, setCompatibleSource, [['all', t('capabilities.allSources')], ...sources,
|
|
533
|
-
...(source !== 'all' && !sources.has(source) ? [[source, t('capabilities.previousSource')]] : [])]),
|
|
534
|
-
select(t('capabilities.policy.disclosureFilter'), strategy, setStrategy, [['all', t('capabilities.allStrategies')],
|
|
535
|
-
...['automatic', ...CAPABILITY_DISCLOSURES].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : `capabilities.${key}`)])]),
|
|
536
|
-
select(t('capabilities.policy.availabilityFilter'), availabilityRule, setAvailabilityRule, [['all', t('capabilities.allStrategies')],
|
|
537
|
-
...['automatic', 'enabled', 'closed'].map(key => [key, t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed')])]),
|
|
538
|
-
h('label', { className: 'ptcPlusCapabilityOnlyCommon' }, h('input', { type: 'checkbox', checked: onlyCommon,
|
|
539
|
-
onChange: event => setOnlyCommon(event.target.checked) }), t('capabilities.onlyCommon')),
|
|
540
|
-
select(t('capabilities.availabilityFilter'), availability, setAvailability,
|
|
541
|
-
['all', 'available', 'unavailable', 'unknown'].map(key => [key, t(`capabilities.availability.${key}`)])))) : null,
|
|
542
|
-
inheritedRulesOpen ? h(CapabilityDialog, { title: t('capabilities.inheritedRules'), onClose: () => setInheritedRulesOpen(false), t,
|
|
543
|
-
footer: h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
544
|
-
onClick: () => setInheritedRulesOpen(false) }, t('capabilities.dialogDone')) },
|
|
545
|
-
h(PolicyRules, { value, catalog, disabled, onChange, t })) : null,
|
|
546
|
-
batchOperation ? h(CapabilityBatchDialog, { operation: batchOperation, value, entries, disabled, onChange,
|
|
547
|
-
onClose: () => setBatchOperation(null), t }) : null,
|
|
548
|
-
activeFilters ? h('div', { className: 'ptcPlusActiveFilters' }, h('span', null, filterLabels.join(' · ')),
|
|
549
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) : null,
|
|
550
|
-
h('div', { className: 'ptcPlusCapabilityResultLine' }, h('span', { role: 'status' }, t('capabilities.results', { count: visible.length, total: entries.length })),
|
|
551
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadCatalog'))),
|
|
552
|
-
catalogError ? h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.catalogUnavailable', { error: catalogError })) : null,
|
|
553
|
-
entries.length === 0 ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.catalogEmpty'))
|
|
554
|
-
: visible.length === 0 ? h('div', { className: 'ptcPlusCapabilityEmpty' }, h('p', null, t('capabilities.noMatches')),
|
|
555
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: clear }, t('capabilities.clear'))) :
|
|
556
|
-
h('div', { className: 'ptcPlusCapabilityTable', role: 'table', 'aria-label': t('capabilities.management') },
|
|
557
|
-
h('div', { className: 'ptcPlusCapabilityColumns', role: 'row' },
|
|
558
|
-
...CAPABILITY_SORT_COLUMNS.map(column => h('span', { key: column.key, role: 'columnheader',
|
|
559
|
-
'aria-sort': capabilitySortDirection(sort, column.key),
|
|
560
|
-
...(column.key === 'name' ? { 'aria-label': t('capabilities.member'), className: 'ptcPlusCapabilityPickHead' } : {}) },
|
|
561
|
-
...(column.key === 'name' ? [h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: allVisiblePicked,
|
|
562
|
-
ref: node => { if (node) node.indeterminate = someVisiblePicked && !allVisiblePicked },
|
|
563
|
-
disabled: visible.length === 0, 'aria-label': t('capabilities.selectAllVisible'),
|
|
564
|
-
onChange: toggleAllVisible })] : []),
|
|
565
|
-
h('button', { type: 'button', className: 'ptcPlusCapabilitySort', title: t('capabilities.sortCycle'),
|
|
566
|
-
onClick: () => setSort(current => cycleCapabilitySort(current, column.key)) },
|
|
567
|
-
t(`capabilities.${column.label}`), h('span', { 'aria-hidden': true },
|
|
568
|
-
capabilitySortDirection(sort, column.key) === 'ascending' ? '↑' : capabilitySortDirection(sort, column.key) === 'descending' ? '↓' : '↕'))))),
|
|
569
|
-
h('div', { className: 'ptcPlusCapabilityList', role: 'rowgroup' }, ...visible.map(item => h('div', { key: item.id, className: 'ptcPlusCapabilityItem', role: 'row',
|
|
570
|
-
'data-selected': entry?.id === item.id, 'data-availability': item.availability,
|
|
571
|
-
onClick: () => setSelected(item.id) },
|
|
572
|
-
h('div', { className: 'ptcPlusCapabilityIdentity', role: 'cell' },
|
|
573
|
-
h('input', { type: 'checkbox', className: 'ptcPlusPick', checked: pickedIds.includes(item.id),
|
|
574
|
-
'aria-label': t('capabilities.selectForBatch', { name: item.name ?? item.id }),
|
|
575
|
-
onClick: event => event.stopPropagation(), onChange: () => togglePick(item.id) }),
|
|
576
|
-
h('div', { className: 'ptcPlusCapabilityIdentityText' },
|
|
577
|
-
h('span', { className: 'ptcPlusCapabilityName' }, item.name), h('code', null, item.id))),
|
|
578
|
-
h('span', { className: 'ptcPlusCapabilityBadge', role: 'cell', 'data-kind': type(item) }, t(`capabilities.type.${type(item)}`)),
|
|
579
|
-
h('span', { className: 'ptcPlusCapabilitySource', role: 'cell' }, displayedSource(item)),
|
|
580
|
-
// The cell stays a plain cell so its empty area selects the row; only the switch and
|
|
581
|
-
// its own label swallow the click, keeping a toggle from also opening the row editor.
|
|
582
|
-
h('div', { className: 'ptcPlusCapabilityAvailability', role: 'cell', 'data-state': state(item) },
|
|
583
|
-
h('input', { type: 'checkbox', role: 'switch', className: 'ptcPlusCheck', id: `ptcPlusCapabilityAvailability-${item.id}`,
|
|
584
|
-
checked: item.resolvedRule.availability === 'enabled',
|
|
585
|
-
disabled: disabled('capabilityPolicy') || state(item) === 'source-closed',
|
|
586
|
-
'aria-label': t('capabilities.policy.availabilityFor', { name: item.id }),
|
|
587
|
-
onClick: event => event.stopPropagation(),
|
|
588
|
-
onChange: event => change('capabilityPolicy', changeCapabilityRule(policy, item.id, 'availability', event.target.checked ? 'enabled' : 'closed')) }),
|
|
589
|
-
h('label', { htmlFor: `ptcPlusCapabilityAvailability-${item.id}`,
|
|
590
|
-
onClick: event => event.stopPropagation() }, t(`capabilities.rowState.${state(item)}`))),
|
|
591
|
-
h('div', { role: 'cell', className: 'ptcPlusCapabilityContextCell' }, h('button', { type: 'button', className: 'ptcPlusCapabilityContext', 'data-capability-edit-id': item.id,
|
|
592
|
-
'aria-label': t('capabilities.editContextFor', { name: item.id }), 'aria-haspopup': 'dialog',
|
|
593
|
-
onClick: event => { event.stopPropagation(); setSelected(item.id) } },
|
|
594
|
-
h('span', null, t(`capabilities.${item.resolvedRule.disclosure}`)),
|
|
595
|
-
h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, 'aria-hidden': true },
|
|
596
|
-
h('path', { d: 'm15 5 4 4M4 20l4-1L20 7l-4-4L4 15Z' })))))))),
|
|
597
|
-
entry ? h(CapabilityDialog, { title: entry.name, onClose: closeEditor, t, className: 'ptcPlusCapabilityEditorDialog',
|
|
598
|
-
footer: h('button', { type: 'button', className: 'ptcPlusButton', onClick: closeEditor }, t('capabilities.closeDetails')) },
|
|
599
|
-
h('code', null, entry.id), h('p', { className: 'ptcPlusDetail' }, t('capabilities.contextHelp')),
|
|
600
|
-
manualSelect(entry), commonSwitch(entry),
|
|
601
|
-
h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
602
|
-
: value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff'
|
|
603
|
-
: entry.resolvedRule.availabilitySource.startsWith('mcp:') && entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.sourceClosed'
|
|
604
|
-
: entry.resolvedRule.availability === 'closed' ? 'capabilities.effect.closed'
|
|
605
|
-
: `capabilities.effect.disclosure.${entry.resolvedRule.disclosure}`)),
|
|
606
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.policy.resolved', {
|
|
607
|
-
availability: t(entry.resolvedRule.availability === 'enabled' ? 'capabilities.quick.enabled' : 'capabilities.closed'),
|
|
608
|
-
disclosure: t(`capabilities.${entry.resolvedRule.disclosure}`),
|
|
609
|
-
source: `${entry.resolvedRule.availabilitySource} · ${entry.resolvedRule.disclosureSource}` })),
|
|
610
|
-
entry.rule.disclosure !== undefined ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink', disabled: disabled('capabilityPolicy'),
|
|
611
|
-
onClick: () => setManual(entry.id, 'automatic') }, t('capabilities.restoreAutomatic')) : null,
|
|
612
|
-
entry.source?.kind === 'mcp' && entry.mcpServer ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
613
|
-
h('h5', null, t('capabilities.sourceRules')),
|
|
614
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.serverHint')),
|
|
615
|
-
h('div', { className: 'ptcPlusCapabilityEditorFields' },
|
|
616
|
-
...['availability', 'disclosure'].map(dimension => editorField(t(`capabilities.policy.${dimension}`),
|
|
617
|
-
h(Select, { value: policy.mcpServers[entry.mcpServer]?.[dimension] ?? 'automatic', disabled: disabled('capabilityPolicy'),
|
|
618
|
-
label: `${entry.mcpServer} · ${t(`capabilities.policy.${dimension}`)}`,
|
|
619
|
-
onChange: next => change('capabilityPolicy', changeCapabilityRule(policy, `mcpServers.${entry.mcpServer}`, dimension, next)),
|
|
620
|
-
options: ['automatic', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)].map(key => ({ value: key,
|
|
621
|
-
label: t(key === 'automatic' ? 'capabilities.policy.inheritRule' : key === 'enabled' ? 'capabilities.quick.enabled' : `capabilities.${key}`) })) }))))) : null,
|
|
622
|
-
entry.kind === 'skill' ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.skillDisclosure')) : null,
|
|
623
|
-
entry.kind === 'skill' && entry.invocationConflict ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.invocation.varies')) : null,
|
|
624
|
-
entry.kind === 'skill' && entry.invocation ? h('p', { className: 'ptcPlusDetail' },
|
|
625
|
-
t(entry.invocation.modelInvocable ? entry.invocation.userInvocable ? 'capabilities.invocation.both'
|
|
626
|
-
: 'capabilities.invocation.model' : 'capabilities.invocation.user', { name: entry.name })) : null,
|
|
627
|
-
entry.description ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
628
|
-
h('h5', null, t('capabilities.catalogDescription')), h('p', { className: 'ptcPlusCapabilityProse' }, entry.description)) : null,
|
|
629
|
-
entry.availability === 'unavailable' ? h('button', { type: 'button', className: 'ptcPlusCapabilityLink',
|
|
630
|
-
disabled: disabled('capabilityBaseline') || disabled('capabilityPolicy'), onClick: () => {
|
|
631
|
-
const next = { ...policy, items: { ...policy.items } }; delete next.items[entry.id]; change('capabilityPolicy', next)
|
|
632
|
-
if (baseline.includes(entry.id)) change('capabilityBaseline', baseline.filter(id => id !== entry.id))
|
|
633
|
-
} }, t('capabilities.removeSaved')) : null,
|
|
634
|
-
value.capabilityContextEnabled !== false && value.capabilityManagementEnabled === true ? h('section', { className: 'ptcPlusCapabilityEditorSection' },
|
|
635
|
-
h('h5', null, t('capabilities.currentSession')),
|
|
636
|
-
editorField(t('capabilities.sessionSelector'), h(Select, { value: sessionId ?? '', label: t('capabilities.sessionSelector'), onChange: setSessionId,
|
|
637
|
-
options: [{ value: '', label: t('capabilities.chooseSession') }, ...(plans ?? []).map(item => ({ value: item.sessionId, label: item.sessionId }))] })),
|
|
638
|
-
plansError ? h('p', { role: 'status', className: 'ptcPlusDetail' }, t('capabilities.unavailable', { error: plansError }))
|
|
639
|
-
: !plan ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.selectObservation')) : h(React.Fragment, null,
|
|
640
|
-
h('p', null, effective ? t(`capabilities.${effective.selection}`) : t('capabilities.notInSession')),
|
|
641
|
-
effective ? h('p', { className: 'ptcPlusDetail' }, capabilityPlanReason(t, effective)) : null,
|
|
642
|
-
effective?.evidence ? h('p', { className: 'ptcPlusDetail' }, `${effective.evidence.used}/${effective.evidence.eligible} (${effective.evidence.scope})`) : null,
|
|
643
|
-
plan.pendingRefresh ? h('p', { className: 'ptcPlusDetail' }, t('capabilities.pending')) : null)) : null) : null)
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
518
|
function AllocationEvidence({ allocation, allocationError, allocationPending, selectedSessionId, statistics = false, onReload, t }) {
|
|
648
519
|
const [statisticsScope, setStatisticsScope] = React.useState('workspace')
|
|
649
520
|
if (allocationPending) return h('p', { role: 'status' }, t('state.syncing'))
|
|
650
521
|
if (allocationError) return h('p', { role: 'status', className: 'ptcPlusMessage' }, t('capabilities.unavailable', { error: allocationError }))
|
|
651
|
-
if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.
|
|
522
|
+
if (!allocation) return selectedSessionId ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.observationNotRead')) : null
|
|
652
523
|
const { plan, coverage, configuration } = allocation
|
|
653
524
|
const count = selection => plan.entries.filter(entry => entry.selection === selection).length
|
|
654
525
|
const evidenceFor = item => coverage?.[item.id]?.[statisticsScope]
|
|
655
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')
|
|
656
532
|
const coverageText = evidence => usable(evidence) && Number.isFinite(evidence.coverage) ? `${Math.round(evidence.coverage * 100)}%`
|
|
657
533
|
: usable(evidence) ? t('capabilities.statStatus.no-samples')
|
|
658
|
-
|
|
534
|
+
: sampleText(evidence)
|
|
659
535
|
return h('section', { className: 'ptcPlusAllocationEvidence', 'aria-label': t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview') },
|
|
660
536
|
h('div', { className: 'ptcPlusCapabilityResultLine' }, h('h4', null, t(statistics ? 'capabilities.observedUsage' : 'capabilities.budgetPreview')),
|
|
661
537
|
h('button', { type: 'button', className: 'ptcPlusCapabilityLink', onClick: onReload }, t('capabilities.reloadObservation'))),
|
|
@@ -666,14 +542,14 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
666
542
|
!statistics ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.coldStartHint')) : null,
|
|
667
543
|
!statistics ? h(React.Fragment, null,
|
|
668
544
|
h('div', { className: 'ptcPlusAllocationMetrics' },
|
|
669
|
-
...['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}`)))),
|
|
670
546
|
h('div', null, h('strong', null, `${plan.budgets.contractBytes} / ${configuration.capabilityBudgetBytes}`), h('span', null, 'UTF-8 bytes'))),
|
|
671
547
|
h('progress', { max: configuration.capabilityBudgetBytes, value: Math.min(configuration.capabilityBudgetBytes, plan.budgets.contractBytes),
|
|
672
548
|
'aria-label': t('capabilities.budgetPreview') }),
|
|
673
549
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.budget', { bytes: plan.budgets.contractBytes, overage: plan.budgets.overageBytes })),
|
|
674
550
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.directoryUsage', { bytes: plan.budgets.directoryBytes, budget: configuration.capabilityDirectoryBytes })),
|
|
675
551
|
!plan.enabled ? h('p', { className: 'ptcPlusCapabilityNotice' }, t('capabilities.managementOff')) : null)
|
|
676
|
-
: coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.
|
|
552
|
+
: coverage === null ? h('p', { role: 'status' }, t('capabilities.statStatus.not-reported')) : null,
|
|
677
553
|
h('div', { className: 'ptcPlusCapabilityEvidenceTable' }, h('table', null,
|
|
678
554
|
h('thead', null, h('tr', null, ...[t('capabilities.member'), ...(statistics
|
|
679
555
|
? [t('capabilities.usedFamilies'), t('capabilities.eligibleFamilies'), t('capabilities.coverageColumn')]
|
|
@@ -685,7 +561,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
685
561
|
h('td', { key: 'eligible' }, usable(evidenceFor(item)) ? evidenceFor(item).eligible : '—'),
|
|
686
562
|
h('td', { key: 'coverage' }, coverageText(evidenceFor(item)), usable(evidenceFor(item)) && Number.isFinite(evidenceFor(item).coverage)
|
|
687
563
|
? h('progress', { max: 1, value: evidenceFor(item).coverage, 'aria-label': t('capabilities.coverageFor', { name: item.id }) }) : null)]
|
|
688
|
-
: [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`)]))
|
|
689
565
|
})))))
|
|
690
566
|
}
|
|
691
567
|
|
|
@@ -845,7 +721,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
845
721
|
setting('group.capabilities', enabled('capabilityContextEnabled')),
|
|
846
722
|
setting('capabilities.management', enabled('capabilityManagementEnabled')),
|
|
847
723
|
setting('capabilities.statisticsEnabled', t(JSON.parse(configuration.capabilityStatistics).enabled ? 'capabilities.serverOn' : 'capabilities.serverOff')),
|
|
848
|
-
setting('capabilities.defaultDisclosure', t(`capabilities.${policy.defaults.disclosure}`)),
|
|
724
|
+
setting('capabilities.defaultDisclosure', t(`capabilities.word.${policy.defaults.disclosure}`)),
|
|
849
725
|
setting('capabilities.allocationTitle', ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key]).map(key => t(`capabilities.allocation.${key}`)).join(' · ') || '—'),
|
|
850
726
|
setting('capabilities.recoveryEnabled', enabled('capabilityRecoveryEnabled')),
|
|
851
727
|
setting('capabilities.previewEnabled', `${enabled('modelPreviewEnabled')}${configuration.modelPreviewEnabled ? ` · ${t('capabilities.estimatedTokens', { count: configuration.modelPreviewTokens })}` : ''}`),
|
|
@@ -878,14 +754,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
878
754
|
const saved = choicesFor(configurationReady ? value : undefined)
|
|
879
755
|
const retained = flagsFor(saved)
|
|
880
756
|
const applied = flagsFor(effectiveCapabilitySettings(saved))
|
|
881
|
-
|
|
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)
|
|
882
762
|
const write = next => onChange(field('capabilityPolicy'), JSON.stringify(next))
|
|
883
763
|
const adaptive = [policy.defaults, ...Object.values(policy.categories), ...Object.values(policy.mcpServers), ...Object.values(policy.items)]
|
|
884
764
|
.some(rule => rule.disclosure === 'adaptive')
|
|
885
765
|
const summary = key => {
|
|
886
766
|
if (choiceState(retained, applied, key) === 'unknown') return t('capabilities.combination.unknownSettings')
|
|
887
767
|
if (key === 'description') {
|
|
888
|
-
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}`) })
|
|
889
769
|
if (policy.defaults.disclosure !== 'adaptive') return description
|
|
890
770
|
const criteria = ['baseline', 'statistics', 'budget'].filter(key => policy.allocation[key])
|
|
891
771
|
.map(key => t(`capabilities.allocation.${key}`)).join(t('capabilities.combination.separator'))
|
|
@@ -932,7 +812,7 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
932
812
|
h('p', { className: 'ptcPlusDetail' }, t('capabilities.descriptionDependency')),
|
|
933
813
|
h('div', { className: 'ptcPlusCapabilityFilter' }, h('span', null, t('capabilities.defaultDisclosure')),
|
|
934
814
|
h(Select, { value: policy.defaults.disclosure, disabled: disabled('capabilityPolicy'), label: t('capabilities.defaultDisclosure'),
|
|
935
|
-
options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.${choice}`) })),
|
|
815
|
+
options: CAPABILITY_DISCLOSURES.map(choice => ({ value: choice, label: t(`capabilities.word.${choice}`) })),
|
|
936
816
|
onChange: disclosure => write({ ...policy, defaults: { ...policy.defaults, disclosure } }) })),
|
|
937
817
|
h('p', { className: 'ptcPlusDetail' }, effect(value.capabilityManagementEnabled !== true ? 'capabilities.effect.rulesOff' : `capabilities.effect.disclosure.${policy.defaults.disclosure}`)),
|
|
938
818
|
criteria,
|
|
@@ -1321,10 +1201,11 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1321
1201
|
h('p', { className: 'ptcPlusPromptIntro' }, t('capabilities.prompt.intro')))
|
|
1322
1202
|
}
|
|
1323
1203
|
|
|
1324
|
-
function CapabilityWorkbench({ value, configurationReady,
|
|
1325
|
-
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 }) {
|
|
1326
1206
|
const [tab, setTab] = React.useState('manual')
|
|
1327
1207
|
const [contextSection, setContextSection] = React.useState('description')
|
|
1208
|
+
const [rulesOpen, setRulesOpen] = React.useState(false)
|
|
1328
1209
|
const tabs = ['manual', 'budgets', 'statistics', 'prompt', 'plans']
|
|
1329
1210
|
const feedback = saving ? t('state.syncing') : saveStatus && saveStatus.key !== 'status.applied' ? t(saveStatus.key, saveStatus.params)
|
|
1330
1211
|
: t(configurationWritable ? 'capabilities.savedForNew' : 'footer.readOnly')
|
|
@@ -1348,7 +1229,18 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1348
1229
|
onChange: event => onChange(field('capabilityManagementEnabled'), event.target.checked) })) : null),
|
|
1349
1230
|
key === 'manual' ? h(React.Fragment, null, h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
1350
1231
|
: value.capabilityManagementEnabled === true ? 'capabilities.managementHelp' : 'capabilities.effect.rulesOff')),
|
|
1351
|
-
|
|
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)
|
|
1352
1244
|
: key === 'statistics' ? h(React.Fragment, null, renderField('capabilityStatistics'),
|
|
1353
1245
|
h('p', { className: 'ptcPlusDetail' }, t(value.capabilityContextEnabled === false ? 'capabilities.effect.paused'
|
|
1354
1246
|
: choicesFor(value).statistics?.enabled === true ? 'capabilities.effect.collectionOn'
|
|
@@ -1367,21 +1259,28 @@ export function createCapabilitySettingsViews(React, { SearchIcon, Select, Modal
|
|
|
1367
1259
|
return { CapabilityWorkbench }
|
|
1368
1260
|
}
|
|
1369
1261
|
|
|
1370
|
-
/**
|
|
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
|
+
*/
|
|
1371
1271
|
export function createCapabilityRefreshView(React, commands) {
|
|
1372
1272
|
const h = React.createElement
|
|
1373
|
-
const absent = Object.freeze({ getSnapshot: () => false, subscribe: () => () => {} })
|
|
1374
1273
|
return function CapabilityRefreshButton({ sessionId, enabled, t, onApplied, onInvalidated }) {
|
|
1375
|
-
const source = React.useMemo(() => sessionId ? commands.source(sessionId) : absent, [sessionId])
|
|
1376
|
-
const available = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
|
|
1377
1274
|
const [pending, setPending] = React.useState(false)
|
|
1378
1275
|
const [status, setStatus] = React.useState(null)
|
|
1379
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])
|
|
1380
1279
|
React.useEffect(() => {
|
|
1381
1280
|
setStatus(null)
|
|
1382
1281
|
setPending(false)
|
|
1383
1282
|
return () => { request.current?.abort(); request.current = null }
|
|
1384
|
-
}, [sessionId, enabled
|
|
1283
|
+
}, [sessionId, enabled])
|
|
1385
1284
|
const cancel = event => {
|
|
1386
1285
|
event.stopPropagation()
|
|
1387
1286
|
request.current?.abort(); request.current = null; setPending(false)
|
|
@@ -1390,7 +1289,7 @@ export function createCapabilityRefreshView(React, commands) {
|
|
|
1390
1289
|
}
|
|
1391
1290
|
const execute = async event => {
|
|
1392
1291
|
event.stopPropagation()
|
|
1393
|
-
if (!enabled ||
|
|
1292
|
+
if (!enabled || request.current) return
|
|
1394
1293
|
const controller = new AbortController()
|
|
1395
1294
|
request.current = controller
|
|
1396
1295
|
setPending(true)
|
|
@@ -1414,577 +1313,643 @@ export function createCapabilityRefreshView(React, commands) {
|
|
|
1414
1313
|
}
|
|
1415
1314
|
}
|
|
1416
1315
|
return h('div', { className: 'ptcPlusCapabilityRefresh' },
|
|
1417
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled ||
|
|
1418
|
-
title:
|
|
1316
|
+
h('button', { type: 'button', className: 'ptcPlusButton', disabled: !enabled || pending,
|
|
1317
|
+
title: sessionId ? undefined : t('capabilities.chooseSession'),
|
|
1419
1318
|
onClick: execute }, t(pending ? 'capabilities.refreshing' : 'capabilities.applySession')),
|
|
1420
1319
|
pending ? h('button', { type: 'button', className: 'ptcPlusButton', onClick: cancel }, t('capabilities.cancelRefresh')) : null,
|
|
1421
1320
|
status ? h('p', { className: 'ptcPlusDetail', role: 'status' }, capabilityCommandText(t, status))
|
|
1422
|
-
:
|
|
1321
|
+
: sessionId ? null : h('p', { className: 'ptcPlusDetail' }, t('capabilities.chooseSession')))
|
|
1423
1322
|
}
|
|
1424
1323
|
}
|
|
1425
1324
|
|
|
1426
|
-
/**
|
|
1427
|
-
|
|
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 }) {
|
|
1428
1344
|
const h = React.createElement
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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([])
|
|
1439
1402
|
const [picked, setPicked] = React.useState([])
|
|
1440
1403
|
const [batchOpen, setBatchOpen] = React.useState(false)
|
|
1441
1404
|
const [batchChoices, setBatchChoices] = React.useState({ availability: 'keep', disclosure: 'keep' })
|
|
1442
|
-
const [selectedId, setSelectedId] = React.useState(null)
|
|
1443
|
-
const [pane, setPane] = React.useState('list')
|
|
1444
1405
|
const [query, setQuery] = React.useState('')
|
|
1445
|
-
const [
|
|
1446
|
-
const [
|
|
1447
|
-
|
|
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.
|
|
1448
1410
|
const [sort, setSort] = React.useState(null)
|
|
1449
|
-
const [focusDimension, setFocusDimension] = React.useState(null)
|
|
1450
|
-
const [status, setStatus] = React.useState(null)
|
|
1451
|
-
const [pending, setPending] = React.useState(false)
|
|
1452
1411
|
const request = React.useRef(null)
|
|
1453
|
-
|
|
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)
|
|
1454
1416
|
const mounted = React.useRef(true)
|
|
1455
|
-
const detail = React.useRef(null)
|
|
1456
|
-
const list = React.useRef(null)
|
|
1457
|
-
React.useEffect(() => {
|
|
1458
|
-
if (!focusDimension) return
|
|
1459
|
-
const field = focusDimension === 'list' ? list.current?.querySelector('[data-selected="true"] .ptcPlusSessionCapabilityName')
|
|
1460
|
-
: detail.current?.querySelector(`[data-capability-dimension="${focusDimension}"] [role="combobox"]`)
|
|
1461
|
-
?? detail.current?.querySelector('[role="combobox"]')
|
|
1462
|
-
field?.focus({ preventScroll: true })
|
|
1463
|
-
setFocusDimension(null)
|
|
1464
|
-
}, [focusDimension, selectedId, pane])
|
|
1465
1417
|
React.useEffect(() => {
|
|
1466
1418
|
mounted.current = true
|
|
1467
|
-
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
|
+
}
|
|
1468
1427
|
}, [])
|
|
1469
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.
|
|
1470
1431
|
request.current?.abort()
|
|
1471
1432
|
request.current = null
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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([])
|
|
1477
1441
|
setPicked([])
|
|
1478
1442
|
setBatchOpen(false)
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
setPending(false)
|
|
1484
|
-
setError(null)
|
|
1443
|
+
setQuery('')
|
|
1444
|
+
setFamily('all')
|
|
1445
|
+
setSource('all')
|
|
1446
|
+
setSort(null)
|
|
1485
1447
|
}, [sessionId])
|
|
1486
1448
|
const load = React.useCallback(async signal => {
|
|
1487
|
-
|
|
1449
|
+
const mine = ++epoch.current
|
|
1488
1450
|
try {
|
|
1489
1451
|
const payload = await inspectSessionCatalog(sessionId, signal)
|
|
1490
|
-
if (
|
|
1491
|
-
|
|
1492
|
-
|
|
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')
|
|
1493
1458
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
setError(null)
|
|
1501
|
-
return payload.entries
|
|
1502
|
-
} catch (failure) {
|
|
1503
|
-
if (!signal.aborted && mounted.current && request.current?.signal === signal) setError(String(failure?.message ?? failure))
|
|
1504
|
-
} finally {
|
|
1505
|
-
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))
|
|
1506
1465
|
}
|
|
1507
1466
|
}, [sessionId])
|
|
1508
1467
|
React.useEffect(() => {
|
|
1509
|
-
if (!
|
|
1510
|
-
|
|
1511
|
-
|
|
1468
|
+
if (!active) {
|
|
1469
|
+
request.current?.abort()
|
|
1470
|
+
request.current = null
|
|
1512
1471
|
return
|
|
1513
1472
|
}
|
|
1514
|
-
// An in-flight apply owns the shared catalog request and reloads it on completion.
|
|
1515
|
-
if (applying.current) return
|
|
1516
1473
|
const controller = new AbortController()
|
|
1517
1474
|
request.current = controller
|
|
1518
1475
|
void load(controller.signal)
|
|
1519
|
-
return () => {
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
const choiceFor = entry => choices[entry.id] ?? entry.temporary ?? 'inherit'
|
|
1523
|
-
// Session writes also require the saved configuration to keep capability
|
|
1524
|
-
// management and context on; a frozen managed session cannot be edited
|
|
1525
|
-
// while the current settings have them off.
|
|
1526
|
-
const savedBlocks = savedSettings !== null && (savedSettings.enabled === false || savedSettings.contextEnabled === false)
|
|
1527
|
-
const writesBlocked = management === 'paused' || management === 'off' || savedBlocks
|
|
1528
|
-
const sourceKey = entry => entry.source?.kind === 'mcp' ? 'mcp:' + (entry.source.server ?? '')
|
|
1529
|
-
: entry.source?.kind === 'skill' ? 'skill:' + (entry.source.bucket ?? '')
|
|
1530
|
-
: entry.source?.kind ?? 'unknown'
|
|
1531
|
-
const sourceLabel = entry => entry.source?.kind === 'mcp' ? `MCP · ${entry.source.server ?? ''}`
|
|
1532
|
-
: entry.source?.kind === 'skill' ? `Skill · ${entry.source.bucket
|
|
1533
|
-
? t(`capabilities.source.${entry.source.bucket}`) : t('capabilities.source.unknown')}`
|
|
1534
|
-
: t(`capabilities.source.${entry.source?.kind === 'host' ? 'host' : 'unknown'}`)
|
|
1535
|
-
const category = entry => entry.category === 'skill' ? 'skills'
|
|
1536
|
-
: ['server', 'resource'].includes(entry.category) ? 'mcp'
|
|
1537
|
-
: entry.source?.kind === 'mcp' ? 'mcp_tools' : 'tools'
|
|
1538
|
-
const type = entry => entry.category === 'server' ? 'server'
|
|
1539
|
-
: entry.source?.kind === 'mcp' ? 'resource'
|
|
1540
|
-
: entry.category === 'skill' ? 'skill'
|
|
1541
|
-
: entry.category === 'resource' ? 'resource'
|
|
1542
|
-
: entry.category === 'tool' ? 'tool' : 'other'
|
|
1543
|
-
const displayedSource = entry => entry.source?.kind === 'mcp' ? entry.source.server ?? sourceLabel(entry) : sourceLabel(entry)
|
|
1544
|
-
const effectiveAvailability = entry => entry.effective === 'pending' ? 'pending'
|
|
1545
|
-
: entry.enclosingClosure || entry.effective === 'closed' ? 'closed'
|
|
1546
|
-
: entry.effectiveDimensions?.availability
|
|
1547
|
-
?? (['full', 'on-demand', 'host', 'enabled'].includes(entry.effective) ? 'enabled' : 'unknown')
|
|
1548
|
-
const effectiveContext = entry => entry.effective === 'pending' ? 'pending'
|
|
1549
|
-
: effectiveAvailability(entry) === 'closed' ? 'closed'
|
|
1550
|
-
: entry.effectiveDimensions?.disclosure
|
|
1551
|
-
?? (['full', 'on-demand', 'host'].includes(entry.effective) ? entry.effective : 'unknown')
|
|
1552
|
-
const sources = [...new Set(catalog.filter(entry => kind === 'all' || category(entry) === kind).map(sourceKey))].sort()
|
|
1553
|
-
const setCompatibleKind = next => {
|
|
1554
|
-
setKind(next)
|
|
1555
|
-
if (sourceFilter !== 'all' && !catalog.some(entry => (next === 'all' || category(entry) === next)
|
|
1556
|
-
&& sourceKey(entry) === sourceFilter)) setSourceFilter('all')
|
|
1557
|
-
}
|
|
1558
|
-
const setCompatibleSource = next => {
|
|
1559
|
-
setSourceFilter(next)
|
|
1560
|
-
if (next !== 'all' && !catalog.some(entry => (kind === 'all' || category(entry) === kind)
|
|
1561
|
-
&& sourceKey(entry) === next)) setKind('all')
|
|
1562
|
-
}
|
|
1563
|
-
const filtered = catalog.filter(entry => {
|
|
1564
|
-
const text = [entry.name, entry.id, entry.description, entry.source?.server, entry.source?.bucket].filter(Boolean).join(' ').toLocaleLowerCase()
|
|
1565
|
-
return (kind === 'all' || category(entry) === kind)
|
|
1566
|
-
&& (sourceFilter === 'all' || sourceKey(entry) === sourceFilter)
|
|
1567
|
-
&& (stateFilter === 'all' || (stateFilter === 'changed' ? Object.hasOwn(choices, entry.id) : entry.effective === stateFilter))
|
|
1568
|
-
&& text.includes(query.trim().toLocaleLowerCase())
|
|
1569
|
-
}).sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id))
|
|
1570
|
-
const groups = ['tools', 'skills', 'mcp', 'mcp_tools']
|
|
1571
|
-
const defaultOrder = groups.flatMap(group => filtered.filter(entry => category(entry) === group))
|
|
1572
|
-
const matches = sortCapabilityEntries(defaultOrder, sort, (entry, column) => column === 'name' ? entry.name ?? entry.id
|
|
1573
|
-
: column === 'type' ? t(`capabilities.type.${type(entry)}`)
|
|
1574
|
-
: column === 'source' ? displayedSource(entry)
|
|
1575
|
-
: column === 'availability' ? capabilityChoiceLabel(t, effectiveAvailability(entry))
|
|
1576
|
-
: capabilityChoiceLabel(t, effectiveContext(entry)))
|
|
1577
|
-
const selected = matches.find(entry => entry.id === selectedId)
|
|
1578
|
-
?? ['tools', 'skills', 'mcp', 'mcp_tools'].map(group => matches.find(entry => category(entry) === group)).find(Boolean)
|
|
1579
|
-
const staged = Object.entries(choices).flatMap(([id, choice]) => typeof choice === 'object'
|
|
1580
|
-
? Object.entries(choice).map(([dimension, selection]) => [id, selection, dimension]) : [[id, choice]])
|
|
1581
|
-
const pickedRows = picked.map(id => catalog.find(entry => entry.id === id)).filter(Boolean)
|
|
1582
|
-
const allMatchesPicked = matches.length > 0 && matches.every(entry => picked.includes(entry.id))
|
|
1583
|
-
const someMatchesPicked = matches.some(entry => picked.includes(entry.id))
|
|
1584
|
-
const togglePick = id => setPicked(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
|
1585
|
-
const toggleAllMatches = () => setPicked(current => allMatchesPicked
|
|
1586
|
-
? current.filter(id => !matches.some(entry => entry.id === id))
|
|
1587
|
-
: [...new Set([...current, ...matches.map(entry => entry.id)])])
|
|
1588
|
-
// One staging rule for single and batch edits: an item keeps one temporary
|
|
1589
|
-
// patch, "inherit" removes the dimension and reselecting the stored choice
|
|
1590
|
-
// never dirties the draft.
|
|
1591
|
-
const stageDimension = (current, entry, dimension, choice) => {
|
|
1592
|
-
const next = { ...current }
|
|
1593
|
-
const patch = typeof next[entry.id] === 'object' && next[entry.id] !== null ? { ...next[entry.id] } : {}
|
|
1594
|
-
const original = storedDimension(entry, dimension) ?? 'inherit'
|
|
1595
|
-
const selectedChoice = patch[dimension] ?? original
|
|
1596
|
-
if (choice !== selectedChoice) {
|
|
1597
|
-
if (choice === original) delete patch[dimension]
|
|
1598
|
-
else patch[dimension] = choice
|
|
1476
|
+
return () => {
|
|
1477
|
+
controller.abort()
|
|
1478
|
+
if (request.current === controller) request.current = null
|
|
1599
1479
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1603
|
-
const
|
|
1604
|
-
const entry = catalog.find(item => item.id === id)
|
|
1605
|
-
if (entry) setDrafts(current => stageDimension(current, entry, dimension, choice))
|
|
1606
|
-
}
|
|
1607
|
-
const choose = (id, next) => {
|
|
1608
|
-
const entry = catalog.find(item => item.id === id)
|
|
1609
|
-
if (!entry) return
|
|
1610
|
-
const original = entry.temporary ?? 'inherit'
|
|
1611
|
-
if (next === (choices[id] ?? original)) return
|
|
1612
|
-
setDrafts(current => {
|
|
1613
|
-
const nextChoices = { ...current }
|
|
1614
|
-
if (next === original) delete nextChoices[id]
|
|
1615
|
-
else nextChoices[id] = next
|
|
1616
|
-
return nextChoices
|
|
1617
|
-
})
|
|
1618
|
-
}
|
|
1619
|
-
const stageBatch = (ids, selections) => setDrafts(current => ids.reduce((draft, id) => {
|
|
1620
|
-
const entry = catalog.find(item => item.id === id)
|
|
1621
|
-
if (!entry) return draft
|
|
1622
|
-
return ['availability', 'disclosure'].reduce((next, dimension) =>
|
|
1623
|
-
selections[dimension] === undefined || selections[dimension] === 'keep'
|
|
1624
|
-
? next : stageDimension(next, entry, dimension, selections[dimension]), draft)
|
|
1625
|
-
}, current))
|
|
1626
|
-
const hasInheritanceReset = staged.some(([, selection]) => selection === 'inherit')
|
|
1627
|
-
const apply = async targetScope => {
|
|
1628
|
-
if (applying.current || !enabled || !available || writesBlocked || staged.length === 0) return
|
|
1629
|
-
if (targetScope === 'global' && hasInheritanceReset) return
|
|
1630
|
-
const submitted = structuredClone(choices)
|
|
1631
|
-
applying.current = true
|
|
1632
|
-
setPending(true)
|
|
1633
|
-
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 = () => {
|
|
1634
1484
|
const controller = new AbortController()
|
|
1635
1485
|
request.current?.abort()
|
|
1636
1486
|
request.current = controller
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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)
|
|
1644
1502
|
try {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
if (receipt?.state === 'pending') pendingCount += 1
|
|
1658
|
-
completed += 1
|
|
1659
|
-
} catch (cause) {
|
|
1660
|
-
failure = String(cause?.message ?? cause)
|
|
1661
|
-
savedOnly = readCapabilityCommandReceipt(failure)?.state === 'saved-only'
|
|
1662
|
-
? readCapabilityCommandReceipt(failure) : null
|
|
1663
|
-
break
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
if (request.current !== controller || controller.signal.aborted || !mounted.current) return
|
|
1667
|
-
const refreshed = await load(controller.signal)
|
|
1668
|
-
if (request.current !== controller || controller.signal.aborted || !mounted.current) return
|
|
1669
|
-
const confirmed = refreshed ? staged.slice(0, completed).filter(([id, selection, dimension]) => {
|
|
1670
|
-
const entry = refreshed.find(item => item.id === id)
|
|
1671
|
-
if (dimension) return entry && (targetScope === 'session'
|
|
1672
|
-
? (entry.temporaryRule?.[dimension] ?? 'inherit') === selection
|
|
1673
|
-
: (entry.savedRule?.[dimension] ?? 'automatic') === selection && entry.temporaryRule?.[dimension] === undefined)
|
|
1674
|
-
return entry && (targetScope === 'session' ? entry.temporary === selection
|
|
1675
|
-
: entry.saved === selection && entry.temporary === 'inherit')
|
|
1676
|
-
}) : []
|
|
1677
|
-
setDrafts(current => {
|
|
1678
|
-
const nextChoices = { ...current }
|
|
1679
|
-
for (const [id, selection, dimension] of confirmed) {
|
|
1680
|
-
if (savedOnly && id === staged[completed]?.[0] && dimension === staged[completed]?.[2]) continue
|
|
1681
|
-
if (dimension) {
|
|
1682
|
-
if (nextChoices[id]?.[dimension] !== submitted[id]?.[dimension]) continue
|
|
1683
|
-
const patch = { ...nextChoices[id] }; delete patch[dimension]
|
|
1684
|
-
if (Object.keys(patch).length) nextChoices[id] = patch; else delete nextChoices[id]
|
|
1685
|
-
} else if (nextChoices[id] === submitted[id]) delete nextChoices[id]
|
|
1686
|
-
}
|
|
1687
|
-
return nextChoices
|
|
1688
|
-
})
|
|
1689
|
-
if (savedOnly) setStatus({ key: 'capabilities.quick.savedOnly', params: { count: completed, error: savedOnly.error ?? '' } })
|
|
1690
|
-
else if (failure || !refreshed || confirmed.length < completed) setStatus({ key: 'capabilities.quick.partial',
|
|
1691
|
-
params: { count: confirmed.length, error: failure ?? t('capabilities.quick.confirmationUnavailable') } })
|
|
1692
|
-
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 }
|
|
1693
1515
|
} finally {
|
|
1694
|
-
|
|
1516
|
+
writes.current.delete(controller)
|
|
1517
|
+
if (mounted.current) setWriting(current => current.filter(item => item !== id))
|
|
1695
1518
|
}
|
|
1696
1519
|
}
|
|
1697
|
-
const
|
|
1698
|
-
const
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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)
|
|
1709
1550
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
// The
|
|
1716
|
-
//
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
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')}`
|
|
1581
|
+
}
|
|
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}`])])
|
|
1726
1606
|
}
|
|
1727
|
-
|
|
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(' · ')}`
|
|
1728
1615
|
}
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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)
|
|
1739
1628
|
}
|
|
1740
|
-
const
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
+
})
|
|
1636
|
+
}
|
|
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
|
|
1749
1740
|
}
|
|
1750
|
-
const
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
const
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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' }))))
|
|
1768
1769
|
}
|
|
1769
|
-
const
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
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())
|
|
1773
1787
|
}
|
|
1774
|
-
const
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
if (
|
|
1780
|
-
|
|
1781
|
-
|
|
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))
|
|
1782
1796
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
|
1821
|
+
}
|
|
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) }
|
|
1801
1835
|
}
|
|
1802
|
-
|
|
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: {} } })
|
|
1803
1840
|
}
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
h('
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
h('
|
|
1842
|
-
h('
|
|
1843
|
-
h('
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
'
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
h('
|
|
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
|
-
h('h4', null, t('capabilities.quick.serverTools')),
|
|
1918
|
-
h('ul', null, ...catalog.filter(entry => entry.id !== selected.id
|
|
1919
|
-
&& entry.source?.kind === 'mcp' && entry.source.server === selected.source?.server)
|
|
1920
|
-
.map(entry => h('li', { key: entry.id }, entry.name ?? entry.id)))) : null,
|
|
1921
|
-
selected.savedRule ? h(React.Fragment, null, ...['availability', 'disclosure'].map(dimension => h(React.Fragment, { key: dimension },
|
|
1922
|
-
h('div', { className: 'ptcPlusCapabilityFilter', 'data-capability-dimension': dimension },
|
|
1923
|
-
h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
1924
|
-
h(Select, { label: t(`capabilities.policy.${dimension}`), disabled: !enabled || !available || pending || writesBlocked,
|
|
1925
|
-
value: dimensionCurrent(selected, dimension),
|
|
1926
|
-
onChange: next => chooseDimension(selected.id, dimension, next),
|
|
1927
|
-
options: dimensionOptions(selected, dimension) })),
|
|
1928
|
-
h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': dimension },
|
|
1929
|
-
impactLine(dimensionImpact(selected, dimension))))))
|
|
1930
|
-
: h(React.Fragment, null,
|
|
1931
|
-
h('div', { className: 'ptcPlusCapabilityFilter' },
|
|
1932
|
-
h('span', null, t('capabilities.quick.choice')),
|
|
1933
|
-
h(Select, { label: t('capabilities.quick.choice'), value: choiceCurrent(selected),
|
|
1934
|
-
disabled: !enabled || !available || pending || writesBlocked, options: choiceOptions,
|
|
1935
|
-
onChange: next => choose(selected.id, next) })),
|
|
1936
|
-
h('p', { className: 'ptcPlusDetail ptcPlusCapabilityImpact', 'data-impact-dimension': 'selection' },
|
|
1937
|
-
impactLine(choiceImpact(selected)))),
|
|
1938
|
-
(typeof choices[selected.id] === 'string' ? choices[selected.id] === 'inherit'
|
|
1939
|
-
: Object.values(choices[selected.id] ?? {}).includes('inherit')) ? h('p', { className: 'ptcPlusDetail' },
|
|
1940
|
-
t('capabilities.quick.inheritSessionOnly')) : null) : h('p', null, t('capabilities.noMatches')))),
|
|
1941
|
-
batchOpen ? h(Modal, { open: true, onClose: () => setBatchOpen(false), title: t('capabilities.batchSettings'),
|
|
1942
|
-
closeLabel: t('capabilities.batch.cancel'), headless: true, className: 'ptcPlusCapabilityBatchModal' },
|
|
1943
|
-
h('div', { className: 'ptcPlusCapabilityBatchHead' }, h('h2', null, t('capabilities.batchSettings')),
|
|
1944
|
-
h('button', { type: 'button', className: 'ptcPlusDialogClose', 'aria-label': t('capabilities.batch.cancel'),
|
|
1945
|
-
onClick: () => setBatchOpen(false) }, '\u00d7')),
|
|
1946
|
-
h('div', { className: 'ptcPlusCapabilityBatchBody' },
|
|
1947
|
-
h('p', { className: 'ptcPlusDetail', role: 'status' }, t('capabilities.batch.selected', { count: pickedRows.length })),
|
|
1948
|
-
h('ul', { className: 'ptcPlusCapabilityBatchTargets', 'aria-label': t('capabilities.batch.targetList') },
|
|
1949
|
-
...pickedRows.map(entry => h('li', { key: entry.id }, h('span', null, entry.name ?? entry.id), h('code', null, entry.id)))),
|
|
1950
|
-
h('div', { className: 'ptcPlusCapabilityBatchFields' }, ...['availability', 'disclosure'].map(dimension =>
|
|
1951
|
-
h('div', { key: dimension, className: 'ptcPlusCapabilityFilter' }, h('span', null, t(`capabilities.policy.${dimension}`)),
|
|
1952
|
-
h(Select, { value: batchChoices[dimension], label: t(`capabilities.batch.${dimension}`),
|
|
1953
|
-
onChange: choice => setBatchChoices(current => ({ ...current, [dimension]: choice })),
|
|
1954
|
-
options: ['keep', 'inherit', ...(dimension === 'availability' ? ['enabled', 'closed'] : CAPABILITY_DISCLOSURES)]
|
|
1955
|
-
.map(choice => ({ value: choice, label: choice === 'keep' ? t('capabilities.batch.keep')
|
|
1956
|
-
: choice === 'inherit' ? t('capabilities.batch.sessionInherit')
|
|
1957
|
-
: choice === 'enabled' ? t('capabilities.action.enable')
|
|
1958
|
-
: choice === 'closed' ? t('capabilities.action.close') : t(`capabilities.${choice}`) })) })))),
|
|
1959
|
-
h('p', { className: 'ptcPlusDetail' }, t('capabilities.batch.sessionStage'))),
|
|
1960
|
-
h('div', { className: 'ptcPlusCapabilityBatchFooter' },
|
|
1961
|
-
h('button', { type: 'button', className: 'ptcPlusButton', onClick: () => setBatchOpen(false) }, t('capabilities.batch.cancel')),
|
|
1962
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary',
|
|
1963
|
-
disabled: pickedRows.length === 0 || !['availability', 'disclosure'].some(dimension => batchChoices[dimension] !== 'keep'),
|
|
1964
|
-
onClick: () => { stageBatch(pickedRows.map(entry => entry.id), batchChoices); setBatchOpen(false) } },
|
|
1965
|
-
t('capabilities.batch.stage', { count: pickedRows.length })))) : null,
|
|
1966
|
-
h('div', { className: 'ptcPlusSessionCapabilityFooter' },
|
|
1967
|
-
h('span', { role: 'status' }, pending ? t('capabilities.quick.applying') : status ? t(status.key, status.params)
|
|
1968
|
-
: t('capabilities.quick.changes', { count: staged.length })),
|
|
1969
|
-
h('button', { type: 'button', className: 'ptcPlusButton', disabled: pending || staged.length === 0, onClick: discard },
|
|
1970
|
-
t('capabilities.quick.discard')),
|
|
1971
|
-
// Session-scoped temporary application stays the footer's primary action; saving global
|
|
1972
|
-
// defaults is an explicit secondary choice, so no default-looking control changes global state.
|
|
1973
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', title: t('capabilities.quick.sessionHint'),
|
|
1974
|
-
disabled: pending || !enabled || !available || writesBlocked || staged.length === 0, onClick: () => void apply('session') },
|
|
1975
|
-
t('capabilities.quick.applySession')),
|
|
1976
|
-
h('button', { type: 'button', className: 'ptcPlusButton',
|
|
1977
|
-
title: t(hasInheritanceReset ? 'capabilities.quick.inheritSessionOnly' : 'capabilities.quick.globalHint'),
|
|
1978
|
-
disabled: pending || !enabled || !available || writesBlocked || staged.length === 0 || hasInheritanceReset,
|
|
1979
|
-
onClick: () => void apply('global') },
|
|
1980
|
-
t('capabilities.quick.applyGlobal')),
|
|
1981
|
-
h('button', { type: 'button', className: 'ptcPlusButton', 'aria-haspopup': 'dialog',
|
|
1982
|
-
'aria-label': t('capabilities.batchSettings'),
|
|
1983
|
-
disabled: pending || writesBlocked || picked.length === 0,
|
|
1984
|
-
onClick: () => { setBatchChoices({ availability: 'keep', disclosure: 'keep' }); setBatchOpen(true) } },
|
|
1985
|
-
t('capabilities.batchSettings'),
|
|
1986
|
-
picked.length ? h('span', { className: 'ptcPlusCapabilityBatchCount', 'aria-hidden': true,
|
|
1987
|
-
title: t('capabilities.selectionCount', { count: picked.length }) },
|
|
1988
|
-
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)
|
|
1989
1954
|
}
|
|
1990
1955
|
}
|