diff --git a/README.md b/README.md index 2b6432c..0e27ec9 100644 --- a/README.md +++ b/README.md @@ -146,18 +146,20 @@ Parameters: `query`, `action`, `limit`, `path` (restrict to a subdirectory), 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 - on it, deliberately: at the measured rate a whole conversation's avoided - context is a fraction of a penny, and a chip advertising that reads as the tool - being useless rather than as the arithmetic being honest. The tooltip carries - the full comparison. +- a **chip in the session header's utilities** — `Recall 3 · 40× · 457 KB kept + out`. No price on it, deliberately: at the measured rate a whole conversation's + avoided context is a fraction of a penny, and a chip advertising that reads as + the tool being useless rather than as the arithmetic being honest. The tooltip + carries the full comparison. - 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 read each time. Token counts are bytes ÷ 4."* 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 -framework lays it out only while its occupant renders something. +recall costs no layout at all. The chip is an entry in the header's `utilities` +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 projection unit (`recallUsage`) with a wire view the browser reads through diff --git a/client/client.js b/client/client.js index 5a4f39d..623bba8 100644 --- a/client/client.js +++ b/client/client.js @@ -4,7 +4,7 @@ * 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 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. * * **Where plugin settings live now.** 0.1.5 drew plugin configuration as cards @@ -620,8 +620,8 @@ window.__ModuleLoader__.load({ function RecallChip(props) { const usage = useUsage(props) - // The corner is laid out only while its occupant renders something, so a - // conversation that never called the tool costs no layout at all. + // 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', @@ -735,10 +735,28 @@ window.__ModuleLoader__.load({ ) }) - // The chip: the header's far-right corner, which the framework lays out - // only while its occupant renders something. - ctx.slots.inject('conversation.session.header.corner', () => - ctx.slots.register({ name: 'conversation.session.header.corner' }, (props) => h(RecallChip, props)), + // 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 diff --git a/test/client.test.js b/test/client.test.js index 55b3f01..33c1a07 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -405,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. const { registered, tabTypes } = mount({ withConfigForms: false }) assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ - 'conversation.session.header.corner', + 'conversation.session.header.utilities', 'sidebar.right.pane.tab', ]) 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 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.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', () => { @@ -440,7 +455,7 @@ 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', () => { const { registered } = mount() assert.deepEqual(registered.map((item) => item.meta.name).sort(), [ - 'conversation.session.header.corner', + 'conversation.session.header.utilities', 'plugins.bundle.config', 'sidebar.right.pane.tab', ]) @@ -590,17 +605,17 @@ function usageView(overrides = {}) { test('the header chip renders nothing when the conversation never used it', () => { const { renderSlot } = mount() - // The corner is laid out only while its occupant renders something, so 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.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null) + // The utilities list draws nothing for an entry that renders nothing, so + // this is the state that must cost no space at all. + assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => undefined }), null) + assert.equal(renderSlot('conversation.session.header.utilities', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null) // 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', () => { 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) const text = String(chip.props.children) assert.match(text, /Recall 3/) @@ -613,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', () => { 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 assert.match(detail, /3 searches/) assert.match(detail, /9 excerpts from 5 files/)