Release / release (push) Successful in 6s
The per-session scope the host half now reads had no writer. This is it: a crosshair on the composer tool row, present only in sessions running the facet preset, opening a panel that sets the focus and the paths allowed outside it. The gate is the session's own `agentPreset` projection rather than anything inferred from the URL or from the plugin being installed, so a control for a plugin that is not running never appears. It writes through the settings document — the same one the plugin card edits — because `settings.mutate` accepts a path under a volatile node and refuses anything else. That is what makes the split real rather than aspirational: the agent has no write path to `scopes`, and the panel's write is the only one that can widen a session. The panel also carries the mode details and the usage notes, which the preset picker's guide dialog cannot show for a preset no core package ships — its guide table holds four shipped ids and is looked up only for built-ins. Also adds `@deepseek-ai/dsh-client-ui-conversation` to the client half's inject list: registering into `conversation.input.left` is a dependency on the package that declares it, and `slots.inject` waits for a slot rather than creating one, so naming another package's slot without loading it first registers nothing — silently, because nothing errors.
649 lines
25 KiB
JavaScript
649 lines
25 KiB
JavaScript
/**
|
|
* dsh-facet — the browser half.
|
|
*
|
|
* **Why this file exists when the host half already declares the schema.** The
|
|
* Plugins page does not generate a card from a `Config` schema. It draws whatever
|
|
* browser halves register into the slots it declares, and `plugins.bundle.config`
|
|
* is the seat for one package's own configuration. Marking a field `volatile()`
|
|
* makes the Host *serve* that namespace — `settings.describe()` drops an entry
|
|
* with no volatile field, and `configForms.whileServed()` below refuses to
|
|
* register a page the Host does not serve — but serving it is not drawing it. No
|
|
* browser half means no card, however the schema is marked.
|
|
*
|
|
* **The two keys below are not the same string, and that is the trap.** Every
|
|
* other plugin in this installation happens to name them identically, so the
|
|
* distinction never shows up until it does:
|
|
*
|
|
* * the settings DOCUMENT is filed under the plugin's profile entry id —
|
|
* `facet-policy`, the `id` of this bundle's row in `cordis.patch.yml`.
|
|
* Exporting `Config` from `index.js` is the registration; this is the key
|
|
* `configForms.get()` addresses.
|
|
* * the CARD is filed under the bundle's PACKAGE NAME — `dsh-facet`. That is
|
|
* what the Plugins page dispatches `plugins.bundle.config` by, and it goes on
|
|
* the bundle's own page between its description and its rows.
|
|
*
|
|
* Bind the form to the wrong one and the card renders, saves, and writes nowhere.
|
|
*
|
|
* **Loadable without a bundler.** `react` and the primitives package are both in
|
|
* the boot seed table; the settings document and the slot ledgers arrive as
|
|
* Cordis services or slot props. Nothing here reaches into another plugin's
|
|
* bundle.
|
|
*/
|
|
|
|
window.__ModuleLoader__.load({
|
|
id: 'dsh-facet',
|
|
factory: (require) => {
|
|
var module = { exports: {} }
|
|
var exports = module.exports
|
|
|
|
const react = require('react')
|
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
|
|
const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
|
|
|
|
const h = react.createElement
|
|
|
|
/** The settings document's key: this bundle's row id, not its package name. */
|
|
const SETTINGS_ENTRY = 'facet-policy'
|
|
|
|
/** The card's key: the bundle's package name, not its row id. */
|
|
const BUNDLE_CONFIG_KEY = 'dsh-facet'
|
|
|
|
/** The modes the host schema accepts, in the order the control offers them. */
|
|
const MODES = ['ask', 'deny', 'off']
|
|
|
|
const TITLE = 'Facet'
|
|
const DESCRIPTION = 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.'
|
|
|
|
const OVERRIDDEN_LABEL = 'Overridden'
|
|
const RESET_LABEL = 'Reset to default'
|
|
|
|
const MODE_LABELS = { ask: 'Ask', deny: 'Deny', off: 'Off' }
|
|
const MODE_OPTIONS = MODES.map((value) => ({ value, label: MODE_LABELS[value] }))
|
|
|
|
const labelStyle = {
|
|
display: 'block',
|
|
marginBottom: '6px',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
}
|
|
const hintStyle = {
|
|
margin: '6px 0 0',
|
|
fontSize: '12px',
|
|
lineHeight: '18px',
|
|
color: 'var(--dsw-alias-label-tertiary)',
|
|
}
|
|
const badgeStyle = {
|
|
marginLeft: '8px',
|
|
fontSize: '11px',
|
|
fontWeight: 400,
|
|
color: 'var(--dsw-alias-label-tertiary)',
|
|
}
|
|
|
|
/**
|
|
* A finite-choice field, staged as the chosen value.
|
|
*
|
|
* The primitives ship `settingsNumberField` and `settingsTextField` and
|
|
* deliberately stop there: only the owning plugin knows what its own
|
|
* vocabulary is. An unrecognised draft returns `undefined`, which marks the
|
|
* field invalid and blocks the save rather than quietly coercing a typo into
|
|
* a mode the schema would reject at the Host anyway.
|
|
*
|
|
* @param {string} field - field name inside the namespace section.
|
|
* @param {readonly string[]} choices - the values the host schema accepts.
|
|
*/
|
|
function settingsChoiceField(field, choices) {
|
|
return {
|
|
field,
|
|
format: (value) => (typeof value === 'string' && choices.includes(value) ? value : ''),
|
|
parse: (text) => {
|
|
const normalised = String(text).trim().toLowerCase()
|
|
if (normalised === '') return { kind: 'clear' }
|
|
if (choices.includes(normalised)) return { kind: 'set', value: normalised }
|
|
return undefined
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A whole-list field: comma or newline separated in the control, an array of
|
|
* non-empty strings in the document.
|
|
*
|
|
* Empty stages a clear rather than an empty array. The schema's own default
|
|
* is `[]`, so writing one would shadow a deployment's composed default with
|
|
* nothing and leave the field marked overridden when the user meant to
|
|
* remove their override.
|
|
*
|
|
* @param {string} field - field name inside the namespace section.
|
|
*/
|
|
function settingsListField(field) {
|
|
return {
|
|
field,
|
|
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
|
|
parse: (text) => {
|
|
const parts = String(text)
|
|
.split(/[,\n]/)
|
|
.map((part) => part.trim())
|
|
.filter((part) => part !== '')
|
|
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The facet page's staged form over the composed `facet-policy` entry. */
|
|
var FacetCardController = class {
|
|
/**
|
|
* @param {object} scope - the shared configuration form for `facet-policy`.
|
|
*/
|
|
constructor(scope) {
|
|
this.form = new SettingsFormModel(scope, [
|
|
settingsChoiceField('mode', MODES),
|
|
settingsListField('extraRoots'),
|
|
])
|
|
this.store = this.form.bind(() => this.projection())
|
|
}
|
|
|
|
/** @returns {object} the shell's shared state plus this card's two controls. */
|
|
projection() {
|
|
return {
|
|
...this.form.shell(),
|
|
mode: this.form.field('mode'),
|
|
extraRoots: this.form.field('extraRoots'),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the face the slot registration injects.
|
|
*
|
|
* The `hooks` key becomes the component's `useFacetCard` prop — the slot
|
|
* runtime turns each entry into a bound hook — and the actions supply
|
|
* `edit`, `resetField`, `save` and `discard` beside it.
|
|
*
|
|
* @returns {object} the snapshot hook and the form actions.
|
|
*/
|
|
inject() {
|
|
return { hooks: { facetCard: this.store }, ...this.form.actions() }
|
|
}
|
|
|
|
/** Release the form subscription. */
|
|
dispose() {
|
|
this.form.dispose()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render the facet card's one-liner or its settings form, as the page asks.
|
|
*
|
|
* @param {object} props - the view asked for, the bound snapshot, and the form actions.
|
|
*/
|
|
function FacetCard(props) {
|
|
if (props.view === 'summary') return DESCRIPTION
|
|
|
|
const state = props.useFacetCard((snapshot) => snapshot)
|
|
const disabled = !state.writable || state.saving
|
|
|
|
// The mode is editable as a segmented control rather than through
|
|
// `SettingsValueField`, because a three-value vocabulary in a free-text box
|
|
// invites a typo the field can only answer with "invalid". It is still the
|
|
// same staged draft underneath — the control calls the form's own `edit`,
|
|
// so nothing is written until Save.
|
|
const mode = h(
|
|
'div',
|
|
{ key: 'mode' },
|
|
h(
|
|
'label',
|
|
{ style: labelStyle, htmlFor: 'facet-mode' },
|
|
'Enforcement mode',
|
|
state.mode.overridden ? h('span', { style: badgeStyle }, OVERRIDDEN_LABEL) : null,
|
|
),
|
|
h(SegmentedControl, {
|
|
id: 'facet-mode',
|
|
value: state.mode.text === '' ? 'ask' : state.mode.text,
|
|
options: MODE_OPTIONS,
|
|
onChange: (next) => props.edit('mode', next),
|
|
label: 'Enforcement mode',
|
|
disabled,
|
|
}),
|
|
h(
|
|
'p',
|
|
{ style: hintStyle },
|
|
'Ask puts each call outside the facet to you as a question. Deny refuses it. Off disables enforcement while leaving the preset in place — useful for telling a scoping problem from an unrelated one.',
|
|
),
|
|
)
|
|
|
|
const extraRoots = h(SettingsValueField, {
|
|
key: 'extraRoots',
|
|
id: 'facet-extra-roots',
|
|
label: 'Standing grants',
|
|
hint: 'Paths always in scope, in every session — shared types, a common test harness. Separate them with commas. Relative paths resolve against each session\u2019s own directory, so the same list is meaningful in every workspace.',
|
|
text: state.extraRoots.text,
|
|
overridden: state.extraRoots.overridden,
|
|
invalid: state.extraRoots.invalid,
|
|
overriddenLabel: OVERRIDDEN_LABEL,
|
|
resetLabel: RESET_LABEL,
|
|
invalidLabel: 'Enter one or more paths, separated by commas.',
|
|
disabled,
|
|
onEdit: (text) => props.edit('extraRoots', text),
|
|
onReset: () => props.resetField('extraRoots'),
|
|
})
|
|
|
|
return h(
|
|
SettingsForm,
|
|
{
|
|
labels: {
|
|
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
readOnly: 'This deployment stores settings read-only.',
|
|
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
|
save: 'Save',
|
|
saving: 'Saving\u2026',
|
|
},
|
|
state,
|
|
onSave: props.save,
|
|
onDiscard: props.discard,
|
|
// Column rather than a bare array, so the two controls keep the form's
|
|
// own spacing instead of sitting flush against each other.
|
|
children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '18px' } }, mode, extraRoots),
|
|
},
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The preset id whose sessions get a context button.
|
|
*
|
|
* A session records its preset as a projection value, so the button can
|
|
* appear exactly where the facet is in force and stay out of the way
|
|
* everywhere else. Rendering it unconditionally would put a control for a
|
|
* plugin that is not running on every conversation in the deployment.
|
|
*/
|
|
const FACET_PRESET = 'facet'
|
|
|
|
/** The composer tool row's entry id, and the button's accessible name. */
|
|
const CONTEXT_ENTRY_ID = 'facet-context'
|
|
const CONTEXT_LABEL = 'Facet context'
|
|
|
|
/** The scope one session has declared, normalised, or an empty declaration. */
|
|
function declaredFor(snapshot, sessionId) {
|
|
const all = snapshot?.value?.scopes
|
|
const mine = all !== null && typeof all === 'object' ? all[sessionId] : undefined
|
|
const entry = mine !== null && typeof mine === 'object' ? mine : {}
|
|
return {
|
|
focus: typeof entry.focus === 'string' ? entry.focus : '',
|
|
extras: Array.isArray(entry.extras) ? entry.extras.filter((path) => typeof path === 'string') : [],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write one session's whole declaration.
|
|
*
|
|
* One path, one write: the panel always supplies the complete declaration it
|
|
* wants, so a queued edit can never half-apply and leave a session scoped to
|
|
* something nobody chose. An empty declaration is removed rather than stored,
|
|
* so a session the user has finished with leaves no residue in the profile
|
|
* patch — and so clearing both fields means "the session directory", which is
|
|
* the schema's own default rather than a stored empty override.
|
|
*
|
|
* @returns {Promise<boolean>} whether the Host accepted the write.
|
|
*/
|
|
function writeDeclaration(form, sessionId, next) {
|
|
const focus = typeof next.focus === 'string' ? next.focus.trim() : ''
|
|
const extras = (next.extras ?? []).map((path) => String(path).trim()).filter((path) => path !== '')
|
|
const value = {
|
|
...(focus === '' ? {} : { focus }),
|
|
...(extras.length === 0 ? {} : { extras }),
|
|
}
|
|
const op =
|
|
Object.keys(value).length === 0
|
|
? { op: 'unset', path: ['scopes', sessionId] }
|
|
: { op: 'set', path: ['scopes', sessionId], value }
|
|
return Promise.resolve(form.mutate([op]))
|
|
}
|
|
|
|
/** Shared control styling, so the panel reads as part of the product. */
|
|
const controlStyle = {
|
|
boxSizing: 'border-box',
|
|
height: '32px',
|
|
padding: '0 10px',
|
|
fontSize: '13px',
|
|
fontFamily: 'inherit',
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
background: 'var(--dsw-alias-bg-layer-3)',
|
|
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
borderRadius: 'var(--dsw-radius-md)',
|
|
}
|
|
const quietButtonStyle = {
|
|
...controlStyle,
|
|
cursor: 'pointer',
|
|
color: 'var(--dsw-alias-label-secondary)',
|
|
background: 'transparent',
|
|
}
|
|
const sectionTitleStyle = {
|
|
margin: '0 0 6px',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
}
|
|
const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' }
|
|
const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' }
|
|
|
|
/** The crosshair that marks the button: a scope, not a setting. */
|
|
const CONTEXT_GLYPH = h(
|
|
'svg',
|
|
{ width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
|
|
h('circle', { cx: 8, cy: 8, r: 3.2, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
h('path', {
|
|
d: 'M8 1.2v2.6M8 12.2v2.6M1.2 8h2.6M12.2 8h2.6',
|
|
stroke: 'currentColor',
|
|
strokeWidth: 1.3,
|
|
strokeLinecap: 'round',
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* The facet context panel: what this conversation may see, and who decides.
|
|
*
|
|
* This is the only surface that writes per-session scope. The agent has no
|
|
* write path to it — not a narrow one, none — which is what makes "the user
|
|
* decides what is in scope" a property of the system rather than of the
|
|
* agent's instructions.
|
|
*
|
|
* @param {object} props - the bound form, the session, and the close command.
|
|
*/
|
|
function FacetContextPanel(props) {
|
|
const { form, sessionId, mode, onClose } = props
|
|
|
|
// `form` is the settings document for `facet-policy`, already a store: the
|
|
// panel renders whatever the Host last accepted, including a write from
|
|
// another tab.
|
|
const snapshot = react.useSyncExternalStore(
|
|
(listener) => form.subscribe(listener),
|
|
() => form.getSnapshot(),
|
|
)
|
|
const declared = declaredFor(snapshot, sessionId)
|
|
const [focusDraft, setFocusDraft] = react.useState(declared.focus)
|
|
const [extraDraft, setExtraDraft] = react.useState('')
|
|
const [failure, setFailure] = react.useState('')
|
|
const disabled = snapshot.writable !== true
|
|
|
|
const write = (next) => {
|
|
writeDeclaration(form, sessionId, next).then(
|
|
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'),
|
|
() => setFailure('The deployment did not accept that change.'),
|
|
)
|
|
}
|
|
|
|
const rows = declared.extras.map((path) =>
|
|
h(
|
|
'li',
|
|
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
|
|
h('code', { style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' } }, path),
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
style: { ...quietButtonStyle, height: '26px' },
|
|
disabled,
|
|
onClick: () => write({ ...declared, extras: declared.extras.filter((other) => other !== path) }),
|
|
},
|
|
'Remove',
|
|
),
|
|
),
|
|
)
|
|
|
|
return h(
|
|
Modal,
|
|
{ open: true, onClose, title: CONTEXT_LABEL, closeLabel: 'Close', description: `Session ${sessionId}` },
|
|
h(
|
|
'div',
|
|
null,
|
|
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'),
|
|
h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'The agent works inside the focus folder. Anything it needs beyond that is listed below, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
|
|
),
|
|
|
|
h('label', { style: sectionTitleStyle, htmlFor: 'facet-focus' }, 'Focus'),
|
|
h(
|
|
'div',
|
|
{ style: { display: 'flex', gap: '8px' } },
|
|
h('input', {
|
|
id: 'facet-focus',
|
|
type: 'text',
|
|
style: { ...controlStyle, flex: 1 },
|
|
placeholder: 'the folder the session started in',
|
|
value: focusDraft,
|
|
disabled,
|
|
'data-modal-autofocus': true,
|
|
onChange: (event) => setFocusDraft(event.target.value),
|
|
onKeyDown: (event) => {
|
|
if (event.key !== 'Enter') return
|
|
event.preventDefault()
|
|
write({ ...declared, focus: focusDraft })
|
|
},
|
|
}),
|
|
h(
|
|
'button',
|
|
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) },
|
|
'Set',
|
|
),
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
style: quietButtonStyle,
|
|
disabled,
|
|
onClick: () => {
|
|
setFocusDraft('')
|
|
write({ ...declared, focus: '' })
|
|
},
|
|
},
|
|
'Clear',
|
|
),
|
|
),
|
|
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'),
|
|
|
|
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
|
|
declared.extras.length === 0
|
|
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
|
|
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
|
|
h(
|
|
'div',
|
|
{ style: { display: 'flex', gap: '8px', marginTop: '10px' } },
|
|
h('input', {
|
|
id: 'facet-extra',
|
|
type: 'text',
|
|
style: { ...controlStyle, flex: 1 },
|
|
placeholder: 'src/shared, ../../CONTROLLER.md',
|
|
value: extraDraft,
|
|
disabled,
|
|
onChange: (event) => setExtraDraft(event.target.value),
|
|
onKeyDown: (event) => {
|
|
if (event.key !== 'Enter') return
|
|
event.preventDefault()
|
|
if (extraDraft.trim() === '') return
|
|
write({ ...declared, extras: [...declared.extras, extraDraft] })
|
|
setExtraDraft('')
|
|
},
|
|
}),
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
style: quietButtonStyle,
|
|
disabled,
|
|
onClick: () => {
|
|
if (extraDraft.trim() === '') return
|
|
write({ ...declared, extras: [...declared.extras, extraDraft] })
|
|
setExtraDraft('')
|
|
},
|
|
},
|
|
'Add',
|
|
),
|
|
),
|
|
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute — anything the session needs beyond the focus.'),
|
|
|
|
failure === '' ? null : h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure),
|
|
h(
|
|
'p',
|
|
{ style: { ...bodyStyle, marginTop: '18px' } },
|
|
`Changes take effect on the next tool call. Deployment mode: ${mode ?? 'ask'}.`,
|
|
),
|
|
|
|
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Mode details'),
|
|
h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
|
|
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
|
|
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
|
|
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
|
|
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
|
|
),
|
|
h('h3', { style: sectionTitleStyle }, 'How to use'),
|
|
h(
|
|
'ol',
|
|
{ style: { ...bodyStyle, paddingLeft: '18px' } },
|
|
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
|
|
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
|
|
h(
|
|
'li',
|
|
{ style: { marginBottom: '6px' } },
|
|
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.',
|
|
),
|
|
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The composer tool row's button, and the panel it opens.
|
|
*
|
|
* Present only for a session running the facet preset, which is what "shows
|
|
* up when in facet mode" means mechanically: the session's own projection
|
|
* says which preset it runs, so nothing has to be inferred from the URL or
|
|
* from the plugin being installed.
|
|
*
|
|
* @param {object} props - slot props: the session, `useSessions`, and the form.
|
|
*/
|
|
function FacetContextButton(props) {
|
|
const { sessionId, useSessions, form } = props
|
|
|
|
// Called before the early return, so the hook order is stable across the
|
|
// sessions that do and do not run the facet.
|
|
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset)
|
|
const [open, setOpen] = react.useState(false)
|
|
|
|
if (preset !== FACET_PRESET) return null
|
|
|
|
return h(
|
|
react.Fragment,
|
|
null,
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
'aria-label': CONTEXT_LABEL,
|
|
'aria-expanded': open,
|
|
title: `${CONTEXT_LABEL} — what this conversation may see`,
|
|
onClick: () => setOpen((value) => !value),
|
|
style: {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '28px',
|
|
height: '28px',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
color: open ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
background: open ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
|
|
border: 0,
|
|
borderRadius: 'var(--dsw-radius-sm)',
|
|
},
|
|
},
|
|
CONTEXT_GLYPH,
|
|
),
|
|
// Mounted only while open, so each visit starts from what the Host holds
|
|
// rather than from a draft left over from the last one.
|
|
open ? h(FacetContextPanel, { ...props, onClose: () => setOpen(false) }) : null,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Mount the facet settings card while the Host serves its namespace.
|
|
*
|
|
* `whileServed` is the gate that makes the `volatile()` markers load-bearing:
|
|
* the card is registered only while `facet-policy` appears in the Host's
|
|
* served namespaces. An unserved namespace would be a control that silently
|
|
* writes nowhere, so it registers nothing at all.
|
|
*
|
|
* @param {object} ctx - the browser plugin context.
|
|
*/
|
|
function apply(ctx) {
|
|
// `inject` rather than a required dependency, so a deployment that composed
|
|
// no settings provider loses the card and keeps everything else this bundle
|
|
// contributes.
|
|
ctx.inject(['configForms'], (scoped) => {
|
|
const form = scoped.configForms.get(SETTINGS_ENTRY)
|
|
const card = new FacetCardController(form)
|
|
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
|
|
|
|
scoped.effect(
|
|
() =>
|
|
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
|
|
scoped.slots.inject('plugins.bundle.config', () =>
|
|
scoped.slots.register(
|
|
{
|
|
name: 'plugins.bundle.config',
|
|
key: BUNDLE_CONFIG_KEY,
|
|
inject: () => card.inject(),
|
|
},
|
|
FacetCard,
|
|
),
|
|
),
|
|
),
|
|
'dsh-facet: settings page',
|
|
)
|
|
|
|
// The context button rides the same form, so the panel and the plugin
|
|
// card can never disagree about what the document holds.
|
|
scoped.effect(
|
|
() =>
|
|
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
|
|
scoped.slots.inject('conversation.input.left', () =>
|
|
scoped.slots.register(
|
|
{
|
|
name: 'conversation.input.left',
|
|
id: CONTEXT_ENTRY_ID,
|
|
order: 20,
|
|
inject: () => ({ form }),
|
|
},
|
|
FacetContextButton,
|
|
),
|
|
),
|
|
),
|
|
'dsh-facet: context button',
|
|
)
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
name: 'dsh-facet',
|
|
inject: ['slots'],
|
|
apply,
|
|
SETTINGS_ENTRY,
|
|
BUNDLE_CONFIG_KEY,
|
|
FACET_PRESET,
|
|
CONTEXT_ENTRY_ID,
|
|
FacetCard,
|
|
FacetCardController,
|
|
FacetContextButton,
|
|
FacetContextPanel,
|
|
declaredFor,
|
|
writeDeclaration,
|
|
}
|
|
return module.exports
|
|
},
|
|
})
|