Files
dsh-facet/client/client.js
T
NotExpectedYet b0902017fd fix: name the allowed-paths action for what it adds, a path or a file
"Add folder or path" promised one thing and offered another: the listing is
folders only, because the Host's browse primitive has no file rows, while the
extras legitimately name files — `../../CONTROLLER.md` is the example the panel
itself gives.

The action is "Add path or file", its field is "Path or file", and the hint says
which of the two the list can show and how the other one is added. Nothing about
the interaction moved; the copy was the part that was wrong.
2026-09-27 07:31:50 +01:00

1274 lines
51 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,
IconFolderCloseRegular,
} = 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 directory listing ────────────────────────────────────────────────
/** The classes that widen the two dialogs past the primitive's 380px card. */
const PANEL_CLASS = 'dsh-facet-panel'
const PICKER_CLASS = 'dsh-facet-picker'
const ROW_CLASS = 'dsh-facet-row'
/**
* The stylesheet this bundle installs, for the two things an inline style
* cannot reach.
*
* `Modal` sizes its own card and takes only a class name, and a `:hover`
* state has no inline spelling, so both live here. Every selector names a
* class of ours and doubles it: the primitive's card rule is a single hashed
* class, and a doubled unhashed one outranks it whatever order the two
* stylesheets land in. That is the whole reason for the doubling — no
* `!important`, and nothing reaching into a class name this bundle does not
* own.
*
* The widths are the fix for the card the panel used to be: 380px is
* comfortable for a sentence and cramped for a path listing.
*/
const STYLE_ID = 'dsh-facet'
const STYLE_CSS =
'.dsh-facet-panel.dsh-facet-panel{width:min(760px,100%)}' +
'.dsh-facet-picker.dsh-facet-picker{width:min(560px,100%)}' +
'.dsh-facet-row:enabled:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
'.dsh-facet-row:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}'
/**
* Install {@link STYLE_CSS} once per document.
*
* Guarded on `document` because the Node test harness loads this module with
* only `window` and `require` faked, and there is no DOM there to style.
*/
function installStyles() {
if (typeof document === 'undefined') return
if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return
const tag = document.createElement('style')
tag.dataset.plugin = 'dsh-facet'
tag.dataset.pluginCss = STYLE_ID
tag.textContent = STYLE_CSS
document.head.appendChild(tag)
}
installStyles()
/**
* Whether a declared path stands on its own instead of resolving against
* the session folder.
*
* Both separators, because a profile patch is written on one platform and
* read on whichever machine runs it.
*/
function isAbsolutePath(path) {
return typeof path === 'string' && (/^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path))
}
/** One host path's segments, on either separator. */
function pathSegments(path) {
return String(path)
.split(/[\\/]+/)
.filter((segment) => segment !== '')
}
/**
* Say an absolute host path the way this panel declares paths: relative to
* the session folder where that still names it, absolute where it does not.
*
* Relative is not cosmetic. A declaration is resolved against the session
* directory at the point of use, so `../shared` keeps its meaning when the
* workspace moves and `/srv/old-workspace/shared` does not.
*
* @param {string} root - the session folder, absolute, or '' when unknown.
* @param {string} target - the path to express.
* @returns {string} the declared form; '' is the session folder itself.
*/
function relativeTo(root, target) {
const path = String(target ?? '')
if (typeof root !== 'string' || root === '' || path === '') return path
const base = pathSegments(root)
const full = pathSegments(path)
let common = 0
while (common < base.length && common < full.length && base[common] === full[common]) common += 1
// Nothing shared — another drive, or a path that is relative already.
// Absolute is the only honest spelling left.
if (common === 0) return path
const up = base.slice(common).map(() => '..')
return [...up, ...full.slice(common)].join('/')
}
/**
* Resolve a declared path against the session folder, for a listing call.
*
* The host's browse primitive lists absolute paths only, so a stored
* `focus` has to be joined before it can be opened. Joining is a string
* operation rather than a `path.resolve` because there is no `node:path` on
* this side of the wire and the host accepts either separator.
*
* @param {string} root - the session folder, absolute, or '' when unknown.
* @param {string} declared - a stored focus or extra.
* @returns {string} an absolute path where one can be built, else the input.
*/
function absoluteFrom(root, declared) {
const path = String(declared ?? '').trim()
if (path === '' || isAbsolutePath(path)) return path
if (typeof root !== 'string' || root === '') return path
return `${root.replace(/[\\/]+$/, '')}/${path.replace(/^[\\/]+/, '')}`
}
/**
* The operator-facing text for a failed browse call.
*
* The Host's own business message is preferred to the transport error,
* because a browse refusal is an answer — the folder is unreadable, the path
* is not absolute — and that sentence is the one worth reading.
*/
function browseFailure(error) {
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
if (rpc !== null && typeof rpc === 'object' && typeof rpc.message === 'string') return rpc.message
return error instanceof Error ? error.message : String(error)
}
/**
* The state behind the picker: the level on screen, the path being edited,
* and the last failure.
*
* A model rather than component state because a browse is asynchronous and
* supersedable: clicking down three folders fires three listings, and the
* first one landing last must not overwrite the third. Each request carries
* a sequence number and only the newest may commit. It also keeps the whole
* interaction testable without a DOM, which is the same reason the settings
* card has a controller.
*/
var DirectoryPickerModel = class {
/**
* @param {object} options
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.list - the host listing call.
* @param {string} options.root - the session folder, absolute, or ''.
* @param {string} [options.start] - the directory to open first; the root by default.
*/
constructor(options) {
const root = typeof options.root === 'string' ? options.root : ''
this.list = options.list
this.listeners = new Set()
this.request = 0
this.state = {
listing: null,
loading: false,
error: '',
showHidden: false,
draft: typeof options.start === 'string' && options.start !== '' ? options.start : root,
}
this.open(this.state.draft)
}
/**
* @param {() => void} listener - woken on every commit.
* @returns {() => void} the unsubscribe.
*/
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
/** @returns {object} the current state, for `useSyncExternalStore`. */
getSnapshot() {
return this.state
}
/** @param {object} patch @returns {void} */
update(patch) {
this.state = { ...this.state, ...patch }
for (const listener of [...this.listeners]) listener()
}
/**
* List one directory, discarding any listing already in flight.
*
* An empty path asks the Host for its home directory, which is the right
* answer when the session folder is not known yet. A failure keeps the
* level that was on screen and the path as typed, because a listing can
* fail on a path this panel may still legitimately declare — a file rather
* than a folder is the ordinary case, not an error.
*
* @param {string} [path] - the absolute directory to list.
*/
open(path) {
const target = String(path ?? '').trim()
const request = (this.request += 1)
this.update({ loading: true, error: '', draft: target })
Promise.resolve()
.then(() => this.list(target === '' ? undefined : target))
.then(
(listing) => {
if (request !== this.request) return
this.update({ listing, loading: false, error: '', draft: String(listing?.path ?? target) })
},
(reason) => {
if (request !== this.request) return
this.update({ loading: false, error: browseFailure(reason) })
},
)
}
/** @param {string} text - the path field's own value. */
setDraft(text) {
this.update({ draft: String(text) })
}
/** @param {boolean} showHidden - whether host-flagged hidden folders are listed. */
setShowHidden(showHidden) {
this.update({ showHidden: showHidden === true })
}
}
/** Picker styling: the primitive's own control shapes, one size down. */
const pickerBodyStyle = { maxHeight: 'min(52vh, 360px)', overflowY: 'auto', paddingRight: '4px' }
const pickerLevelStyle = { display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '4px' }
const pickerRowStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
boxSizing: 'border-box',
width: '100%',
height: '30px',
padding: '0 8px',
border: 0,
borderRadius: 'var(--dsw-radius-sm)',
background: 'transparent',
color: 'var(--dsw-alias-label-primary)',
fontFamily: 'inherit',
fontSize: '13px',
textAlign: 'left',
cursor: 'pointer',
}
const pickerNameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
const pickerGlyphStyle = { display: 'inline-flex', flex: 'none', color: 'var(--dsw-alias-label-secondary)' }
const crumbStyle = {
flex: 'none',
border: 0,
padding: '0 2px',
background: 'transparent',
color: 'var(--dsw-alias-label-tertiary)',
fontFamily: 'inherit',
fontSize: '12.5px',
cursor: 'pointer',
}
const pickerStatusStyle = {
margin: '6px 0 0',
fontSize: '12px',
lineHeight: '18px',
color: 'var(--dsw-alias-label-tertiary)',
}
const pickerErrorStyle = { ...pickerStatusStyle, color: 'var(--dsw-alias-state-error-primary)' }
const toggleStyle = {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '12.5px',
color: 'var(--dsw-alias-label-secondary)',
cursor: 'pointer',
}
/**
* The directory picker: one editable path, the ancestry above it, and one
* level of child folders.
*
* It drives no host call of its own — {@link DirectoryPickerModel} holds the
* listing and the path, and the panel builds one per opening so each visit
* starts from what the Host holds rather than from the last one's draft.
*
* @param {object} props - the model, the session folder, what is being picked, and the outcome calls.
*/
function DirectoryPicker(props) {
const { model, root, target, disabled, onClose, onPick } = props
const state = react.useSyncExternalStore(
(listener) => model.subscribe(listener),
() => model.getSnapshot(),
)
const listing = state.listing
const blocked = disabled === true
const typed = state.draft
const entries = listing === null ? [] : listing.entries.filter((entry) => state.showHidden || entry.hidden !== true)
// What the field currently means as a declaration. '' is the session
// folder: the focus's own default, and for an extra — already the root —
// nothing worth adding.
const declared = relativeTo(root, typed)
const rows = entries.map((entry) =>
h(
'button',
{
key: entry.path,
type: 'button',
className: ROW_CLASS,
style: pickerRowStyle,
title: entry.path,
disabled: blocked || state.loading,
// A click walks down rather than picking: the primary action is the
// path field's own value, so a folder is chosen by standing in it.
onClick: () => model.open(entry.path),
},
h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })),
h('span', { style: pickerNameStyle }, entry.name),
),
)
const trail = []
for (const [index, crumb] of (listing === null ? [] : listing.crumbs).entries()) {
if (index > 0) trail.push(h('span', { key: `sep-${index}`, style: { color: 'var(--dsw-alias-label-caption)' } }, '/'))
trail.push(
h(
'button',
{
key: crumb.path,
type: 'button',
style: crumbStyle,
title: crumb.path,
disabled: blocked || state.loading,
onClick: () => model.open(crumb.path),
},
// A root crumb carries its whole path as its name; "Home" is the
// shorter label the host's own browser gives it, and the title
// attribute still names where it is.
crumb.path === listing.home ? 'Home' : crumb.name,
),
)
}
const status =
state.error !== ''
? h('p', { key: 'status', style: pickerErrorStyle }, state.error)
: state.loading
? h('p', { key: 'status', style: pickerStatusStyle }, 'Listing\u2026')
: listing === null
? h('p', { key: 'status', style: pickerStatusStyle }, 'Type a folder to start from.')
: entries.length === 0
? h('p', { key: 'status', style: pickerStatusStyle }, 'No folders in here.')
: listing.truncated === true
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.')
: null
return h(Modal, {
open: true,
onClose,
title: target === 'focus' ? 'Choose the focus folder' : 'Add a path or a file',
closeLabel: 'Close',
className: PICKER_CLASS,
// One scroll region around everything but the footer, so the card is
// bounded on a short viewport and the action stays reachable. The
// primitive's card is content-sized with `overflow: hidden`, so a fixed
// footer is the only way to keep it in view without reaching into the
// primitive's own class names.
children: h(
'div',
{ style: pickerBodyStyle },
h(
'div',
{ key: 'path' },
h('label', { style: labelStyle, htmlFor: 'facet-picker-path' }, target === 'focus' ? 'Folder' : 'Path or file'),
h('input', {
id: 'facet-picker-path',
type: 'text',
style: { ...controlStyle, width: '100%' },
value: typed,
disabled: blocked,
spellCheck: false,
// The Host lists folders, so this box is also how a file — or a
// folder the session will create — is named at all.
'data-modal-autofocus': true,
placeholder: root === '' ? absoluteFrom(root, 'src/shared') : 'relative/to/the/session/folder',
onChange: (event) => model.setDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
model.open(typed)
},
}),
h(
'p',
{ style: hintStyle },
target === 'focus'
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
: // The list holds folders only — the Host's browse primitive has no
// file rows — so the box is where a file is named, and saying so
// is the difference between a dead end and an instruction.
'Relative to the session folder, or absolute. The list holds folders; type a file\u2019s name to add the file itself.',
),
),
h(
'div',
{
key: 'trail',
style: {
display: 'flex',
alignItems: 'center',
gap: '2px',
minHeight: '22px',
marginTop: '12px',
overflowX: 'auto',
},
},
trail.length === 0 ? null : trail,
),
h('div', { key: 'level', style: pickerLevelStyle }, rows),
status,
),
footer: [
// Kept beside the action rather than under the list, the way the
// host's own browser does it: one control, always in view.
h(
'label',
{ key: 'hidden', style: toggleStyle },
h('input', {
type: 'checkbox',
checked: state.showHidden,
disabled: blocked,
onChange: (event) => model.setShowHidden(event.target.checked),
}),
'Show hidden folders',
),
h('span', { key: 'gap', style: { flex: 1 } }),
h('button', { key: 'cancel', type: 'button', style: quietButtonStyle, onClick: onClose }, 'Cancel'),
h(
'button',
{
key: 'pick',
type: 'button',
style: { ...quietButtonStyle, color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' },
// An extra that resolves to the session folder would add the root
// to a list of things beyond it; the focus may say exactly that,
// which is how it spells "the session folder itself".
disabled: blocked || typed.trim() === '' || (target === 'extra' && declared === ''),
// The declared form, not the absolute one: the session folder is
// where this conversation lives, and a path that says so keeps its
// meaning if the workspace moves.
onClick: () => onPick(declared),
},
target === 'focus' ? 'Set focus' : 'Add',
),
],
})
}
/** 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 panel's own scroll region: the card is wide, not tall. */
const panelBodyStyle = { maxHeight: 'min(62vh, 540px)', overflowY: 'auto', paddingRight: '4px' }
/**
* Two columns on a wide card, one on a narrow one. `auto-fit` is the whole
* responsive story: at 380px — the width this card used to be — the second
* track does not fit and every cell takes the full row.
*/
const panelGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '28px',
alignItems: 'start',
}
/** The focus, shown as the declared path rather than as an editable box. */
const focusPathStyle = {
flex: 1,
minWidth: 0,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
height: '32px',
padding: '0 10px',
border: '0.5px solid var(--dsw-alias-border-l4)',
borderRadius: 'var(--dsw-radius-md)',
background: 'var(--dsw-alias-bg-layer-3)',
fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)',
fontSize: '12.5px',
color: 'var(--dsw-alias-label-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const focusEmptyPathStyle = { ...focusPathStyle, fontFamily: 'inherit', color: 'var(--dsw-alias-label-tertiary)' }
/**
* One folded-away reference section.
*
* The guidance used to sit under the controls at full length, which made a
* panel that is mostly prose and filled the viewport. Folded, it is still
* there for the session that needs it and costs two lines otherwise.
*/
function disclosure(summary, content) {
return h(
'details',
{ style: { marginTop: '18px' } },
h('summary', { style: { ...sectionTitleStyle, cursor: 'pointer', margin: 0 } }, summary),
h('div', { style: { marginTop: '8px' } }, content),
)
}
/**
* 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.
*
* Both scope controls are the same directory listing rather than two text
* boxes. The panel's vocabulary is paths relative to the session folder, and
* "which folder is `../shared`" is exactly the question a free-text box
* cannot answer; the listing answers it from the Host's own directory rather
* than from a guess about what the path was meant to name. The path field is
* still editable inside the picker, because the Host lists folders and the
* extras may name a file.
*
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
*/
function FacetContextPanel(props) {
const { form, sessionId, mode, onClose, browse } = 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 [picker, setPicker] = react.useState(null)
const [failure, setFailure] = react.useState('')
const disabled = snapshot.writable !== true
// The session's own folder: what every relative path in this panel is
// relative to, and where a listing starts. Absent on a session whose
// summary has not arrived, in which case the Host's home directory stands
// in and every path is declared absolute.
const root = typeof props.cwd === 'string' ? props.cwd : ''
// Asked during render rather than once at registration: the service may
// activate after this bundle does, and a frozen `false` would hide the
// listing for the rest of the session.
const canBrowse = browse?.available?.() === true && !disabled
// The mode is a fact about the settings document, so it is read from the
// document rather than looked for among the slot's own props — which
// never carried it, leaving this line saying "ask" in every deployment.
const documentMode = snapshot.value?.mode
const shownMode = typeof mode === 'string' ? mode : typeof documentMode === 'string' ? documentMode : 'ask'
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.'),
)
}
// One model per opening, built at the click rather than at render, so
// closing and reopening the picker starts from what the Host holds.
const openPicker = (target) => {
if (!canBrowse) return
// The listing opens on the stored focus for the focus control and on the
// session folder otherwise. A path with nothing to resolve against — a
// relative declaration and an unknown session folder — is not a starting
// point at all, so an empty one asks the Host for its home instead.
const stored = target === 'focus' ? absoluteFrom(root, declared.focus) : root
setPicker({
target,
model: new DirectoryPickerModel({
list: browse.list,
root,
start: isAbsolutePath(stored) ? stored : '',
}),
})
}
const rows = declared.extras.map((path) =>
h(
'li',
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
h(
'code',
// The declared form is what is stored; the title spells out the
// absolute path it resolves to, which is what the agent will see.
{ style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' }, title: absoluteFrom(root, path) },
path,
),
h(
'button',
{
type: 'button',
style: { ...quietButtonStyle, height: '26px' },
disabled,
onClick: () => write({ ...declared, extras: declared.extras.filter((other) => other !== path) }),
},
'Remove',
),
),
)
const overlay =
picker === null
? null
: h(DirectoryPicker, {
model: picker.model,
root,
target: picker.target,
disabled,
onClose: () => setPicker(null),
onPick: (path) => {
setPicker(null)
// The picker already answers in declared form: an empty string
// is the session folder itself, which is `focus`'s own default
// rather than a stored override naming the same place.
if (picker.target === 'focus') write({ ...declared, focus: path })
else write({ ...declared, extras: [...declared.extras, path] })
},
})
return h(
Modal,
{
open: true,
onClose,
title: CONTEXT_LABEL,
closeLabel: 'Close',
description: `Session ${sessionId}`,
className: PANEL_CLASS,
},
h(
'div',
{ style: panelBodyStyle },
// Two columns, because the panel is now wide: the controls on the
// left where the eye starts, the reference prose on the right where it
// reads at a sane line length. `auto-fit` collapses it back to one
// column on a narrow viewport, so nothing has to know the width.
h(
'div',
{ style: panelGridStyle },
h(
'div',
null,
h('h3', { style: sectionTitleStyle }, 'Focus'),
h(
'div',
{ style: { display: 'flex', gap: '8px', alignItems: 'center' } },
h(
'span',
{ style: declared.focus === '' ? focusEmptyPathStyle : focusPathStyle, title: absoluteFrom(root, declared.focus) },
declared.focus === '' ? 'the session folder' : declared.focus,
),
h(
'button',
{ type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('focus') },
'Browse\u2026',
),
declared.focus === ''
? null
: h(
'button',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: '' }) },
'Clear',
),
),
h(
'p',
{ style: hintStyle },
root === ''
? 'Relative to the session folder, or absolute. Empty means the session folder itself.'
: `Relative to the session folder (${root}), 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: { marginTop: '10px' } },
h(
'button',
{ type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('extra') },
'+ Add path or file\u2026',
),
),
h(
'p',
{ style: bodyStyle },
'Relative to the session folder, or absolute — anything the session needs beyond the focus.',
),
canBrowse
? null
: h(
'p',
{ style: bodyStyle },
'This deployment has no directory browser, so paths cannot be picked here. Standing grants can still be set on the plugin card; a per-session declaration needs a browser.',
),
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: ${shownMode}.`,
),
),
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 here, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
),
disclosure(
'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.',
),
),
disclosure(
'How to use',
h(
'ol',
{ style: { ...bodyStyle, margin: 0, 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.'),
),
),
),
),
),
// Portalled by `Modal` itself, so a nested dialog is an ordinary child
// here: the last dialog in the document owns Escape, which makes the
// picker close before the panel does.
overlay,
)
}
/**
* 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)
// The session's own folder, which is what "relative" means in the panel
// and where its listing starts. Read from the session list rather than
// looked up from the host, because the list already carries it and a
// second read could disagree with the row the user picked.
const cwd = useSessions((state) => state.byId[sessionId]?.cwd)
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, form, sessionId, cwd, onClose: () => setOpen(false) }) : null,
)
}
/**
* The directory-browsing face the picker drives.
*
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
* the whole context button conditional on it, and the panel is still the
* only way to declare scope in a deployment with no browser — it would just
* have no listing to offer. `ctx.get` answers `undefined` instead of
* throwing, which is what makes that a decision rather than a crash.
*
* The availability probe is a function rather than a value because the
* renderer memoizes the registration's inject result: a boolean read there
* would freeze at whatever the first render saw, and the service may well
* activate after this bundle does.
*
* @param {object} ctx - the client context to resolve the service from.
* @returns {object} `available()` and `list()`, the two calls the panel makes.
*/
function directoryBrowse(ctx) {
const workspace = () => ctx.get('uiWorkspace')
const usable = () => typeof workspace()?.listDirectory === 'function'
return {
available: usable,
list: (path, signal) => {
const service = workspace()
if (service === undefined || typeof service.listDirectory !== 'function') {
return Promise.reject(new Error('This deployment has no directory browser to list with.'))
}
return service.listDirectory(path, signal)
},
}
}
/**
* 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, browse: directoryBrowse(ctx) }),
},
FacetContextButton,
),
),
),
'dsh-facet: context button',
)
})
}
module.exports = {
name: 'dsh-facet',
inject: ['slots'],
apply,
SETTINGS_ENTRY,
BUNDLE_CONFIG_KEY,
FACET_PRESET,
CONTEXT_ENTRY_ID,
PANEL_CLASS,
PICKER_CLASS,
ROW_CLASS,
FacetCard,
FacetCardController,
FacetContextButton,
FacetContextPanel,
DirectoryPicker,
DirectoryPickerModel,
declaredFor,
writeDeclaration,
absoluteFrom,
relativeTo,
isAbsolutePath,
directoryBrowse,
}
return module.exports
},
})