Files
dsh-recall/client/client.js
T
NotExpectedYet 28df7a0e56
Release / release (push) Successful in 6s
fix: put the recall chip in the header utilities, not the cell the sidebar's expand button owns
The chip registered into `conversation.session.header.corner`, which is a
SINGLE cell the product already occupies: the client catalog lists its
occupants as `client-ui-sidebar-right ExpandButton` and its replaceRisk as
`shadows-shipped-ui`. Two registrations at priority 0 in a single cell are a
hard error inside the slot registry, and the error lands in the OTHER
plugin's `slots.inject()` callback: the renderer's `changed` handler catches
it, calls `stop()` on that inject controller — killing that contribution for
the rest of the fiber's life — and rethrows it from a microtask, which is the
uncaught `single slot ... already has a registration at priority 0 (registered
by dsh-recall)` in the console. So the chip was not merely racing the shipped
right-sidebar expand control; it was taking it down.

It now registers into `conversation.session.header.utilities`, a list cell
(`replaceRisk: none`, right-aligned, same Session scope and therefore the same
`useProjection` prop) as `{ id: 'dsh-recall', order: 20 }`, so it stays at the
right-hand end without competing for a seat.

The suite could not have caught this: the slot stub accepts any registration
and knows nothing about cardinality, keys or collisions. It now asserts the
chip carries a list id and that this plugin registers nothing into the corner.
2026-09-27 06:36:49 +01:00

802 lines
32 KiB
JavaScript

/**
* dsh-recall — browser half.
*
* Three contributions, each into a seat the Harness 0.1.7 composition declares:
*
* 1. the plugin's configuration page, in the **Plugins** panel;
* 2. a one-line usage chip in a conversation's header utilities;
* 3. a right-sidebar tab with the same figures and room for their caveats.
*
* **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards
* under Settings → Plugins → Plugin configuration, and a plugin claimed one by
* binding `settingsScope` and registering into a `settings.plugin.item` slot.
* Neither exists in 0.1.7. The generated settings document is keyed by the
* plugin's own profile entry id, and a plugin that wants a page renders it as a
* contribution to the Plugins panel — `plugins.bundle.config`, keyed by the
* bundle's package name, which the page draws as a section on the plugin's own
* card. Without a contribution there is no section, however complete the schema
* is; the sibling seat `plugins.row.config` would instead put it one click
* deeper, on a single row's page.
*
* **The page is the framework's form, not a form of our own.** The card used to
* reproduce `PluginCard`'s chrome by hand — the `<li>` root, the disclosure
* button, the unsaved marker — because the 0.1.5 client-purity rule forbade
* importing another plugin's values. 0.1.7 ships that chrome as platform
* primitives: `SettingsFormModel` stages drafts over the settings document and
* `SettingsForm`/`SettingsValueField` render them, with the overridden badges,
* per-field reset, and revision-fenced save that a hand-rolled card had to
* guess at. `@deepseek-ai/dsh-client-ui-primitives` is a module-table seed, so
* it is importable by name and needs no build step.
*
* **The only import is a platform seed.** `react` and the primitives package
* are both in the boot seed table; everything else — the settings document, the
* tab registry, the slot ledgers, the projection the chip reads — arrives as a
* Cordis service or a slot prop. Nothing here reaches into another plugin's
* bundle, which is what keeps this file loadable without a bundler.
*/
window.__ModuleLoader__.load({
id: 'dsh-recall',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const {
SettingsForm,
SettingsFormModel,
SettingsSecretField,
SettingsValueField,
settingsNumberField,
settingsTextField,
} = primitives
const h = react.createElement
/** This plugin's identity: its settings namespace AND its tab-type id. */
const NS = 'dsh-recall'
/**
* The settings document is keyed by the plugin's PROFILE ENTRY id.
*
* That is the `id` of this bundle's row in the composed tree — `dsh-recall`
* in cordis.patch.yml. It is not registered anywhere from here; exporting
* `Config` from the host half is the registration, and this is the key the
* Harness files it under.
*/
const SETTINGS_ENTRY = 'dsh-recall'
/**
* The seat this plugin's configuration page renders in.
*
* `plugins.bundle.config`, keyed by the BUNDLE's package name: the Plugins
* page draws that contribution as a section on the plugin's own card,
* between its description and its rows. That is where someone opening
* "dsh-recall" expects its settings — the alternative seat,
* `plugins.row.config` (keyed `<package>#<row id>`), draws them on a page
* one click deeper, behind the row's title button, which is the right home
* for one row of a bundle that declares several. This bundle declares one
* row, so the card is the honest place.
*/
const BUNDLE_PACKAGE = 'dsh-recall'
const BUNDLE_CONFIG_KEY = BUNDLE_PACKAGE
const TITLE = 'Recall'
const DESCRIPTION =
'Local embedding endpoint, what gets indexed, and how many tokens a result may cost.'
/**
* The settings this page edits, grouped for scanning.
*
* The list mirrors the host schema in index.js, deliberately: each entry
* names the field, the control that stages it, and the copy a person needs
* to choose a value. A field the host does not declare is refused by the
* document, and a declared field missing from this list is simply not
* editable here — so the two are kept in step by
* `test/client.test.js`, which fails when the schema and this list differ.
*/
const FIELDS = [
{
key: 'embeddingApiBase',
kind: 'text',
group: 'Endpoint',
label: 'Embedding endpoint',
hint: 'e.g. http://127.0.0.1:11434 for Ollama. Must be reachable from the machine running the harness.',
},
{
key: 'embeddingModel',
kind: 'text',
group: 'Endpoint',
label: 'Embedding model',
hint: 'The model id as the endpoint knows it. Changing this discards the existing index.',
},
{
key: 'embeddingDimensions',
kind: 'number',
group: 'Endpoint',
label: 'Embedding dimensions',
hint: 'Must match what the model returns. Changing this discards the existing index.',
},
{
key: 'embeddingApiKey',
kind: 'secret',
group: 'Endpoint',
label: 'Embedding API key',
hint: 'Stored as a secret and never returned to this page. Leave blank to keep the current value.',
},
{
key: 'embeddingBatchSize',
kind: 'number',
group: 'Endpoint',
label: 'Inputs per request',
},
{
key: 'embeddingConcurrency',
kind: 'number',
group: 'Endpoint',
label: 'Requests in flight',
hint: 'Keep this low on a small host. A cold index is a sustained CPU load, and too much of it can leave the endpoint unable to load the model.',
},
{
key: 'embeddingTimeoutMs',
kind: 'number',
group: 'Endpoint',
label: 'Request timeout (ms)',
hint: 'Short on purpose. A wedged endpoint accepts the connection and never answers, so a long timeout looks like a hang.',
},
{
key: 'embeddingKeepAlive',
kind: 'text',
group: 'Endpoint',
label: 'Keep-alive',
hint: 'Ollama keep_alive, e.g. 5m. Keeps the model resident for the length of an index run.',
},
{
key: 'extensions',
kind: 'list',
group: 'What gets indexed',
label: 'Extensions',
hint: 'Comma separated. Empty uses the built-in code set (no prose or markup).',
},
{
key: 'exclude',
kind: 'list',
group: 'What gets indexed',
label: 'Extra exclude patterns',
hint: 'Comma separated globs, e.g. src/generated/. Added to the built-in directory list rather than replacing it.',
},
{
key: 'maxFiles',
kind: 'number',
group: 'What gets indexed',
label: 'Most files to consider',
},
{
key: 'maxFileBytes',
kind: 'number',
group: 'What gets indexed',
label: 'Skip files larger than (bytes)',
hint: 'Generated and minified files are usually large; they are skipped rather than indexed as noise.',
},
{ key: 'chunkLines', kind: 'number', group: 'Chunking', label: 'Target lines per chunk' },
{ key: 'maxChunkLines', kind: 'number', group: 'Chunking', label: 'Hard cap before splitting' },
{
key: 'overlapLines',
kind: 'number',
group: 'Chunking',
label: 'Lines shared when splitting',
hint: 'Must be smaller than the hard cap, or a split never advances.',
},
{
key: 'excerptLines',
kind: 'number',
group: 'Results',
label: 'Lines per excerpt',
hint: 'A chunk is a bounded semantic unit, so this defaults to the chunk cap and results come back whole. Truncating below it is how a search turns into a follow-up read that costs more than it saved.',
},
{
key: 'totalExcerptLines',
kind: 'number',
group: 'Results',
label: 'Total lines across one result set',
hint: 'Shared budget, allocated by rank, so the best answer gets the room and the response stays bounded.',
},
{ key: 'defaultLimit', kind: 'number', group: 'Results', label: 'Excerpts returned' },
{ key: 'maxLimit', kind: 'number', group: 'Results', label: 'Most a caller may ask for' },
{
key: 'perFileLimit',
kind: 'number',
group: 'Results',
label: 'Most chunks from one file',
hint: 'Stops one large file filling the whole result set.',
},
{
key: 'minSimilarity',
kind: 'number',
group: 'Results',
label: 'Weak-match threshold',
hint: 'A cosine similarity between 0 and 1. Below this best score, results are reported as a weak match and the model is told to prefer grep.',
},
{
key: 'syncBudgetMs',
kind: 'number',
group: 'Index',
label: 'Index warm-up budget per search (ms)',
hint: 'How long one search may spend embedding changed files before reporting the rest as pending.',
},
{
key: 'stateDir',
kind: 'text',
group: 'Index',
label: 'Index directory',
hint: 'Empty uses $DSH_HOME/dsh-recall/workspaces/<workspace>.',
},
{
key: 'focus',
kind: 'text',
group: 'Focus',
label: 'Default focus',
hint: 'A workspace-relative directory to scope every call to. Empty resolves by marker file, then the workspace root.',
},
{
key: 'focusMarkers',
kind: 'list',
group: 'Focus',
label: 'Scope marker files',
hint: 'Comma separated filenames that make a directory its own scope. Empty uses the built-ins.',
},
{
key: 'expansionEnabled',
kind: 'boolean',
group: 'Query expansion',
label: 'Ask the local model for rephrasings',
hint: 'true or false. Off by default: it spends one local generation per search on a gain that has not been measured against a real model.',
},
{
key: 'expansionModel',
kind: 'text',
group: 'Query expansion',
label: 'Expansion model',
hint: 'Empty uses the embedding model.',
},
{ key: 'expansionCount', kind: 'number', group: 'Query expansion', label: 'Most variants to ask for' },
{
key: 'expansionTimeoutMs',
kind: 'number',
group: 'Query expansion',
label: 'Expansion budget (ms)',
hint: 'Expiry just means the search runs without expansion.',
},
{
key: 'rerankEnabled',
kind: 'boolean',
group: 'Re-ranking',
label: 'Score candidates with the local model',
hint: 'true or false. Off by default: one more model call on the critical path of every search.',
},
{ key: 'rerankModel', kind: 'text', group: 'Re-ranking', label: 'Re-ranking model' },
{
key: 'rerankCandidates',
kind: 'number',
group: 'Re-ranking',
label: 'Candidates scored per search',
},
{ key: 'rerankTimeoutMs', kind: 'number', group: 'Re-ranking', label: 'Re-ranking budget (ms)' },
]
/** Fields the Host redacts, so the page never holds their real value. */
const SECRET_KEYS = FIELDS.filter((field) => field.kind === 'secret').map((field) => field.key)
/** HTML ids, unique per field, so every label points at its own control. */
const controlId = (key) => `dsh-recall-${key}`
/**
* A whole-list field: comma or newline separated in the control, an array of
* non-empty strings in the document.
*
* One of three conversions the shared model asks a field for. `number` and
* `text` ship with the primitives; a list does not, because only the owning
* plugin knows whether its list is comma-separated, JSON, or one-per-line.
*/
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 !== '')
// Empty means "no restriction", which for these fields is the schema
// default — so this clears the override rather than storing an empty
// list that would shadow a deployment's own default with nothing.
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
},
}
}
/**
* A boolean field, edited as text.
*
* The shared model stages strings, so a boolean is staged as `true`/`false`
* and the accepted spellings are broadened rather than guessed at: anything
* else leaves the draft invalid and blocks the save, which is the honest
* outcome for a value the field cannot represent.
*/
function settingsBooleanField(field) {
return {
field,
format: (value) => (value === true ? 'true' : 'false'),
parse: (text) => {
const normalised = String(text).trim().toLowerCase()
if (normalised === '') return { kind: 'clear' }
if (['true', 'on', 'yes', '1'].includes(normalised)) return { kind: 'set', value: true }
if (['false', 'off', 'no', '0'].includes(normalised)) return { kind: 'set', value: false }
return undefined
},
}
}
/** The conversion spec one declared field stages through. */
function specFor(field) {
if (field.kind === 'number') return settingsNumberField(field.key)
if (field.kind === 'list') return settingsListField(field.key)
if (field.kind === 'boolean') return settingsBooleanField(field.key)
// A secret is written outside the section, so it gets a write-only spec
// below rather than a value spec that would render an empty draft as an
// instruction to clear the stored key.
return settingsTextField(field.key)
}
/** Copy the shared form frame renders. */
const FORM_LABELS = {
unavailable: 'dsh-recall is not configurable in this deployment.',
readOnly: 'This deployment stores plugin settings read-only.',
saveFailed: 'The host did not accept that change.',
save: 'Save',
saving: 'Saving…',
}
const OVERRIDDEN_LABEL = 'overridden'
const RESET_LABEL = 'reset'
const INVALID_LABEL = 'Not a value this field accepts.'
/**
* Bridge the settings document onto the shared staged form.
*
* The document's form for one entry is a `ConfigForm`: a sync snapshot, a
* subscription, and one atomic revision-fenced mutation. `SettingsFormModel`
* owns the staging, validation, and save ordering; this class owns the two
* things that are true of dsh-recall in particular — which fields exist, and
* that one of them is a secret the wire never carries — and nothing else.
*/
class RecallSettingsController {
/**
* @param {object} ctx the plugin's client context, carrying `configForms`
*/
constructor(ctx) {
this.ctx = ctx
// Every field the projection reads must exist BEFORE anything projects.
//
// `SettingsFormModel.bind()` is not a subscription setter: it builds the
// snapshot store from one immediate `project()` call, so the projection
// runs once, right here, inside this constructor. Assigning `describe`
// after `bind` made that first projection read `undefined.getSnapshot()`
// — a TypeError thrown while the inject callback was registering the
// card, which aborted the registration and left the plugin with no
// settings section at all and nothing in the UI to say so.
this.describe = ctx.configForms.describe()
this.form = ctx.configForms.get(SETTINGS_ENTRY)
this.model = new SettingsFormModel(
this.form,
FIELDS.filter((field) => field.kind !== 'secret').map(specFor),
SECRET_KEYS.map((key) => ({
field: key,
// A blank draft writes nothing — the model only calls this when the
// user actually typed a key — so opening the page and saving can
// never delete a credential the page was never shown.
write: (text) =>
this.form.mutate([{ op: 'set', path: [key], value: text }], this.form.getSnapshot().revision),
})),
)
this.store = this.model.bind(() => this.projection())
}
/** Everything the page renders, rebuilt whenever the form or a draft moves. */
projection() {
const fields = {}
for (const field of FIELDS) fields[field.key] = this.model.field(field.key)
return {
shell: this.model.shell(),
fields,
secretSet: this.secretIsSet(),
}
}
/**
* Whether the endpoint currently holds an API key.
*
* Read from the namespace view's `secrets`, which names every declared
* secret slot and whether it holds a value — the value itself never
* crosses the wire, which is the whole point of the control being
* write-only.
*
* @returns {boolean} whether a key is stored
*/
secretIsSet() {
const view = this.describe.getSnapshot().view
const namespace = view?.namespaces?.find((entry) => entry.ns === SETTINGS_ENTRY)
return (namespace?.secrets ?? []).some(
(secret) => secret.path.length === 1 && SECRET_KEYS.includes(secret.path[0]) && secret.set === true,
)
}
/**
* The face the slot registration injects.
*
* `hooks` becomes a `use*` prop on the component, which is how a card
* reads its own state through a snapshot selector without importing
* anything from another plugin.
*
* @returns {object} the store and the staged-form actions
*/
inject() {
return {
hooks: { recallSettings: this.store },
...this.model.actions(),
}
}
/** Release the form's subscription to the settings document. */
dispose() {
this.model.dispose()
}
}
/** One field's label, control, hint, and overridden marker. */
function Field(props) {
const { field, state, disabled, onEdit, onReset, secretSet } = props
if (field.kind === 'secret') {
return h(SettingsSecretField, {
id: controlId(field.key),
label: field.label,
hint: field.hint ?? '',
text: state.text,
configured: secretSet,
stateLabel: secretSet ? 'A key is stored.' : 'No key is stored.',
disabled,
onEdit,
})
}
return h(SettingsValueField, {
id: controlId(field.key),
label: field.label,
hint: field.hint,
text: state.text,
overridden: state.overridden,
invalid: state.invalid,
overriddenLabel: OVERRIDDEN_LABEL,
resetLabel: RESET_LABEL,
invalidLabel: INVALID_LABEL,
numeric: field.kind === 'number',
disabled,
onEdit,
onReset,
})
}
/**
* The plugin's configuration page.
*
* The seat asks for `page`, the form with its save control; the `summary`
* branch answers the other shape a configuration entry can be asked for —
* the one-liner standing in for a missing description — because a component
* registered under a seat that only ever asks for `page` is also the natural
* thing to reuse where that shape arrives. Only a save writes, and the form
* discards its drafts on unmount.
*/
function RecallSettingsCard(props) {
if (props.view === 'summary') return DESCRIPTION
const state = props.useRecallSettings((snapshot) => snapshot)
const rows = []
let group
for (const field of FIELDS) {
if (field.group !== group) {
group = field.group
// The only chrome this file draws itself. Thirty-three fields in one
// flat list is a long scroll, and the framework's form renders no
// group headings of its own — so the headings are here, coloured from
// the product's own token rather than a hand-picked grey so they sit
// in the panel instead of beside it.
rows.push(
h(
'h4',
{
key: `group-${group}`,
style: {
margin: '20px 0 0',
fontSize: '12px',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: 'var(--dsw-alias-label-tertiary)',
},
},
group,
),
)
}
rows.push(
h(Field, {
key: field.key,
field,
state: state.fields[field.key],
secretSet: state.secretSet,
disabled: !state.shell.writable || state.shell.saving,
onEdit: (text) => props.edit(field.key, text),
onReset: () => props.resetField(field.key),
}),
)
}
return h(
SettingsForm,
{
labels: FORM_LABELS,
state: state.shell,
onSave: props.save,
onDiscard: props.discard,
},
...rows,
)
}
/** The projection key the host half registers. */
const USAGE_KEY = 'recallUsage'
/** Byte counts a person reads at a glance. */
function humanBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
/**
* Read the usage projection, whatever the slot can offer.
*
* Called unconditionally and before any early return, because it is a hook:
* a conditional call would break the moment the chip had nothing to show,
* which is its normal state in a conversation that has not used the tool.
*/
function useUsage(props) {
if (typeof props.useProjection !== 'function') return undefined
return props.useProjection(USAGE_KEY)
}
/**
* The one-line summary: what it kept out, not what it cost.
*
* The dollar figure is deliberately absent here. At the measured cache-read
* rate a whole conversation's avoided context is worth a fraction of a
* penny, and a chip advertising that would be read as the tool being
* useless rather than as the arithmetic being honest. The detail lives in
* the tab and in the tooltip.
*/
function chipText(usage) {
const parts = ['Recall ' + usage.searches]
if (usage.compression > 1) parts.push(usage.compression + '\u00d7')
if (usage.avoidedBytes > 0) parts.push(humanBytes(usage.avoidedBytes) + ' kept out')
return parts.join(' \u00b7 ')
}
/** Everything a hover can hold, including what is not worth a chip. */
function chipDetail(usage) {
const lines = [
usage.searches + ' search' + (usage.searches === 1 ? '' : 'es') + (usage.indexes ? ', ' + usage.indexes + ' index' : ''),
usage.excerpts + ' excerpt' + (usage.excerpts === 1 ? '' : 's') + ' from ' + usage.files + ' file' + (usage.files === 1 ? '' : 's'),
humanBytes(usage.returnedBytes) + ' returned where ' + humanBytes(usage.sourceBytes) + ' would have been read',
]
if (usage.rereadTokens > 0) {
lines.push(
'about ' + usage.rereadTokens.toLocaleString() + ' tokens re-read since, if those files had been read in full',
)
}
lines.push('estimate: reading every file the excerpts came from, not a measurement of what you would have done')
return lines.join('\n')
}
function RecallChip(props) {
const usage = useUsage(props)
// The utilities list draws nothing for an entry that renders nothing, so
// a conversation that never called the tool costs no layout at all.
if (usage === undefined || !usage.searches) return null
return h(
'span',
{
title: chipDetail(usage),
'aria-label': chipText(usage) + ' \u2014 recall usage for this conversation',
style: {
fontSize: '11px',
opacity: 0.75,
whiteSpace: 'nowrap',
padding: '2px 8px',
borderRadius: '999px',
// The product's own border token. This used to read `--dsh-border`,
// which is defined nowhere — so it always fell back to a hardcoded
// dark grey and drew a dark ring around the chip in a light theme.
border: '1px solid var(--dsw-alias-border-l2)',
},
},
chipText(usage),
)
}
/** One labelled figure in the panel. */
function Figure(props) {
const { label, value, note } = props
return h(
'div',
{ style: { marginBottom: '10px' } },
h('div', { style: { fontSize: '11px', opacity: 0.6 } }, label),
h('div', { style: { fontSize: '15px', fontWeight: 600 } }, value),
note === undefined ? null : h('div', { style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.4 } }, note),
)
}
function RecallPanel(props) {
const usage = useUsage(props)
if (usage === undefined) {
// A host without the projection, or a session that predates it. Say so
// rather than showing zeroes, which would read as "this did nothing".
return h('div', { style: { padding: '16px', fontSize: '12px', opacity: 0.7 } }, 'Recall usage is not available for this conversation.')
}
if (!usage.searches && !usage.indexes) {
return h(
'div',
{ style: { padding: '16px', fontSize: '12px', opacity: 0.7, lineHeight: 1.5 } },
'This conversation has not used recall yet. It returns ranked excerpts instead of whole files, so context it keeps out is never re-sent on a later step.',
)
}
const head = usage.compression > 1 ? usage.compression + '\u00d7' : '\u2014'
return h(
'div',
{ style: { padding: '16px', fontSize: '12px' } },
h(Figure, {
label: 'Kept out of this conversation',
value: humanBytes(usage.avoidedBytes),
note: humanBytes(usage.returnedBytes) + ' returned, where reading those files would have been ' + humanBytes(usage.sourceBytes),
}),
h(Figure, {
label: 'Smaller than reading the files',
value: head,
note: 'The excerpt is a slice; the comparison is against the whole of each file it came from.',
}),
h(Figure, {
label: 'Re-read since',
value: usage.rereadTokens > 0 ? '~' + usage.rereadTokens.toLocaleString() + ' tokens' : 'not yet',
note: 'Context a conversation carries is re-sent on every later step. Avoided context is avoided every time.',
}),
h(Figure, {
label: 'Searches',
value: String(usage.searches) + (usage.indexes ? ' + ' + usage.indexes + ' indexed' : ''),
note: usage.excerpts + ' excerpts from ' + usage.files + ' files' + (usage.weak ? '; ' + usage.weak + ' too weak to use' : ''),
}),
h(
'p',
{ style: { fontSize: '11px', opacity: 0.6, lineHeight: 1.5, marginTop: '4px' } },
'An estimate, and an upper bound: it assumes a whole file would have been read each time. Token counts are bytes \u00f7 4.',
),
)
}
function apply(ctx) {
// The settings page. Reached through `ctx.inject` rather than a top-level
// `inject`, so a deployment that composed no settings provider loses the
// configuration page and keeps the chip and the tab — a required
// dependency would instead mean this plugin never applied at all.
ctx.inject(['configForms'], (scoped) => {
const card = new RecallSettingsController(scoped)
scoped.effect(() => () => {
card.dispose()
}, 'dsh-recall: settings form')
// `whileServed` keeps the registration alive exactly while the Host
// serves this entry's settings. A page for a namespace the Host does not
// serve would be a control that silently writes nowhere.
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(),
},
RecallSettingsCard,
),
),
),
'dsh-recall: settings page',
)
})
// The chip: a right-aligned session utility, next to the header's
// far-right corner.
//
// It is in the `utilities` LIST, not the corner, and that is not a
// cosmetic choice. The corner is a `single` cell whose occupant the
// product already declares — `client-ui-sidebar-right ExpandButton`, the
// right sidebar's expand control — and the client catalog reports it as
// `replaceRisk: "shadows-shipped-ui"`. A second registration at the same
// priority is a hard error inside the slot registry, thrown while the
// other plugin's `slots.inject()` callback runs: the renderer catches it,
// STOPS that inject controller for the rest of the fiber's life, and
// rethrows it uncaught. So squatting there did not merely race — it took
// the shipped expand control down and logged an uncaught error. A list
// entry carrying its own id cannot collide with anyone, and the framework
// draws nothing for an entry that renders nothing.
ctx.slots.inject('conversation.session.header.utilities', () =>
ctx.slots.register(
// `order` ascends left to right, so a high value keeps the chip at
// the right-hand end of the utilities, where it was before.
{ name: 'conversation.session.header.utilities', id: 'dsh-recall', order: 20 },
(props) => h(RecallChip, props),
),
)
// The right-sidebar tab is registered in two stages, and the second is
// useless without the first: a tab TYPE says what a tab is (id, kind,
// title, guide entry), and the keyed slot of the same id supplies its
// body. Registering only the body leaves a type nobody declared, so no
// tab can ever open it.
ctx.inject(['sidebarRightTabs'], (scoped) => {
scoped.effect(
() =>
scoped.sidebarRightTabs.register({
id: NS,
kind: NS,
// `extension` is the band for a type from outside the product, and
// the kind is ours alone — nothing shipped claims it.
priority: 'extension',
title: () => TITLE,
guide: [
{
id: NS,
order: 60,
title: () => TITLE,
description: () => 'What recall kept out of this conversation, and what that estimate assumes.',
},
],
}),
'dsh-recall: right-sidebar tab type',
)
})
// The body, dispatched under the tab type's id. Registered through
// `slots.inject` because the seat belongs to the right sidebar's session
// entry and may not be declared yet.
ctx.slots.inject('sidebar.right.pane.tab', () =>
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: NS }, (props) => h(RecallPanel, props)),
)
}
module.exports = { name: NS, inject: ['slots'], apply }
return module.exports
},
})