feat: set a conversation's scope from the composer, where the agent cannot reach
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:
NotExpectedYet committed 2026-09-27 06:15:53 +01:00
1 parent d9caf4eeec
commit 266e90aa2d
4 files changed
+589 -37

No files matched your search

+360 -3
View File
@@ -39,7 +39,7 @@ window.__ModuleLoader__.load({
const react = require('react')
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
@@ -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.
*
@@ -263,7 +585,8 @@ window.__ModuleLoader__.load({
// no settings provider loses the card and keeps everything else this bundle
// contributes.
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(
@@ -282,10 +605,44 @@ window.__ModuleLoader__.load({
),
'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
},
})