feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s
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.
This commit is contained in:
1 parent
d9caf4eeec
commit
266e90aa2d
4 files changed
+589
-37
No files matched your search
@@ -103,23 +103,22 @@ first turn has run, so choose it when you start the conversation.
|
|||||||
|
|
||||||
### Configure it in your profile, not in the package
|
### Configure it in your profile, not in the package
|
||||||
|
|
||||||
The host row's config is where this deployment's facts belong — the profile's own
|
The host row's config is where this *deployment's* facts belong — the profile's
|
||||||
`cordis.patch.yml`:
|
own `cordis.patch.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: facet-policy
|
- id: facet-policy
|
||||||
name: dsh-facet
|
name: dsh-facet
|
||||||
config:
|
config:
|
||||||
mode: ask # ask | deny | off
|
mode: ask # ask | deny | off
|
||||||
extraRoots: # always in scope, in every session
|
extraRoots: [] # optional: a seed of paths always in scope, every session
|
||||||
- ../../shared # relative to each session's own directory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Both fields are also on the plugin's card under **Plugins** in the Web UI, which
|
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
|
||||||
is the same layer reached a different way: a settings write rewrites this entry in
|
same layer reached a different way: a settings write rewrites this entry in the
|
||||||
the profile patch rather than storing anything new. An edit there is live — the
|
profile patch rather than storing anything new. An edit there is live — the guard
|
||||||
guard reads `mode` and `extraRoots` on every call, so a change decides the next
|
reads `mode` and `extraRoots` on every call, so a change decides the next file
|
||||||
file call without a restart.
|
call without a restart.
|
||||||
|
|
||||||
The one exception is `off`. Choosing it while a session is running stops the guard
|
The one exception is `off`. Choosing it while a session is running stops the guard
|
||||||
immediately and empties the binding on the next request, because the binding is a
|
immediately and empties the binding on the next request, because the binding is a
|
||||||
@@ -132,11 +131,37 @@ patch. `ask` and `deny` swap freely in both directions.
|
|||||||
place the rule and the binding among the other prompt sections, which is decided
|
place the rule and the binding among the other prompt sections, which is decided
|
||||||
once at load, and no section is re-ordered without a remount.
|
once at load, and no section is re-ordered without a remount.
|
||||||
|
|
||||||
`extraRoots` is for code a module legitimately always depends on. Anything you
|
### Per-session scope, and who gets to decide it
|
||||||
grant by hand is per-conversation and is never persisted — "this session may
|
|
||||||
also see `src/shared`" is a statement about one conversation, and writing it down
|
Scope has two layers, and the split is the point:
|
||||||
would quietly widen every future one, which is the failure this plugin exists to
|
|
||||||
prevent.
|
| layer | what it is | who can write it |
|
||||||
|
|---|---|---|
|
||||||
|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
|
||||||
|
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
|
||||||
|
|
||||||
|
The effective root is the agent's narrowing while it still sits inside your root,
|
||||||
|
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
|
||||||
|
and `facet action=reset` returns it to what you declared rather than to the
|
||||||
|
session directory — landing on the directory would have made `reset` a widening.
|
||||||
|
|
||||||
|
You set the declaration from the **crosshair button on the composer tool row**,
|
||||||
|
which appears only in sessions running the `facet` preset. It shows the focus and
|
||||||
|
the allowed paths, adds and removes them, and carries the mode details and usage
|
||||||
|
notes that the preset picker's guide dialog cannot show for a preset no core
|
||||||
|
package ships. Writes go to the profile patch, so reopening a chat gives back the
|
||||||
|
scope it had.
|
||||||
|
|
||||||
|
Because the panel owns widening, `facet action=add` is the *other* path and the
|
||||||
|
narrower one: it is gated on an approval channel, and a deployment with none — the
|
||||||
|
`danger-full-access` permission preset sets `approval: never` — refuses it before
|
||||||
|
anyone is asked. In that deployment the panel is the only way to widen, which is
|
||||||
|
exactly what the binding tells the agent.
|
||||||
|
|
||||||
|
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
|
||||||
|
widen anything, which meant "this conversation needs the entity folder" had to be
|
||||||
|
written as a fact about every conversation in every workspace. It is for paths a
|
||||||
|
deployment genuinely always wants, and it is empty by default.
|
||||||
|
|
||||||
## The compaction row
|
## The compaction row
|
||||||
|
|
||||||
@@ -188,13 +213,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
|
|||||||
|
|
||||||
Verified:
|
Verified:
|
||||||
|
|
||||||
- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
|
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
|
||||||
the browser half — including that containment is decided on canonical targets
|
the browser half — including that containment is decided on canonical targets
|
||||||
(a symlink inside the facet pointing outside it is refused), that a prefix is
|
(a symlink inside the facet pointing outside it is refused), that a prefix is
|
||||||
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
|
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
|
||||||
call never makes the guard throw, that a live `mode` edit decides the next call
|
call never makes the guard throw, that a live `mode` edit decides the next call
|
||||||
without the guard being remounted, and that every `facet` result satisfies the
|
without the guard being remounted, that an agent narrowing the user has since
|
||||||
tool's own declared output schema.
|
narrowed past is dropped rather than honoured, that `reset` lands on the
|
||||||
|
declared scope rather than the session directory, and that every `facet` result
|
||||||
|
satisfies the tool's own declared output schema.
|
||||||
- The bundle's patch layers parse, merge and land correctly against the real
|
- The bundle's patch layers parse, merge and land correctly against the real
|
||||||
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
||||||
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
||||||
@@ -264,13 +291,13 @@ it.
|
|||||||
|
|
||||||
```
|
```
|
||||||
index.js host row: the policy, published as ctx.facetPolicy
|
index.js host row: the policy, published as ctx.facetPolicy
|
||||||
client/client.js browser half: the settings card on the Plugins page
|
client/client.js browser half: the settings card, and the context panel
|
||||||
lib/policy.js the stateful half — scope resolution, grants
|
lib/policy.js the stateful half — scope resolution, grants
|
||||||
lib/scope.js the pure half — argument extraction, text, reasons
|
lib/scope.js the pure half — argument extraction, text, reasons
|
||||||
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
||||||
presets/facet.patch.yml the `facet` agent preset
|
presets/facet.patch.yml the `facet` agent preset
|
||||||
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
||||||
test/ 53 tests
|
test/ 73 tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`client/client.js` is a browser half rather than a module the host imports, and
|
`client/client.js` is a browser half rather than a module the host imports, and
|
||||||
@@ -287,6 +314,14 @@ entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
|
|||||||
happens to name them identically, so binding the form to the wrong one produces
|
happens to name them identically, so binding the form to the wrong one produces
|
||||||
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
|
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
|
||||||
|
|
||||||
|
The same file carries the context panel, registered into
|
||||||
|
`conversation.input.left` — the composer tool row — and gated on the session's own
|
||||||
|
`agentPreset` projection, so it appears only where the facet is actually in force.
|
||||||
|
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
|
||||||
|
refuses a path that is not under a volatile node, and refusing is exactly right —
|
||||||
|
the panel is the only writer, and a plugin that could not serve the namespace
|
||||||
|
would be a control that silently stored nothing.
|
||||||
|
|
||||||
## Also in this repository
|
## Also in this repository
|
||||||
|
|
||||||
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
||||||
|
|||||||
+360
-3
@@ -39,7 +39,7 @@ window.__ModuleLoader__.load({
|
|||||||
const react = require('react')
|
const react = require('react')
|
||||||
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||||
|
|
||||||
const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
|
const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
|
||||||
|
|
||||||
const h = react.createElement
|
const h = react.createElement
|
||||||
|
|
||||||
@@ -248,6 +248,328 @@ window.__ModuleLoader__.load({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Mount the facet settings card while the Host serves its namespace.
|
||||||
*
|
*
|
||||||
@@ -263,7 +585,8 @@ window.__ModuleLoader__.load({
|
|||||||
// no settings provider loses the card and keeps everything else this bundle
|
// no settings provider loses the card and keeps everything else this bundle
|
||||||
// contributes.
|
// contributes.
|
||||||
ctx.inject(['configForms'], (scoped) => {
|
ctx.inject(['configForms'], (scoped) => {
|
||||||
const card = new FacetCardController(scoped.configForms.get(SETTINGS_ENTRY))
|
const form = scoped.configForms.get(SETTINGS_ENTRY)
|
||||||
|
const card = new FacetCardController(form)
|
||||||
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
|
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
|
||||||
|
|
||||||
scoped.effect(
|
scoped.effect(
|
||||||
@@ -282,10 +605,44 @@ window.__ModuleLoader__.load({
|
|||||||
),
|
),
|
||||||
'dsh-facet: settings page',
|
'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, FacetCard, FacetCardController }
|
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
|
return module.exports
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
+2
-1
@@ -49,7 +49,8 @@
|
|||||||
"platform": "web",
|
"platform": "web",
|
||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-ui-settings",
|
"@deepseek-ai/dsh-client-ui-settings",
|
||||||
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+173
-14
@@ -31,6 +31,7 @@ async function loadClient() {
|
|||||||
const staged = []
|
const staged = []
|
||||||
|
|
||||||
const primitives = {
|
const primitives = {
|
||||||
|
Modal: 'Modal',
|
||||||
SettingsForm: 'SettingsForm',
|
SettingsForm: 'SettingsForm',
|
||||||
SettingsValueField: 'SettingsValueField',
|
SettingsValueField: 'SettingsValueField',
|
||||||
SegmentedControl: 'SegmentedControl',
|
SegmentedControl: 'SegmentedControl',
|
||||||
@@ -60,7 +61,15 @@ async function loadClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const require = (id) => {
|
const require = (id) => {
|
||||||
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
|
if (id === 'react') {
|
||||||
|
return {
|
||||||
|
createElement: (type, props, ...children) => ({ type, props, children }),
|
||||||
|
// Single-pass stand-ins: the tests render a component once and read the
|
||||||
|
// tree, so a hook only has to answer, not to re-render.
|
||||||
|
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
||||||
|
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
||||||
|
}
|
||||||
|
}
|
||||||
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
|
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
|
||||||
throw new Error(`client.js required an unexpected module: ${id}`)
|
throw new Error(`client.js required an unexpected module: ${id}`)
|
||||||
}
|
}
|
||||||
@@ -76,8 +85,9 @@ async function loadClient() {
|
|||||||
|
|
||||||
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
|
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
|
||||||
function drive(mod, { served = true, value = {} } = {}) {
|
function drive(mod, { served = true, value = {} } = {}) {
|
||||||
const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined }
|
const seen = { formNs: undefined, watched: undefined, registrations: [] }
|
||||||
const disposers = []
|
const disposers = []
|
||||||
|
let currentSlot
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
inject: (names, scope) => {
|
inject: (names, scope) => {
|
||||||
@@ -85,7 +95,7 @@ function drive(mod, { served = true, value = {} } = {}) {
|
|||||||
configForms: {
|
configForms: {
|
||||||
get: (ns) => {
|
get: (ns) => {
|
||||||
seen.formNs = ns
|
seen.formNs = ns
|
||||||
return { value }
|
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
|
||||||
},
|
},
|
||||||
whileServed: (namespaces, register) => {
|
whileServed: (namespaces, register) => {
|
||||||
seen.watched = namespaces
|
seen.watched = namespaces
|
||||||
@@ -97,12 +107,12 @@ function drive(mod, { served = true, value = {} } = {}) {
|
|||||||
},
|
},
|
||||||
slots: {
|
slots: {
|
||||||
inject: (name, register) => {
|
inject: (name, register) => {
|
||||||
seen.slot = name
|
currentSlot = name
|
||||||
register()
|
register()
|
||||||
|
currentSlot = undefined
|
||||||
},
|
},
|
||||||
register: (options, component) => {
|
register: (options, component) => {
|
||||||
seen.entry = options
|
seen.registrations.push({ slot: currentSlot, options, component })
|
||||||
seen.card = component
|
|
||||||
return () => {}
|
return () => {}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -120,6 +130,13 @@ function drive(mod, { served = true, value = {} } = {}) {
|
|||||||
return { seen, disposers }
|
return { seen, disposers }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one registration a test cares about, by the slot it went into. */
|
||||||
|
function registrationFor(seen, slot) {
|
||||||
|
const found = seen.registrations.filter((entry) => entry.slot === slot)
|
||||||
|
assert.equal(found.length, 1, `expected exactly one registration into ${slot}`)
|
||||||
|
return found[0]
|
||||||
|
}
|
||||||
|
|
||||||
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
|
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
|
||||||
const { module: mod } = await loadClient()
|
const { module: mod } = await loadClient()
|
||||||
assert.equal(mod.name, 'dsh-facet')
|
assert.equal(mod.name, 'dsh-facet')
|
||||||
@@ -130,34 +147,51 @@ test('the module is the one the Plugins page loads, and asks for the slots it re
|
|||||||
test('the form binds to the profile entry id while the card registers under the package name', async () => {
|
test('the form binds to the profile entry id while the card registers under the package name', async () => {
|
||||||
const { module: mod } = await loadClient()
|
const { module: mod } = await loadClient()
|
||||||
const { seen } = drive(mod)
|
const { seen } = drive(mod)
|
||||||
|
const card = registrationFor(seen, 'plugins.bundle.config')
|
||||||
|
|
||||||
// These two are deliberately different strings. `facet-policy` is the `id` of
|
// These two are deliberately different strings. `facet-policy` is the `id` of
|
||||||
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
|
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
|
||||||
// card is dispatched by the BUNDLE's package name.
|
// card is dispatched by the BUNDLE's package name.
|
||||||
assert.equal(seen.formNs, 'facet-policy')
|
assert.equal(seen.formNs, 'facet-policy')
|
||||||
assert.equal(seen.entry.key, 'dsh-facet')
|
assert.equal(card.options.key, 'dsh-facet')
|
||||||
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved')
|
assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved')
|
||||||
|
|
||||||
assert.equal(seen.slot, 'plugins.bundle.config')
|
assert.equal(card.slot, 'plugins.bundle.config')
|
||||||
assert.deepEqual(seen.watched, ['facet-policy'])
|
assert.deepEqual(seen.watched, ['facet-policy'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the context button rides the composer tool row, gated on the same namespace', async () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
const { seen } = drive(mod)
|
||||||
|
const button = registrationFor(seen, 'conversation.input.left')
|
||||||
|
|
||||||
|
// An id, not a key: `conversation.input.left` is a list, and the row shows one
|
||||||
|
// entry per id.
|
||||||
|
assert.equal(button.options.id, 'facet-context')
|
||||||
|
assert.equal(typeof button.options.inject, 'function')
|
||||||
|
// It has to receive the settings form, because the panel writes through the
|
||||||
|
// very same document the plugin card does.
|
||||||
|
assert.equal(typeof button.options.inject().form, 'object')
|
||||||
|
})
|
||||||
|
|
||||||
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
|
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
|
||||||
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
|
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
|
||||||
const { module: mod } = await loadClient()
|
const { module: mod } = await loadClient()
|
||||||
const { seen } = drive(mod)
|
const { seen } = drive(mod)
|
||||||
|
const card = registrationFor(seen, 'plugins.bundle.config')
|
||||||
|
|
||||||
// A rename in the patch without a rename here is a card that writes nowhere,
|
// A rename in the patch without a rename here is a card that writes nowhere,
|
||||||
// so the id is read out of the patch rather than trusted to the test.
|
// so the id is read out of the patch rather than trusted to the test.
|
||||||
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
|
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
|
||||||
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, seen.entry.key)
|
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.key)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nothing is registered while the Host does not serve the namespace', async () => {
|
test('nothing is registered while the Host does not serve the namespace', async () => {
|
||||||
const { module: mod } = await loadClient()
|
const { module: mod } = await loadClient()
|
||||||
const { seen } = drive(mod, { served: false })
|
const { seen } = drive(mod, { served: false })
|
||||||
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
|
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
|
||||||
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
|
// Neither the card nor the button: each would be a control that writes nowhere.
|
||||||
|
assert.deepEqual(seen.registrations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the card stages exactly the deployment fields the host marks volatile', async () => {
|
test('the card stages exactly the deployment fields the host marks volatile', async () => {
|
||||||
@@ -212,11 +246,12 @@ test('the list field clears rather than storing an empty array that shadows the
|
|||||||
test('the card renders both views without reaching for anything it was not given', async () => {
|
test('the card renders both views without reaching for anything it was not given', async () => {
|
||||||
const { module: mod } = await loadClient()
|
const { module: mod } = await loadClient()
|
||||||
const { seen } = drive(mod)
|
const { seen } = drive(mod)
|
||||||
|
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
|
||||||
|
|
||||||
assert.equal(typeof seen.card, 'function')
|
assert.equal(typeof card, 'function')
|
||||||
assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
|
assert.equal(card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
|
||||||
|
|
||||||
const tree = seen.card({
|
const tree = card({
|
||||||
view: 'page',
|
view: 'page',
|
||||||
useFacetCard: (select) =>
|
useFacetCard: (select) =>
|
||||||
select({
|
select({
|
||||||
@@ -244,3 +279,127 @@ test('the card renders both views without reaching for anything it was not given
|
|||||||
assert.equal(controls[0].children[1].props.value, 'deny')
|
assert.equal(controls[0].children[1].props.value, 'deny')
|
||||||
assert.equal(controls[1].props.text, 'src/shared')
|
assert.equal(controls[1].props.text, 'src/shared')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the client half loads the packages whose slots it registers into', async () => {
|
||||||
|
const pkg = (await import('../package.json', { with: { type: 'json' } })).default
|
||||||
|
const inject = pkg.dsh.client.inject
|
||||||
|
|
||||||
|
// Registering into a slot is a dependency on the package that declares it.
|
||||||
|
// `slots.inject` waits for a slot rather than creating one, so a half that
|
||||||
|
// names a slot belonging to a package the boot graph has not loaded yet
|
||||||
|
// registers nothing at all — and silently, because nothing errors.
|
||||||
|
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
|
||||||
|
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
|
||||||
|
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the context button and panel ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the context button appears for a facet session and nowhere else', async () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
const { seen } = drive(mod)
|
||||||
|
const { component: button, options } = registrationFor(seen, 'conversation.input.left')
|
||||||
|
const { form } = options.inject()
|
||||||
|
|
||||||
|
const render = (preset) =>
|
||||||
|
button({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
useSessions: (select) => select({ byId: { 'session-1': { projectionValues: { agentPreset: preset } } } }),
|
||||||
|
locked: false,
|
||||||
|
form,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.notEqual(render('facet'), null)
|
||||||
|
// Every other preset in the deployment is untouched: a control for a plugin
|
||||||
|
// that is not running would be worse than no control.
|
||||||
|
assert.equal(render('standard'), null)
|
||||||
|
assert.equal(render(undefined), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('declaredFor normalises whatever the document holds', async () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
|
||||||
|
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 'src/x', extras: ['p'] } } } }, 'a'), {
|
||||||
|
focus: 'src/x',
|
||||||
|
extras: ['p'],
|
||||||
|
})
|
||||||
|
// A session with no entry, a document with no scopes, and junk in either all
|
||||||
|
// answer with the same empty declaration rather than throwing in a render.
|
||||||
|
const empty = { focus: '', extras: [] }
|
||||||
|
assert.deepEqual(mod.declaredFor({ value: {} }, 'a'), empty)
|
||||||
|
assert.deepEqual(mod.declaredFor({}, 'a'), empty)
|
||||||
|
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: 'nonsense' } } }, 'a'), empty)
|
||||||
|
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 7, extras: ['ok', 3, null] } } } }, 'a'), {
|
||||||
|
focus: '',
|
||||||
|
extras: ['ok'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('writeDeclaration writes one whole declaration, and removes an empty one', async () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
const calls = []
|
||||||
|
const form = { mutate: (ops) => (calls.push(ops), true) }
|
||||||
|
|
||||||
|
mod.writeDeclaration(form, 'session-1', { focus: ' src/x ', extras: ['p', ' ', 'q'] })
|
||||||
|
mod.writeDeclaration(form, 'session-1', { focus: '', extras: [] })
|
||||||
|
|
||||||
|
// One path, one write: a queued edit can never half-apply.
|
||||||
|
assert.deepEqual(calls[0], [{ op: 'set', path: ['scopes', 'session-1'], value: { focus: 'src/x', extras: ['p', 'q'] } }])
|
||||||
|
// Both fields empty means "the session directory", which is the schema default
|
||||||
|
// — so it is removed rather than stored as an empty override.
|
||||||
|
assert.deepEqual(calls[1], [{ op: 'unset', path: ['scopes', 'session-1'] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the panel renders the scope, the guidance, and a way to add and remove', async () => {
|
||||||
|
const { module: mod } = await loadClient()
|
||||||
|
const written = []
|
||||||
|
const form = {
|
||||||
|
getSnapshot: () => ({
|
||||||
|
status: 'ready',
|
||||||
|
writable: true,
|
||||||
|
revision: 4,
|
||||||
|
value: { mode: 'deny', scopes: { 'session-1': { focus: 'src/room', extras: ['CONTROLLER.md'] } } },
|
||||||
|
}),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
mutate: (ops) => (written.push(ops), true),
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} })
|
||||||
|
assert.equal(tree.type, 'Modal')
|
||||||
|
|
||||||
|
// Walk the tree for the text and controls a user would see.
|
||||||
|
const texts = []
|
||||||
|
const buttons = []
|
||||||
|
const inputs = []
|
||||||
|
const walk = (node) => {
|
||||||
|
if (typeof node === 'string') return texts.push(node)
|
||||||
|
if (node === null || node === undefined || typeof node !== 'object') return
|
||||||
|
if (Array.isArray(node)) return node.forEach(walk)
|
||||||
|
if (node.type === 'button') buttons.push(node)
|
||||||
|
if (node.type === 'input') inputs.push(node)
|
||||||
|
// Children arrive either as createElement arguments or, as the card does it,
|
||||||
|
// through the `children` prop.
|
||||||
|
if (Array.isArray(node.children)) node.children.forEach(walk)
|
||||||
|
walk(node.props?.children)
|
||||||
|
}
|
||||||
|
walk(tree)
|
||||||
|
|
||||||
|
const labelOf = (node) => (node.children ?? [])[0]
|
||||||
|
|
||||||
|
const prose = texts.join('\n')
|
||||||
|
assert.match(prose, /What this conversation may see/)
|
||||||
|
// The two tabs the preset picker cannot show for a custom preset live here.
|
||||||
|
assert.match(prose, /Mode details/)
|
||||||
|
assert.match(prose, /How to use/)
|
||||||
|
// The honest bit about this deployment, so the panel does not promise a
|
||||||
|
// question no approval channel can carry.
|
||||||
|
assert.match(prose, /no approval channel/)
|
||||||
|
assert.match(prose, /danger-full-access/)
|
||||||
|
|
||||||
|
// The declared scope is on screen, and the existing extra has its own control.
|
||||||
|
assert.equal(inputs[0].props.value, 'src/room')
|
||||||
|
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
|
||||||
|
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
|
||||||
|
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
|
||||||
|
assert.ok(buttons.some((button) => labelOf(button) === 'Set'))
|
||||||
|
})
|
||||||
Reference in new issue
Block a user