10 Commits
Author SHA1 Message Date
gitea-actions 602c715cbf Release v0.1.5 [skip release] [skip actions] 2026-09-27 05:37:14 +00:00
NotExpectedYet 28df7a0e56 fix: put the recall chip in the header utilities, not the cell the sidebar's expand button owns
Release / release (push) Successful in 6s
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
gitea-actions 811871e978 Release v0.1.4 [skip release] [skip actions] 2026-09-26 06:50:34 +00:00
NotExpectedYet fbf1da7452 fix: register the settings card instead of throwing while building it
Release / release (push) Successful in 7s
The card never appeared, and nothing said why: the plugin's configuration
section was missing from its card in the Plugins panel while a sibling
plugin's showed, on a deployment where the Host demonstrably served both
namespaces (`settings/describe` over the app's own RPC answered
`dsh-recall fields=32 secrets=[embeddingApiKey set]`) and the browser was
demonstrably serving this bundle.

The cause was construction order. `SettingsFormModel.bind()` is not a
subscription setter — it builds its snapshot store from one immediate
`project()` call — so the projection runs inside the constructor, and the
projection reads the describe mirror to report whether the API key is set.
`this.describe` was assigned *after* `bind`, so that first projection read
`undefined.getSnapshot()`:

  TypeError: Cannot read properties of undefined (reading 'getSnapshot')
      at RecallSettingsController.secretIsSet (client/client.js:428)
      at RecallSettingsController.projection (client/client.js:413)

It was thrown from inside the `ctx.inject(['configForms'], …)` callback while
the card was being registered, so the registration was abandoned: no ledger
entry, no section on the card, and no error in the UI — the one failure mode
this file's own header set out to prevent.

Everything the projection reads is now assigned before anything projects. The
sibling plugin was never affected because it reads the same face lazily from a
field it assigns first.

The test stub's `bind()` only *stored* the projection, which is why the suite
was green through all of this. It now projects once, as the framework does —
and with the old ordering every mounting test fails with that TypeError, so
this cannot regress silently again.
2026-09-26 07:43:31 +01:00
gitea-actions a350178434 Release v0.1.3 [skip release] [skip actions] 2026-09-26 06:07:51 +00:00
NotExpectedYet 7f1dfddb83 Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall
Release / release (push) Successful in 6s
2026-09-26 07:07:33 +01:00
NotExpectedYet 233215e6c9 fix: draw the settings on the plugin card, not a click deeper
The page was registered into `plugins.row.config`, keyed
`<package>#<row id>`. That seat puts a page behind the ROW's title button on the
bundle's card — the right home for one row of a bundle that declares several,
and the reasoning the previous commit wrote down. What it missed is that this
bundle declares exactly one row, so its settings became reachable only by
opening the card and noticing that the row title had turned into a button.
Reported from a real install as "it shows the plugin just no settings", while
the sibling plugin's settings were visible: that one registers into
`plugins.bundle.config`.

`plugins.bundle.config` is the same document, dispatched on the card itself:
`PackageDetail` gates a section on `ledger.bundles.has(pkg.name)` and renders
`renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })`
between the description and the rows. So the key becomes the bundle's package
name, and the form draws where someone opening "dsh-recall" is already looking.

The section gets no `form` prop at this seat, unlike the row page — which is
fine, because the card reads its own form from `configForms` rather than
depending on the one handed in.

Verified by serving the bundle from a profile that links this checkout and
reading the artifact back: the served `client.js` carries
`plugins.bundle.config` at the registration and names the retired seat only in
prose. The tests pin the new key and now fail if either the seat or the key
drifts.
2026-09-26 07:05:52 +01:00
gitea-actions df2fa0f54b Release v0.1.2 [skip release] [skip actions] 2026-09-26 05:53:08 +00:00
NotExpectedYet 1f42d2a821 Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall
Release / release (push) Successful in 6s
2026-09-26 06:52:50 +01:00
NotExpectedYet 269360a4ea docs: lead the install instructions with the published git spec
`dsh plugin --profile web add /path/to/dsh-recall` was the only form given,
which is the development one: it is what you type against a checkout, not what
somebody installing this repository types. The published spec now comes first,
exactly as the maintainer writes it, with the `#semver:^0.1.0` variant beside it
because that is what keeps `dsh plugin update` following release tags — the
Releases section explains why, and this points at it.

The directory form stays, reframed as the alternative it is: a `link:`
dependency that tracks a working tree, so an edit needs no reinstall.

The directory form is the one exercised in a profile here; the remote form
cannot be resolved from this machine, which has no credentials for that host.
2026-09-26 06:51:49 +01:00
5 changed files with 176 additions and 65 deletions

No files matched your search

+28
View File
@@ -2,6 +2,34 @@
Every release, newest first, generated from the commits in each release. Every release, newest first, generated from the commits in each release.
## v0.1.5 — 2026-09-27
### Fixed
- put the recall chip in the header utilities, not the cell the sidebar's expand button owns
## v0.1.4 — 2026-09-26
### Fixed
- register the settings card instead of throwing while building it
## v0.1.3 — 2026-09-26
### Fixed
- draw the settings on the plugin card, not a click deeper
## v0.1.2 — 2026-09-26
### Documented
- lead the install instructions with the published git spec
## v0.1.1 — 2026-09-26 ## v0.1.1 — 2026-09-26
### Fixed ### Fixed
+39 -14
View File
@@ -49,6 +49,22 @@ about a millisecond; the only per-query network cost is one embedding call.
## Install ## Install
```sh
dsh plugin --profile web add git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git
```
That form installs whatever the default branch holds, pinned to a commit in the
profile's lockfile. The semver fragment installs the newest release tag instead,
and is what makes `dsh plugin update` follow releases — see
[Releases](#releases) for the tag and versioning rules:
```sh
dsh plugin --profile web add 'git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git#semver:^0.1.0'
```
To run your own copy instead, add the directory — that becomes a `link:`
dependency which tracks the working tree, so an edit needs no reinstall:
```sh ```sh
dsh plugin --profile web add /path/to/dsh-recall dsh plugin --profile web add /path/to/dsh-recall
``` ```
@@ -65,9 +81,11 @@ a fact:
embeddingApiBase: http://127.0.0.1:11434 # replace with your endpoint embeddingApiBase: http://127.0.0.1:11434 # replace with your endpoint
``` ```
Or edit every setting from the UI: **Plugins → dsh-recall → Configure**. The Or edit every setting from the UI: **Plugins** in the sidebar → the
plugin ships **two halves**, and the page appears only when both are present and **dsh-recall** card → its settings are on that card, below the description.
the deployment serves the namespace: (Not Settings → *Built-in plugins*: that section is a read-only inventory of
what the deployment ships.) The plugin ships **two halves**, and the section
appears only when both are present and the deployment serves the namespace:
- the **host half** (`index.js`) exports a `Config` schema whose every field is - the **host half** (`index.js`) exports a `Config` schema whose every field is
`.volatile()`. That schema *is* the settings page's description — the Harness `.volatile()`. That schema *is* the settings page's description — the Harness
@@ -76,11 +94,16 @@ the deployment serves the namespace:
a schema without those markers has no settings surface at all rather than an a schema without those markers has no settings surface at all rather than an
empty one; empty one;
- the **browser half** (`client/client.js`) renders that form into the Plugins - the **browser half** (`client/client.js`) renders that form into the Plugins
panel's per-row configuration seat, `plugins.row.config`, keyed panel's per-bundle configuration seat, `plugins.bundle.config`, keyed by the
`<bundle package>#<row id>` — here `dsh-recall#dsh-recall`. Registering it is bundle's package name. Registering it is what draws the section; without it
what adds the **Configure** control to the plugin's row. there is no settings UI however complete the schema is.
A served namespace with no page draws no control, and a page whose namespace is The sibling seat `plugins.row.config` — keyed `<package>#<row id>` — draws one
row's configuration on that row's own page. That is the right home for one row
of a bundle that declares several; this bundle declares one, so its settings
belong on the card.
A served namespace with no page draws nothing, and a page whose namespace is
not served is deliberately not registered — so both are required. A restart is not served is deliberately not registered — so both are required. A restart is
needed for a newly installed browser half to appear, because client bundle bytes needed for a newly installed browser half to appear, because client bundle bytes
are snapshotted when the profile activates. are snapshotted when the profile activates.
@@ -123,18 +146,20 @@ Parameters: `query`, `action`, `limit`, `path` (restrict to a subdirectory),
Two surfaces, both reading a host projection rather than polling: Two surfaces, both reading a host projection rather than polling:
- a **chip in the session header** — `Recall 3 · 40× · 457 KB kept out`. No price - a **chip in the session header's utilities** — `Recall 3 · 40× · 457 KB kept
on it, deliberately: at the measured rate a whole conversation's avoided out`. No price on it, deliberately: at the measured rate a whole conversation's
context is a fraction of a penny, and a chip advertising that reads as the tool avoided context is a fraction of a penny, and a chip advertising that reads as
being useless rather than as the arithmetic being honest. The tooltip carries the tool being useless rather than as the arithmetic being honest. The tooltip
the full comparison. carries the full comparison.
- a **right-sidebar Recall tab** — the same figures with room for what they are - a **right-sidebar Recall tab** — the same figures with room for what they are
not: *"An estimate, and an upper bound: it assumes a whole file would have been not: *"An estimate, and an upper bound: it assumes a whole file would have been
read each time. Token counts are bytes ÷ 4."* read each time. Token counts are bytes ÷ 4."*
Both render nothing when there is nothing to say — a conversation that never used Both render nothing when there is nothing to say — a conversation that never used
recall costs no layout at all, which matters for the header corner because the recall costs no layout at all. The chip is an entry in the header's `utilities`
framework lays it out only while its occupant renders something. list, carrying its own id. The far-right corner is deliberately left alone: it is
a single cell that the product's own right-sidebar expand control occupies, so a
registration there shadows shipped UI rather than sitting beside it.
The data path is `presentationMeta` → the `tool/result` event's `meta` → a The data path is `presentationMeta` → the `tool/result` event's `meta` → a
projection unit (`recallUsage`) with a wire view the browser reads through projection unit (`recallUsage`) with a wire view the browser reads through
+58 -25
View File
@@ -4,7 +4,7 @@
* Three contributions, each into a seat the Harness 0.1.7 composition declares: * Three contributions, each into a seat the Harness 0.1.7 composition declares:
* *
* 1. the plugin's configuration page, in the **Plugins** panel; * 1. the plugin's configuration page, in the **Plugins** panel;
* 2. a one-line usage chip in a conversation's header corner; * 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. * 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 * **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards
@@ -12,10 +12,11 @@
* binding `settingsScope` and registering into a `settings.plugin.item` slot. * binding `settingsScope` and registering into a `settings.plugin.item` slot.
* Neither exists in 0.1.7. The generated settings document is keyed by the * 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 * plugin's own profile entry id, and a plugin that wants a page renders it as a
* contribution to the Plugins panel: `plugins.row.config`, keyed * contribution to the Plugins panel — `plugins.bundle.config`, keyed by the
* `<bundle package>#<row id>`. That key is what puts a **configure** control on * bundle's package name, which the page draws as a section on the plugin's own
* the plugin's row — without a contribution there is no control and no page, * card. Without a contribution there is no section, however complete the schema
* however complete the schema is. * 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 * **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 * reproduce `PluginCard`'s chrome by hand — the `<li>` root, the disclosure
@@ -70,13 +71,17 @@ window.__ModuleLoader__.load({
/** /**
* The seat this plugin's configuration page renders in. * The seat this plugin's configuration page renders in.
* *
* Keyed `<bundle package name>#<row id>`, both as the profile's patch * `plugins.bundle.config`, keyed by the BUNDLE's package name: the Plugins
* declares them. For this bundle the two coincide, which is a coincidence * page draws that contribution as a section on the plugin's own card,
* and not a rule — the constants are kept separate so that stays visible. * 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_PACKAGE = 'dsh-recall'
const BUNDLE_ROW = 'dsh-recall' const BUNDLE_CONFIG_KEY = BUNDLE_PACKAGE
const ROW_CONFIG_KEY = `${BUNDLE_PACKAGE}#${BUNDLE_ROW}`
const TITLE = 'Recall' const TITLE = 'Recall'
const DESCRIPTION = const DESCRIPTION =
@@ -380,6 +385,16 @@ window.__ModuleLoader__.load({
*/ */
constructor(ctx) { constructor(ctx) {
this.ctx = 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.form = ctx.configForms.get(SETTINGS_ENTRY)
this.model = new SettingsFormModel( this.model = new SettingsFormModel(
this.form, this.form,
@@ -394,8 +409,6 @@ window.__ModuleLoader__.load({
})), })),
) )
this.store = this.model.bind(() => this.projection()) this.store = this.model.bind(() => this.projection())
/** The describe mirror that knows whether a secret is configured. */
this.describe = ctx.configForms.describe()
} }
/** Everything the page renders, rebuilt whenever the form or a draft moves. */ /** Everything the page renders, rebuilt whenever the form or a draft moves. */
@@ -486,10 +499,12 @@ window.__ModuleLoader__.load({
/** /**
* The plugin's configuration page. * The plugin's configuration page.
* *
* The Plugins page asks a configuration entry twice: once for a one-liner to * The seat asks for `page`, the form with its save control; the `summary`
* stand in for a missing description (`summary`), and once for the body of * branch answers the other shape a configuration entry can be asked for —
* the plugin's own page (`page`). Only the second draws a save control — * the one-liner standing in for a missing description — because a component
* and only one save writes, because the form discards its drafts on unmount. * 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) { function RecallSettingsCard(props) {
if (props.view === 'summary') return DESCRIPTION if (props.view === 'summary') return DESCRIPTION
@@ -605,8 +620,8 @@ window.__ModuleLoader__.load({
function RecallChip(props) { function RecallChip(props) {
const usage = useUsage(props) const usage = useUsage(props)
// The corner is laid out only while its occupant renders something, so a // The utilities list draws nothing for an entry that renders nothing, so
// conversation that never called the tool costs no layout at all. // a conversation that never called the tool costs no layout at all.
if (usage === undefined || !usage.searches) return null if (usage === undefined || !usage.searches) return null
return h( return h(
'span', 'span',
@@ -705,11 +720,11 @@ window.__ModuleLoader__.load({
scoped.effect( scoped.effect(
() => () =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () => scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('plugins.row.config', () => scoped.slots.inject('plugins.bundle.config', () =>
scoped.slots.register( scoped.slots.register(
{ {
name: 'plugins.row.config', name: 'plugins.bundle.config',
key: ROW_CONFIG_KEY, key: BUNDLE_CONFIG_KEY,
inject: () => card.inject(), inject: () => card.inject(),
}, },
RecallSettingsCard, RecallSettingsCard,
@@ -720,10 +735,28 @@ window.__ModuleLoader__.load({
) )
}) })
// The chip: the header's far-right corner, which the framework lays out // The chip: a right-aligned session utility, next to the header's
// only while its occupant renders something. // far-right corner.
ctx.slots.inject('conversation.session.header.corner', () => //
ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)), // 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 // The right-sidebar tab is registered in two stages, and the second is
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-recall", "name": "dsh-recall",
"version": "0.1.1", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Local-embedding code recall for DeepSeek Harness — returns ranked excerpts with line ranges instead of whole files, so the context window stops growing with every read.", "description": "Local-embedding code recall for DeepSeek Harness — returns ranked excerpts with line ranges instead of whole files, so the context window stops growing with every read.",
+50 -25
View File
@@ -7,10 +7,13 @@
* leaving the page to be eyeballed in a running app, because the failures this * leaving the page to be eyeballed in a running app, because the failures this
* file exists to catch are structural and invisible until someone opens Plugins: * file exists to catch are structural and invisible until someone opens Plugins:
* *
* - the configuration page is registered into `plugins.row.config` under * - the configuration page is registered into `plugins.bundle.config` under
* `<bundle package>#<row id>`. The 0.1.5 seat (`settings.plugin.item`, bound * the bundle's package name, which is the seat the Plugins page draws as a
* through `settingsScope`) does not exist in 0.1.7, so a page left in it * section on the plugin's own card. The 0.1.5 seat (`settings.plugin.item`,
* registers against a slot nobody declares and simply never appears. * bound through `settingsScope`) does not exist in 0.1.7, so a page left in
* it registers against a slot nobody declares and simply never appears; the
* sibling seat `plugins.row.config` renders one click deeper instead, on a
* single row's page, which is not where someone opening the card looks.
* - the right-sidebar tab needs a TYPE registered before its body means * - the right-sidebar tab needs a TYPE registered before its body means
* anything: the keyed body slot is dispatched under the definition's `id`. * anything: the keyed body slot is dispatched under the definition's `id`.
* - the field list here and the `Config` schema in index.js must name the same * - the field list here and the `Config` schema in index.js must name the same
@@ -106,6 +109,11 @@ function makePrimitives(record) {
bind(project) { bind(project) {
this.project = project this.project = project
// Faithful to the framework: `bind` is not a subscription setter, it
// builds the snapshot store from ONE immediate projection. A stub that
// only stored the projection would let a controller read a field it has
// not assigned yet — which is exactly the bug this reproduces.
project()
return { getSnapshot: () => project(), subscribe: () => () => {} } return { getSnapshot: () => project(), subscribe: () => () => {} }
} }
@@ -322,7 +330,7 @@ function mount(options = {}) {
* does, so the component under test sees the props a real render would give it. * does, so the component under test sees the props a real render would give it.
*/ */
const card = (view) => { const card = (view) => {
const item = byName.get('plugins.row.config') const item = byName.get('plugins.bundle.config')
assert.ok(item !== undefined, 'no settings page was registered') assert.ok(item !== undefined, 'no settings page was registered')
const props = { ...item.meta.inject(), view } const props = { ...item.meta.inject(), view }
delete props.hooks delete props.hooks
@@ -364,13 +372,15 @@ test('the client module claims the slot registry in its inject list', () => {
assert.deepEqual(module.inject, ['slots']) assert.deepEqual(module.inject, ['slots'])
}) })
test('the configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => { test('the configuration page claims the seat the plugin card draws', () => {
const { registered } = mount() const { registered } = mount()
const page = registered.find((item) => item.meta.name === 'plugins.row.config') const page = registered.find((item) => item.meta.name === 'plugins.bundle.config')
assert.ok(page !== undefined, 'the page must register into plugins.row.config') assert.ok(page !== undefined, 'the page must register into plugins.bundle.config')
// `<bundle package name>#<row id>`: the row id is the one this bundle's own // Keyed by the BUNDLE's package name, which is what the Plugins page
// cordis.patch.yml declares, which is what the Plugins page dispatches by. // dispatches this seat with (`entryKey: pkg.name`). The sibling seat,
assert.equal(page.meta.key, 'dsh-recall#dsh-recall') // `plugins.row.config`, would be keyed `<package>#<row id>` and would render
// one click deeper, on the row's own page.
assert.equal(page.meta.key, 'dsh-recall')
assert.equal(typeof page.meta.inject, 'function') assert.equal(typeof page.meta.inject, 'function')
}) })
@@ -385,7 +395,7 @@ test('no page is registered for a namespace the Host does not serve', () => {
// onto fields nothing would accept. // onto fields nothing would accept.
const { registered } = mount({ served: ['some-other-plugin'] }) const { registered } = mount({ served: ['some-other-plugin'] })
assert.equal( assert.equal(
registered.some((item) => item.meta.name === 'plugins.row.config'), registered.some((item) => item.meta.name === 'plugins.bundle.config'),
false, false,
) )
}) })
@@ -395,17 +405,32 @@ test('the chip and the sidebar tab survive a deployment with no settings provide
// profile without a settings provider loses the page and keeps the rest. // profile without a settings provider loses the page and keeps the rest.
const { registered, tabTypes } = mount({ withConfigForms: false }) const { registered, tabTypes } = mount({ withConfigForms: false })
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner', 'conversation.session.header.utilities',
'sidebar.right.pane.tab', 'sidebar.right.pane.tab',
]) ])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings') assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
}) })
test('the header chip is a single-seat contribution with no key', () => { test('the header chip is an additive list entry carrying its own id', () => {
// The header's far-right corner is a SINGLE cell, and the product already
// occupies it with its right-sidebar expand control — the client catalog
// reports that key as `replaceRisk: "shadows-shipped-ui"`. A second
// registration at the same priority is a hard error inside the registry,
// thrown while the OTHER plugin's `slots.inject()` callback runs: the
// renderer catches it, stops that controller for the rest of the fiber's
// life, and rethrows it uncaught. So the chip belongs in a list cell, where
// a unique id makes it additive.
const { registered } = mount() const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.corner') const chip = registered.find((item) => item.meta.name === 'conversation.session.header.utilities')
assert.ok(chip !== undefined) assert.ok(chip !== undefined)
assert.equal(chip.meta.key, undefined, 'the corner is a single seat, so it takes no key') assert.equal(chip.meta.id, 'dsh-recall', 'a list cell is addressed by its id')
assert.equal(typeof chip.meta.order, 'number', 'the order places the cell within the list')
assert.equal(chip.meta.key, undefined, 'a list cell takes an id, not a key')
assert.deepEqual(
registered.filter((item) => item.meta.name === 'conversation.session.header.corner'),
[],
'the corner is a single cell the product owns; a registration there shadows shipped UI',
)
}) })
test('the sidebar tab registers a type AND a body under the same id', () => { test('the sidebar tab registers a type AND a body under the same id', () => {
@@ -430,8 +455,8 @@ test('the sidebar tab registers a type AND a body under the same id', () => {
test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => { test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => {
const { registered } = mount() const { registered } = mount()
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner', 'conversation.session.header.utilities',
'plugins.row.config', 'plugins.bundle.config',
'sidebar.right.pane.tab', 'sidebar.right.pane.tab',
]) ])
}) })
@@ -580,17 +605,17 @@ function usageView(overrides = {}) {
test('the header chip renders nothing when the conversation never used it', () => { test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
// The corner is laid out only while its occupant renders something, so this // The utilities list draws nothing for an entry that renders nothing, so
// is the state that must cost no space at all. // this is the state that must cost no space at all.
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null) assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => undefined }), null)
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null) assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
// And a host without the projection service at all. // And a host without the projection service at all.
assert.equal(renderSlot('conversation.session.header.corner', {}), null) assert.equal(renderSlot('conversation.session.header.utilities', {}), null)
}) })
test('the header chip leads with what was kept out, not what it cost', () => { test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() }) const chip = renderSlot('conversation.session.header.utilities', { useProjection: () => usageView() })
assert.ok(chip !== null) assert.ok(chip !== null)
const text = String(chip.props.children) const text = String(chip.props.children)
assert.match(text, /Recall 3/) assert.match(text, /Recall 3/)
@@ -603,7 +628,7 @@ test('the header chip leads with what was kept out, not what it cost', () => {
test('the chip tooltip carries the figures the chip has no room for', () => { test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount() const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) }) const chip = renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title const detail = chip.props.title
assert.match(detail, /3 searches/) assert.match(detail, /3 searches/)
assert.match(detail, /9 excerpts from 5 files/) assert.match(detail, /9 excerpts from 5 files/)