feat(addons): say on the Memory add-on page that memory is built in

Add a Notice component to the add-on settings kit so a package can show
the host's own notice: severity, title, reason and one action. The
Memory add-on page uses it to say that memory is built in and to link to
Settings > Memory. A host without Notice falls back to Callout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ettore Di GiacintoandClaude Sonnet 5.5 committed 2026-11-05 00:42:58 +00:00
1 parent 69bbd61b44
commit 9012d3220c
7 files changed
+75 -3

No files matched your search

+2 -1
View File
@@ -3,7 +3,7 @@
// (core/http/react-ui/src/addons/hostComponents.jsx); these keep the same
// props and draw plain markup with the test ids, so a test can check what an
// add-on asks the host to draw without the host's build.
export const KIT = ['Section', 'Field', 'Select', 'TextInput', 'Switch', 'Choice', 'Callout', 'StatusChip', 'List', 'ListRow', 'Code', 'Actions', 'Table', 'ExternalLink']
export const KIT = ['Section', 'Field', 'Select', 'TextInput', 'Switch', 'Choice', 'Callout', 'Notice', 'StatusChip', 'List', 'ListRow', 'Code', 'Actions', 'Table', 'ExternalLink']
export function kitStubs(React) {
const h = React.createElement
@@ -18,6 +18,7 @@ export function kitStubs(React) {
(p.options || []).map(o => h('label', { key: o.value, 'data-value': o.value, 'data-selected': p.value === o.value ? '' : undefined },
h('input', { type: 'radio', checked: p.value === o.value, onChange: () => {}, disabled: o.disabled }), o.label, o.chip || null, o.description ? h('span', null, o.description) : null))),
Callout: (p) => h('div', { 'data-kit': 'Callout', 'data-tone': p.tone || 'info', role: p.role, ...tid(p) }, p.title ? h('strong', null, p.title) : null, p.children, p.actions || null),
Notice: (p) => h('div', { 'data-kit': 'Notice', 'data-severity': p.severity || 'info', role: 'note', ...tid(p) }, p.title ? h('strong', null, p.title) : null, p.reason ? h('p', null, p.reason) : null, p.fixLabel ? h('button', { type: 'button', onClick: p.onFix }, p.fixLabel) : null, p.children),
StatusChip: (p) => h('span', { 'data-kit': 'StatusChip', 'data-tone': p.tone || 'idle', ...tid(p) }, p.children),
List: (p) => h('ul', { 'data-kit': 'List', 'aria-label': p.label, ...tid(p) }, p.children),
ListRow: (p) => h('li', { 'data-kit': 'ListRow', ...tid(p) }, h('strong', null, p.title), p.status || null, p.meta ? h('span', null, p.meta) : null, p.children,
+16
View File
@@ -9,6 +9,8 @@
// validates it. The rules that make it safe are the host's: this page only
// states them.
export const NOTES_ADDRESS = '/app/account?tab=memory'
// Memory is built in; the save level lives in Settings > Memory.
export const SETTINGS_ADDRESS = '/app/account?tab=memory'
export const SAVE_MODE_KEY = 'save-mode'
export const SAVE_MODES = ['ask', 'auto', 'never']
@@ -23,6 +25,9 @@ export function createAddon(sdk) {
'memory.rules.scopes': 'There are three kinds of notes: yours for every chat, one for each saved agent, and one for each conversation that comes from a messaging app. A saved agent reads notes only when you allow this add-on for it.',
'memory.rules.limits': 'The model reads a short list, at most 40 lines for each kind, and opens a topic when it needs the details.',
'memory.open': 'Open my notes',
'memory.builtin.title': 'Memory is built in',
'memory.builtin.reason': 'Memory is part of LocalAI now, and you do not need this add-on for it. Choose how notes are saved in Settings > Memory.',
'memory.builtin.fix': 'Open Settings > Memory',
'memory.save.title': 'Saving a note',
'memory.save.legend': 'When the assistant wants to save something to my memory',
'memory.save.ask': 'Ask me first',
@@ -86,11 +91,22 @@ export function createAddon(sdk) {
failed ? h(sdk.ui.Callout, { tone: 'warning', role: 'alert', testId: 'memory-save-failed' }, t('memory.save.failed')) : null)
}
// Memory is part of the host now. Older hosts have no Notice; they get the
// Callout with the same words.
function BuiltinNotice() {
const open = () => sdk.navigation.openApp(SETTINGS_ADDRESS)
if (sdk.ui.Notice) {
return h(sdk.ui.Notice, { severity: 'info', title: t('memory.builtin.title'), reason: t('memory.builtin.reason'), fixLabel: t('memory.builtin.fix'), onFix: open, testId: 'memory-builtin-notice' })
}
return h(sdk.ui.Callout, { tone: 'info', title: t('memory.builtin.title'), testId: 'memory-builtin-notice' }, t('memory.builtin.reason'))
}
function Component({ page }) {
if (page !== 'memory') return null
return (
<section data-testid="memory-page" className="memory-page">
<h1 className="page-title memory-page__title">{t('memory.title')}</h1>
<BuiltinNotice />
<p>{t('memory.intro')}</p>
<ul className="memory-page__rules">
<li>{t('memory.rules.confirm')}</li>
+10
View File
@@ -124,3 +124,13 @@ test('settings: the page reads the setting and does not guess before it arrives'
assert.match(html, /data-testid="memory-save-loading"/)
assert.doesNotMatch(html, /type="radio"/)
})
test('settings: the page says memory is built in and links to Settings > Memory', async () => {
const p = await mount(async () => view('ask'))
await settle()
const n = p.q('[data-testid="memory-builtin-notice"]')
assert.ok(n, 'the notice is on the page')
assert.match(n.textContent, /Memory is built in/)
assert.match(n.textContent, /Settings > Memory/)
await p.close()
})
@@ -0,0 +1,36 @@
import fs from 'node:fs'
import path from 'node:path'
import { test, expect } from './coverage-fixtures.js'
import { ADMIN, mockAddons, sessionFor, stubAddonRoutes } from './addons-fixtures.js'
import { buildAddonBundles, packagedApp } from './studio-addon.js'
// The page of the Memory add-on says that memory is built in and links to
// Settings > Memory. The add-on is served from its build output.
const DIST = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../../addons/memory/ui/dist')
test.beforeAll(async () => {
if (process.env.E2E_ADDON_BUNDLES_BUILT) return
await buildAddonBundles(['memory'])
})
test('the add-on page has a notice that memory is built in, with a link to Settings > Memory', async ({ page }) => {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
await stubAddonRoutes(page)
await page.route(/\/api\/addons\/memory\/assets\//, (route) => {
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
const type = { js: 'text/javascript', css: 'text/css', png: 'image/png' }[file.split('.').pop()]
if (!type || !fs.existsSync(path.join(DIST, file))) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
return route.fulfill({ status: 200, contentType: type, headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(DIST, file)) })
})
await page.route('**/api/addons/memory/user-settings', route => route.fulfill({ json: { settings: [{ key: 'save-mode', values: ['ask', 'auto', 'never'], default: 'ask', value: 'ask' }] } }))
await mockAddons(page, { apps: [packagedApp('memory')] })
await page.goto('/app/addons/memory/memory')
const notice = page.getByTestId('memory-builtin-notice')
await expect(notice).toBeVisible()
await expect(notice).toContainText('Memory is built in')
await expect(notice).toContainText('Settings > Memory')
await page.screenshot({ path: 'test-results/memory-addon-notice.png' })
await notice.getByRole('button', { name: 'Open Settings > Memory' }).click()
await expect(page).toHaveURL(/\/app\/account\?tab=memory$/)
})
@@ -6,6 +6,7 @@ import StatusPill from '../components/StatusPill'
import { useId } from 'react'
import HostSwitch from '../components/ui/Switch'
import HostCallout from '../components/ui/Callout'
import HostNotice from '../components/ui/Notice'
import HostStatusChip from '../components/ui/StatusChip'
// Small wrappers over components the host already ships: an add-on gets the
@@ -166,6 +167,13 @@ export function Callout({ tone = 'info', title, actions, role, testId, children
return <HostCallout tone={CALLOUT_TONES.has(tone) ? tone : 'info'} title={title} actions={actions} role={role === 'alert' ? 'alert' : undefined} testId={testId}>{children}</HostCallout>
}
// The host's Notice: what is off or not available, why, and one fix. The
// add-on gives plain data; the fix is a label and a handler, never markup.
export function Notice({ severity = 'info', title, reason, fixLabel, onFix, testId, children }) {
const fix = fixLabel && typeof onFix === 'function' ? { label: String(fixLabel), onClick: onFix } : undefined
return <HostNotice severity={severity} title={title} reason={reason} fix={fix} testId={testId}>{children}</HostNotice>
}
export function StatusChip({ tone = 'idle', children, testId, title }) {
return <HostStatusChip tone={CHIP_TONES.has(tone) ? tone : 'idle'} testId={testId} title={text(title) ?? undefined}>{children}</HostStatusChip>
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { resourceRequest, dispatchArgs } from './resourceCalls'
import { userSettingsRequest } from './userSettingsCalls'
import {
Button, Spinner, EmptyState, Badge, Stack, Text,
Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
Section, Field, Select, TextInput, Switch, Choice, Callout, Notice, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
} from './hostComponents'
@@ -74,7 +74,7 @@ export const host = Object.freeze({
React,
components: {
Button, Spinner, EmptyState, Badge, Stack, Text, ArtifactCard,
Section, Field, Select, TextInput, Switch, Choice, Callout, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
Section, Field, Select, TextInput, Switch, Choice, Callout, Notice, StatusChip, List, ListRow, Code, Actions, Table, ExternalLink,
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
LoadingSpinner, Modal, Lightbox, ModelPicker, HomeModelPicker: ModelPicker,
+1
View File
@@ -1926,6 +1926,7 @@ your own: a class the host does not define has no style.
| `Code` | `testId` | A monospace block for a raw error or a command, wrapped and scrolled. |
| `Actions` | none | A row of buttons that wraps. |
| `Table` | `caption`, `columns: [{key, label}]`, `rows`, `testId` | The host table. |
| `Notice` | `severity` (`info`, `warn`, `error`), `title`, `reason`, `fixLabel` with `onFix`, `testId` | What is off or not available, why, and one action. Hosts older than this release do not have it: fall back to `Callout`. |
| `ExternalLink` | `href` (https only), `testId` | A link out, in a new tab. |
The Sandbox and Web sections are made of these components only (`addons/sandbox/ui/src/Settings.jsx`,