Add one notice for off or unavailable states, and use it in chat and add-ons

Notice draws what is off, why, who can fix it and one fix button, in
that order, with a live role set by severity. FixNotice draws the same
markup for add-on UIs, styled by the host's ui.css.

The chat notice for commands now says that the server's built-in bash
is off. An administrator gets the Settings row and a link to the
Add-ons page; anyone else is told to ask an administrator. Hide keeps
working. The Add-ons library card, the update notice and a turn on
refused as installed_incompatible use the notice, with an Upgrade fix.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 10:19:45 +00:00
1 parent e0d77e410c
commit 3e84fe671f
40 files changed
+727 -43

No files matched your search

@@ -123,3 +123,38 @@ export function Notice({ tone = 'info', role, testId, children, className = '' }
</div>
)
}
// "This is off or not available, why, and the fix": the add-on side of the
// host's <Notice> (core/http/react-ui/src/components/ui/Notice.jsx). It draws
// the same markup, so the host's ui.css styles both. The order is always what
// (title), why (reason), who can fix it (who), then one fix button.
// severity info | warn | error (error is role alert, warn status, info note)
// fix { label, onClick | href, testId }
// secondary the same shape, drawn as a ghost button
const NOTICE_ROLE = { error: 'alert', warn: 'status', info: 'note' }
const NOTICE_ICON = { info: 'info', warn: 'warning', error: 'alert-circle' }
export function FixNotice({ severity = 'info', title, reason, who, fix, secondary, testId, className = '', children }) {
const level = NOTICE_ROLE[severity] ? severity : 'info'
const draw = (a, variant) => {
if (!a || !a.label) return null
const cls = `dk-btn dk-btn--${variant} dk-btn--sm`
if (a.href) return <a className={cls} href={a.href} data-testid={a.testId}>{a.label}</a>
if (typeof a.onClick !== 'function') return null
return <button type="button" className={cls} onClick={a.onClick} disabled={a.disabled} data-testid={a.testId}>{a.label}</button>
}
const primary = draw(fix, 'primary')
const second = draw(secondary, 'ghost')
return (
<div className={`ui-callout ui-callout--${level} ui-notice ${className}`.trim()} role={NOTICE_ROLE[level]} data-severity={level} data-testid={testId}>
<Icon name={NOTICE_ICON[level]} className="ui-callout__icon" />
<div className="ui-callout__text">
{title && <strong className="ui-callout__title">{title}</strong>}
{reason && <span className="ui-notice__reason">{reason}</span>}
{who && <span className="ui-notice__who">{who}</span>}
{children}
</div>
{(primary || second) && <div className="ui-callout__actions">{primary}{second}</div>}
</div>
)
}
@@ -0,0 +1,128 @@
import { test, expect } from '@playwright/test'
import { ADMIN, app, installedAddon, release, sessionFor, mockAddons, fail } from './addons-fixtures.js'
// The Add-ons page says that something is off or unavailable with the shared
// notice (what, why, who, fix): the update states on a library card, the update
// on the detail pane, and a turn on that the server refuses because the
// installed package cannot run here. The routes are stand-ins.
const shots = process.env.NOTICE_SHOTS
const theme = process.env.NOTICE_THEME
async function shoot(page, name) {
if (!shots) return
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.waitForTimeout(400)
await page.screenshot({ path: `${shots}/${name}${process.env.NOTICE_SUFFIX || ''}.png`, fullPage: false })
}
const CANDIDATE = 'sha256:' + 'b'.repeat(64)
const entry = (page, id) => page.locator('.cat-item', { has: page.locator(`[data-addon="${id}"]`) })
const rec = (id, title, update, over = {}) => installedAddon({ id, title, icon: undefined, mainPage: undefined, digest: 'sha256:' + id.padEnd(32, '0'), update, ...over })
async function signIn(page) {
if (theme) await page.addInitScript((value) => { try { localStorage.setItem('localai-theme', value) } catch { /* private window */ } }, theme)
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
}
test.describe('Add-ons: notices', () => {
test('a library card says what an installed add-on is not up to date on, in the notice', async ({ page }) => {
await signIn(page)
await mockAddons(page, {
releases: ['wiki', 'docs', 'slack', 'tables', 'mail'].map(id => release(id, { version: '1.0.0' })),
installed: [
rec('wiki', 'Wiki', { available: true, state: 'update-available', version: '1.3.0', digest: CANDIDATE, checkedAt: '2026-10-01T00:00:00Z' }),
rec('docs', 'Docs', { available: false, state: 'incompatible', version: '3.0.0', reasons: ['host-version'] }),
rec('slack', 'Slack', { state: 'check-failed', available: false, error: 'refresh-failed' }),
rec('tables', 'Tables', undefined),
rec('mail', 'Mail', { state: 'up-to-date', available: false }),
],
})
await page.setViewportSize({ width: 1280, height: 1500 })
await page.goto('/app/addons?tab=browse')
const note = (id) => entry(page, id).getByTestId('entry-update-note')
await expect(note('docs')).toHaveAttribute('role', 'status')
await expect(note('docs')).toHaveAttribute('data-state', 'incompatible')
await expect(note('docs')).toContainText('cannot run on this host. It is not offered.')
await expect(note('slack')).toHaveAttribute('role', 'status')
await expect(note('slack')).toContainText('The update check failed, so it is not known whether an update exists.')
await expect(note('tables')).toHaveAttribute('role', 'note')
await expect(note('tables')).toContainText('The update status is not known. Check for updates to ask the library.')
// Up to date is not a problem: one quiet line, not a notice.
await expect(note('mail')).toContainText('This add-on is up to date.')
await expect(note('mail')).not.toHaveClass(/ui-notice/)
// An update is a notice with the Upgrade fix, which opens the confirmation.
const update = entry(page, 'wiki').getByTestId('entry-upgrade')
await expect(update).toHaveClass(/ui-notice/)
await expect(update).toContainText('Version 1.3.0 is available.')
await shoot(page, 'library')
await update.getByTestId('update-upgrade-card').click()
await expect(page.getByTestId('upgrade-confirm')).toContainText('1.3.0')
})
test('the pane shows the update as a notice with the Upgrade fix', async ({ page }) => {
await signIn(page)
await mockAddons(page, {
apps: [app({ id: 'wiki', title: 'Wiki', update: 'update-pending', mainPage: undefined, admin: { canStart: false, canStop: true, managementUrl: '/app/addons?manage=wiki' } })],
installed: [rec('wiki', 'Wiki', { available: true, state: 'update-available', version: '1.3.0', digest: CANDIDATE, checkedAt: '2026-10-01T00:00:00Z' })],
})
await page.goto('/app/addons?detail=wiki')
const notice = page.getByTestId('pane-update')
await expect(notice).toHaveClass(/ui-notice/)
await expect(notice).toContainText('1.3.0')
await shoot(page, 'pane-update')
await notice.getByRole('button', { name: /^Upgrade/ }).click()
await expect(page.getByTestId('upgrade-confirm')).toContainText('1.3.0')
})
test('a turn on refused as incompatible says why and offers Upgrade', async ({ page }) => {
await signIn(page)
await mockAddons(page, {
apps: [app({ id: 'tg', title: 'Telegram', enabled: false, state: 'stopped', update: 'update-pending', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=tg' } })],
installed: [rec('tg', 'Telegram', { available: true, state: 'update-available', version: '1.5.0', digest: CANDIDATE, checkedAt: '2026-10-01T00:00:00Z' }, { enabled: false, state: 'stopped' })],
start: () => fail(403, 'installed_incompatible', { message: 'This version of the add-on is not compatible with this server, so it cannot be turned on. Upgrade it to a newer version, then turn it on. Nothing was changed' }),
})
await page.goto('/app/addons?detail=tg')
await page.getByRole('button', { name: 'Turn on' }).click()
const notice = page.getByTestId('pane-enable-failed')
await expect(notice).toHaveAttribute('role', 'alert')
await expect(notice).toHaveAttribute('data-cause', 'installed_incompatible')
await expect(notice).toContainText('Cannot turn on Telegram: not compatible with this server')
await expect(notice).toContainText('Upgrade it to a newer version, then turn it on.')
// The plain update notice steps aside: one Upgrade, on the failure.
await expect(page.getByTestId('pane-update')).toHaveCount(0)
await shoot(page, 'enable-incompatible')
await notice.getByRole('button', { name: /^Upgrade/ }).click()
await expect(page.getByTestId('upgrade-confirm')).toContainText('1.5.0')
})
test('with no newer version listed the refusal says there is nothing to upgrade to', async ({ page }) => {
await signIn(page)
await mockAddons(page, {
apps: [app({ id: 'tg', title: 'Telegram', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=tg' } })],
installed: [rec('tg', 'Telegram', { available: false, state: 'incompatible', version: '', reasons: ['api-version'] }, { enabled: false, state: 'stopped' })],
start: () => fail(403, 'installed_incompatible', { message: 'This version of the add-on is not compatible with this server, so it cannot be turned on. Upgrade it to a newer version, then turn it on. Nothing was changed' }),
})
await page.goto('/app/addons?detail=tg')
await page.getByRole('button', { name: 'Turn on' }).click()
const notice = page.getByTestId('pane-enable-failed')
await expect(notice).toContainText('No newer version is listed yet. Keep it off, or remove it.')
await expect(notice.getByRole('button')).toHaveCount(0)
await shoot(page, 'enable-no-upgrade')
})
test('another refused turn on is a notice with the server reason, and no fix', async ({ page }) => {
await signIn(page)
await mockAddons(page, {
apps: [app({ id: 'tg', title: 'Telegram', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=tg' } })],
start: () => fail(403, 'addon_error', { message: 'missing configuration: data directory' }),
})
await page.goto('/app/addons?detail=tg')
await page.getByRole('button', { name: 'Turn on' }).click()
const notice = page.getByTestId('pane-enable-failed')
await expect(notice).toContainText('Could not turn on Telegram')
await expect(notice).toContainText('missing configuration: data directory')
await shoot(page, 'enable-failed')
})
})
+29 -6
View File
@@ -35,7 +35,12 @@ async function stubChat(page, addons) {
return calls
}
const none = () => ({ addons: [], commands: 'none' })
// No add-on runs commands and the server's built-in bash is off.
const none = () => ({ addons: [], commands: 'none', commands_host_exec: 'off' })
// An older server does not say the mode of the built-in bash.
const noneUnsaid = () => ({ addons: [], commands: 'none' })
const REASON = "The server's built-in bash is off, so the assistant can write files but cannot run scripts or commands."
test.describe('Chat that cannot run commands', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
@@ -49,9 +54,16 @@ test.describe('Chat that cannot run commands', () => {
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
await expect(notice).toContainText("Commands can't run here")
await expect(notice).toContainText('No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await expect(notice).toContainText(REASON)
// The administrator is told where to turn it on, and that Ask each time is the safer choice.
await expect(notice).toContainText('You are an administrator. In Settings, Tools, turn on the Commands (bash) row. Ask each time is the safer choice.')
await expect(notice).toContainText('without isolation')
await expect(notice).not.toContainText('Ask an administrator')
const link = notice.getByTestId('chat-commands-notice-link')
await expect(link).toHaveText('Open the setting')
await expect(link).toHaveAttribute('href', /\/app\/settings\?group=tools#settings-tools-row-bash$/)
await expect(notice.getByTestId('chat-commands-notice-sandbox')).toHaveAttribute('href', /\/app\/addons$/)
await expect(notice).toHaveAttribute('role', 'status')
await shot(page, 'tools-menu-admin')
})
@@ -63,7 +75,7 @@ test.describe('Chat that cannot run commands', () => {
await page.keyboard.press('Enter')
await expect(chip).toHaveAttribute('aria-expanded', 'true')
const dialog = page.getByRole('dialog', { name: "Commands can't run here" })
await expect(dialog.getByTestId('chat-commands-pop')).toContainText('No add-on that runs code is installed')
await expect(dialog.getByTestId('chat-commands-pop')).toContainText("The server's built-in bash is off")
await shot(page, 'chip-popover')
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
@@ -76,12 +88,23 @@ test.describe('Chat that cannot run commands', () => {
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
await expect(notice).not.toContainText('Ask an administrator')
await expect(notice).toContainText('No add-on that runs code is installed')
await expect(notice).toContainText(REASON)
await expect(notice).toContainText('Ask an administrator of this server to turn it on.')
await expect(notice).not.toContainText('Open the setting')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await expect(notice.getByTestId('chat-commands-notice-sandbox')).toHaveCount(0)
await shot(page, 'tools-menu-user')
})
test('a server that does not report the mode of its built-in bash keeps the plain notice', async ({ page }) => {
await stubChat(page, noneUnsaid)
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
await expect(notice).toContainText('No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
})
test('hiding the notice hides the chip and leaves one line in the Tools menu, but returns after a reload', async ({ page }) => {
await stubChat(page, none)
await page.goto('/app/chats/c1')
+2 -2
View File
@@ -833,10 +833,10 @@ test.describe('Chat tools menu: every group of built-in tools', () => {
})
test('the shell row is disabled with the reason when nothing can run commands', async ({ page }) => {
await stubGroups(page, { shell: null, commands: { commands: 'none' } })
await stubGroups(page, { shell: null, commands: { commands: 'none', commands_host_exec: 'off' } })
await open(page)
await expect(sw(page, 'Run commands')).toBeDisabled()
await expect(sw(page, 'Run commands')).toHaveAccessibleDescription('No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.')
await expect(sw(page, 'Run commands')).toHaveAccessibleDescription("The server's built-in bash is off, so the assistant can write files but cannot run scripts or commands.")
})
})
@@ -930,5 +930,10 @@
"downgrade": "Dies installiert Version {{version}}, die älter ist als die installierte Version {{installed}}. Eine ältere Version kann Daten, die eine neuere geschrieben hat, eventuell nicht lesen.",
"downgradeConfirm": "Ich möchte die ältere Version installieren"
}
},
"notice": {
"enableFailedTitle": "{{name}} konnte nicht eingeschaltet werden",
"incompatibleTitle": "{{name}} lässt sich nicht einschalten: nicht mit diesem Server kompatibel",
"noUpgrade": "Es ist noch keine neuere Version gelistet. Lass es aus oder entferne es."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "Ausführungsbereitschaft oder Isolation ist unbekannt: das Add-on hat gemeldet, dass es das nicht sagen kann."
},
"providerUnavailable": "Der ausgewählte Ausführungsanbieter ist nicht verfügbar.",
"disabled": "Die Befehlsausführung ist für diesen Chat ausgeschaltet."
"disabled": "Die Befehlsausführung ist für diesen Chat ausgeschaltet.",
"hostOff": {
"reason": "Das eingebaute Bash des Servers ist aus, deshalb kann der Assistent Dateien schreiben, aber keine Skripte oder Befehle ausführen.",
"whoAdmin": "Du bist Administrator. Schalte in den Einstellungen unter Werkzeuge die Zeile Befehle (bash) ein. Jedes Mal fragen ist die sicherere Wahl. Befehle laufen dann ohne Isolierung auf dem Server und können lesen, was der Server lesen kann.",
"whoMember": "Bitte einen Administrator dieses Servers, es einzuschalten.",
"fix": "Einstellung öffnen",
"sandboxText": "Für isolierte Befehle installiere stattdessen ein Add-on, das Befehle isoliert ausführt.",
"sandbox": "Add-ons öffnen"
}
},
"consent": {
"title": "Diesen Befehl auf diesem Rechner ausführen?",
@@ -930,5 +930,10 @@
"downgrade": "This installs version {{version}}, which is older than the installed version {{installed}}. An older version may not read data that a newer one wrote.",
"downgradeConfirm": "I want to install the older version"
}
},
"notice": {
"enableFailedTitle": "Could not turn on {{name}}",
"incompatibleTitle": "Cannot turn on {{name}}: not compatible with this server",
"noUpgrade": "No newer version is listed yet. Keep it off, or remove it."
}
}
@@ -411,7 +411,15 @@
"reported_unknown": "Execution readiness or isolation is unknown: the add-on reported that it cannot tell."
},
"providerUnavailable": "The selected execution provider is unavailable.",
"disabled": "Running commands is switched off for this chat."
"disabled": "Running commands is switched off for this chat.",
"hostOff": {
"reason": "The server's built-in bash is off, so the assistant can write files but cannot run scripts or commands.",
"whoAdmin": "You are an administrator. In Settings, Tools, turn on the Commands (bash) row. Ask each time is the safer choice. Commands then run on the server without isolation and can read what the server can read.",
"whoMember": "Ask an administrator of this server to turn it on.",
"fix": "Open the setting",
"sandboxText": "For isolated commands, install an add-on that runs commands in isolation instead.",
"sandbox": "Open Add-ons"
}
},
"consent": {
"title": "Run this command on this machine?",
@@ -930,5 +930,10 @@
"downgrade": "Esto instala la versión {{version}}, que es anterior a la versión instalada {{installed}}. Una versión anterior puede no leer los datos que escribió una más nueva.",
"downgradeConfirm": "Quiero instalar la versión anterior"
}
},
"notice": {
"enableFailedTitle": "No se pudo activar {{name}}",
"incompatibleTitle": "No se puede activar {{name}}: no es compatible con este servidor",
"noUpgrade": "Todavía no hay una versión más reciente en la lista. Déjalo desactivado o quítalo."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "Se desconoce la disponibilidad o el aislamiento de la ejecución: el complemento informó de que no puede saberlo."
},
"providerUnavailable": "El proveedor de ejecución seleccionado no está disponible.",
"disabled": "La ejecución de comandos está desactivada para este chat."
"disabled": "La ejecución de comandos está desactivada para este chat.",
"hostOff": {
"reason": "El bash integrado del servidor está desactivado, así que el asistente puede escribir archivos pero no ejecutar scripts ni comandos.",
"whoAdmin": "Eres administrador. En Ajustes, Herramientas, activa la fila Comandos (bash). Preguntar cada vez es la opción más segura. Los comandos se ejecutan entonces en el servidor sin aislamiento y pueden leer lo que el servidor puede leer.",
"whoMember": "Pide a un administrador de este servidor que lo active.",
"fix": "Abrir el ajuste",
"sandboxText": "Para comandos aislados, instala mejor un complemento que ejecute comandos de forma aislada.",
"sandbox": "Abrir Complementos"
}
},
"consent": {
"title": "¿Ejecutar este comando en esta máquina?",
@@ -934,5 +934,10 @@
"downgrade": "Cela installe la version {{version}}, plus ancienne que la version installée {{installed}}. Une version plus ancienne peut ne pas lire les données écrites par une version plus récente.",
"downgradeConfirm": "Je veux installer la version plus ancienne"
}
},
"notice": {
"enableFailedTitle": "Impossible d'activer {{name}}",
"incompatibleTitle": "Impossible d'activer {{name}} : incompatible avec ce serveur",
"noUpgrade": "Aucune version plus récente n'est encore listée. Laissez-le désactivé ou supprimez-le."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "La disponibilité ou l’isolation de l’exécution est inconnue : l’add-on a indiqué qu’il ne peut pas le dire."
},
"providerUnavailable": "Le fournisseur d’exécution sélectionné est indisponible.",
"disabled": "L’exécution des commandes est désactivée pour cette discussion."
"disabled": "L’exécution des commandes est désactivée pour cette discussion.",
"hostOff": {
"reason": "Le bash intégré du serveur est désactivé : l'assistant peut écrire des fichiers mais ne peut pas exécuter de scripts ni de commandes.",
"whoAdmin": "Vous êtes administrateur. Dans Paramètres, Outils, activez la ligne Commandes (bash). Demander à chaque fois est le choix le plus sûr. Les commandes s'exécutent alors sur le serveur sans isolation et peuvent lire ce que le serveur peut lire.",
"whoMember": "Demandez à un administrateur de ce serveur de l'activer.",
"fix": "Ouvrir le paramètre",
"sandboxText": "Pour des commandes isolées, installez plutôt un module qui exécute les commandes de façon isolée.",
"sandbox": "Ouvrir les modules"
}
},
"consent": {
"title": "Exécuter cette commande sur cette machine ?",
@@ -930,5 +930,10 @@
"downgrade": "Ini memasang versi {{version}}, yang lebih lama daripada versi terpasang {{installed}}. Versi yang lebih lama mungkin tidak bisa membaca data yang ditulis versi yang lebih baru.",
"downgradeConfirm": "Saya ingin memasang versi yang lebih lama"
}
},
"notice": {
"enableFailedTitle": "{{name}} tidak dapat diaktifkan",
"incompatibleTitle": "{{name}} tidak dapat diaktifkan: tidak kompatibel dengan server ini",
"noUpgrade": "Belum ada versi yang lebih baru di daftar. Biarkan mati, atau hapus."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "Kesiapan atau isolasi eksekusi tidak diketahui: add-on melaporkan bahwa ia tidak dapat mengetahuinya."
},
"providerUnavailable": "Penyedia eksekusi yang dipilih tidak tersedia.",
"disabled": "Eksekusi perintah dinonaktifkan untuk percakapan ini."
"disabled": "Eksekusi perintah dinonaktifkan untuk percakapan ini.",
"hostOff": {
"reason": "Bash bawaan server mati, jadi asisten bisa menulis berkas tetapi tidak bisa menjalankan skrip atau perintah.",
"whoAdmin": "Anda administrator. Di Pengaturan, Alat, aktifkan baris Perintah (bash). Tanya setiap kali adalah pilihan yang lebih aman. Perintah lalu berjalan di server tanpa isolasi dan dapat membaca apa yang dapat dibaca server.",
"whoMember": "Minta administrator server ini untuk mengaktifkannya.",
"fix": "Buka pengaturan",
"sandboxText": "Untuk perintah yang terisolasi, pasang add-on yang menjalankan perintah secara terisolasi sebagai gantinya.",
"sandbox": "Buka Add-on"
}
},
"consent": {
"title": "Jalankan perintah ini di mesin ini?",
@@ -930,5 +930,10 @@
"downgrade": "Questo installa la versione {{version}}, più vecchia della versione installata {{installed}}. Una versione più vecchia può non leggere i dati scritti da una più nuova.",
"downgradeConfirm": "Voglio installare la versione precedente"
}
},
"notice": {
"enableFailedTitle": "Impossibile attivare {{name}}",
"incompatibleTitle": "Impossibile attivare {{name}}: non compatibile con questo server",
"noUpgrade": "Non è ancora elencata una versione più recente. Lascialo disattivato o rimuovilo."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "La disponibilità o l’isolamento dell’esecuzione non sono noti: l’add-on ha segnalato di non poterlo dire."
},
"providerUnavailable": "Il provider di esecuzione selezionato non è disponibile.",
"disabled": "L’esecuzione dei comandi è disattivata per questa chat."
"disabled": "L’esecuzione dei comandi è disattivata per questa chat.",
"hostOff": {
"reason": "Il bash integrato del server è disattivato, quindi l'assistente può scrivere file ma non può eseguire script o comandi.",
"whoAdmin": "Sei un amministratore. In Impostazioni, Strumenti, attiva la riga Comandi (bash). Chiedere ogni volta è la scelta più sicura. I comandi vengono poi eseguiti sul server senza isolamento e possono leggere ciò che il server può leggere.",
"whoMember": "Chiedi a un amministratore di questo server di attivarlo.",
"fix": "Apri l'impostazione",
"sandboxText": "Per comandi isolati, installa invece un componente aggiuntivo che esegue i comandi in isolamento.",
"sandbox": "Apri Componenti aggiuntivi"
}
},
"consent": {
"title": "Eseguire questo comando su questa macchina?",
@@ -930,5 +930,10 @@
"downgrade": "설치된 {{installed}} 버전보다 오래된 {{version}} 버전을 설치합니다. 이전 버전은 새 버전이 쓴 데이터를 읽지 못할 수 있습니다.",
"downgradeConfirm": "이전 버전을 설치하겠습니다"
}
},
"notice": {
"enableFailedTitle": "{{name}}을(를) 켤 수 없습니다",
"incompatibleTitle": "{{name}}을(를) 켤 수 없습니다: 이 서버와 호환되지 않습니다",
"noUpgrade": "아직 더 새로운 버전이 목록에 없습니다. 꺼 두거나 제거하세요."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "실행 준비 상태 또는 격리 여부를 알 수 없습니다: 애드온이 알 수 없다고 보고했습니다."
},
"providerUnavailable": "선택한 실행 공급자를 사용할 수 없습니다.",
"disabled": "이 채팅의 명령 실행이 꺼져 있습니다."
"disabled": "이 채팅의 명령 실행이 꺼져 있습니다.",
"hostOff": {
"reason": "서버의 내장 bash가 꺼져 있어 어시스턴트는 파일을 쓸 수 있지만 스크립트나 명령은 실행할 수 없습니다.",
"whoAdmin": "관리자입니다. 설정의 도구에서 명령(bash) 행을 켜세요. 매번 묻기가 더 안전한 선택입니다. 그러면 명령은 격리 없이 서버에서 실행되며 서버가 읽을 수 있는 것을 읽을 수 있습니다.",
"whoMember": "이 서버의 관리자에게 켜 달라고 요청하세요.",
"fix": "설정 열기",
"sandboxText": "격리된 명령이 필요하면 대신 명령을 격리해서 실행하는 애드온을 설치하세요.",
"sandbox": "애드온 열기"
}
},
"consent": {
"title": "이 명령을 이 컴퓨터에서 실행할까요?",
@@ -930,5 +930,10 @@
"downgrade": "Isto instala a versão {{version}}, que é mais antiga que a versão instalada {{installed}}. Uma versão mais antiga pode não ler dados escritos por uma mais nova.",
"downgradeConfirm": "Quero instalar a versão mais antiga"
}
},
"notice": {
"enableFailedTitle": "Não foi possível ativar {{name}}",
"incompatibleTitle": "Não é possível ativar {{name}}: incompatível com este servidor",
"noUpgrade": "Ainda não há uma versão mais nova na lista. Deixe-o desativado ou remova-o."
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "A disponibilidade ou o isolamento da execução é desconhecido: o complemento informou que não sabe dizer."
},
"providerUnavailable": "O provedor de execução selecionado está indisponível.",
"disabled": "A execução de comandos está desativada para esta conversa."
"disabled": "A execução de comandos está desativada para esta conversa.",
"hostOff": {
"reason": "O bash integrado do servidor está desativado, então o assistente pode escrever arquivos, mas não executar scripts nem comandos.",
"whoAdmin": "Você é administrador. Em Configurações, Ferramentas, ative a linha Comandos (bash). Perguntar a cada vez é a escolha mais segura. Os comandos passam a rodar no servidor sem isolamento e podem ler o que o servidor pode ler.",
"whoMember": "Peça a um administrador deste servidor para ativá-lo.",
"fix": "Abrir a configuração",
"sandboxText": "Para comandos isolados, instale um complemento que execute comandos de forma isolada.",
"sandbox": "Abrir Complementos"
}
},
"consent": {
"title": "Rodar este comando nesta máquina?",
@@ -930,5 +930,10 @@
"downgrade": "这会安装 {{version}} 版本,它比已安装的 {{installed}} 版本更旧。较旧的版本可能无法读取较新版本写入的数据。",
"downgradeConfirm": "我要安装较旧的版本"
}
},
"notice": {
"enableFailedTitle": "无法打开 {{name}}",
"incompatibleTitle": "无法打开 {{name}}:与此服务器不兼容",
"noUpgrade": "目前还没有列出更新的版本。请保持关闭,或将其移除。"
}
}
@@ -290,7 +290,15 @@
"reported_unknown": "执行就绪状态或隔离状态未知:插件报告无法确定。"
},
"providerUnavailable": "所选执行提供程序不可用。",
"disabled": "此聊天已关闭命令执行。"
"disabled": "此聊天已关闭命令执行。",
"hostOff": {
"reason": "服务器内置的 bash 已关闭,所以助手可以写文件,但不能运行脚本或命令。",
"whoAdmin": "你是管理员。在设置的工具中,打开“命令 (bash)”一行。“每次询问”是更安全的选择。之后命令会在服务器上无隔离地运行,并且可以读取服务器能读取的内容。",
"whoMember": "请让此服务器的管理员打开它。",
"fix": "打开设置",
"sandboxText": "如需隔离的命令,请改为安装能隔离运行命令的附加组件。",
"sandbox": "打开附加组件"
}
},
"consent": {
"title": "在这台机器上运行此命令?",
@@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { Button, Callout } from '../ui'
import { Button, Callout, Notice } from '../ui'
import { apiUrl } from '../../utils/basePath'
import { pushLayer } from '../../utils/modalLayers'
import { StateChip, AppIcon, BuiltinIcon, UnknownNotice, UpdateChip, VersionText } from './AddonBits'
@@ -104,6 +104,28 @@ export default function AddonPreview({ tile, language, title, pinned, onPin, onM
)
}
// A refused or failed turn on. The reason is the server's own words. An add-on
// whose package this server cannot run (installed_incompatible) says so, and
// offers the upgrade when a version that fits is listed; with none listed it
// says there is nothing to do but keep it off or remove it.
const INSTALLED_INCOMPATIBLE = 'installed_incompatible'
function EnableFailed({ title, outcome, reason, fix }) {
const { t } = useTranslation('addons')
const incompatible = outcome.error && outcome.error.type === INSTALLED_INCOMPATIBLE
return (
<Notice
severity="error"
title={t(incompatible ? 'notice.incompatibleTitle' : 'notice.enableFailedTitle', { name: title })}
reason={reason}
who={incompatible && !fix ? t('notice.noUpgrade') : undefined}
fix={fix}
testId="pane-enable-failed"
data-cause={incompatible ? INSTALLED_INCOMPATIBLE : undefined}
/>
)
}
function PreviewBody({ app, title, language, pinned, onPin, actions, onStartStop, onRemove, isAdmin, source, update, upgrade, links, children }) {
const { t } = useTranslation('addons')
const description = localizedText(app, 'description', language)
@@ -122,6 +144,8 @@ function PreviewBody({ app, title, language, pinned, onPin, actions, onStartStop
const restarts = Number(source?.restarts) || 0
const canUpgrade = Boolean(upgrade && source && update?.state === 'update-available' && upgradeAvailability(source, { isAdmin, pending: true }).startable)
const upgrading = Boolean(source && upgrade) && upgrade.working(app.id)
// The turn-on failure of an incompatible package carries the Upgrade button, so the plain update notice steps aside.
const incompatibleShown = !pending && result?.error?.type === INSTALLED_INCOMPATIBLE && Boolean(result.outcome)
return (
<div className="ad-pane__body">
{attention === 'failed' && (
@@ -130,16 +154,15 @@ function PreviewBody({ app, title, language, pinned, onPin, actions, onStartStop
{added.tools > 0 ? t('attention.toolsOff', { count: added.tools }) : added.page ? t('attention.pageOff') : ''}
</Callout>
)}
{canUpgrade && (
<Callout
tone="warn"
{canUpgrade && !incompatibleShown && (
<Notice
severity="warn"
icon="arrow-up"
title={t('preview.updateTitle', { version: update.version })}
reason={t('attention.updateBody', { current: source.version })}
testId="pane-update"
actions={<Button size="sm" busy={upgrading} disabled={upgrading || pending} onClick={() => upgrade.ask(source)} aria-label={t('updates.upgradeNamed', { name: title })}>{t('updates.upgrade')}</Button>}
>
{t('attention.updateBody', { current: source.version })}
</Callout>
fix={{ label: t('updates.upgrade'), onClick: () => upgrade.ask(source), busy: upgrading, disabled: upgrading || pending, ariaLabel: t('updates.upgradeNamed', { name: title }) }}
/>
)}
{description && <p className="ad-pane__text">{description}</p>}
<div className="ad-pane__acts">
@@ -158,7 +181,16 @@ function PreviewBody({ app, title, language, pinned, onPin, actions, onStartStop
{pending
? <UnknownNotice addonId={app.id} actions={actions} className="ad-card__note" />
: outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && (
<Callout tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</Callout>
outcome === result
? (
<EnableFailed
title={title}
outcome={outcome}
reason={describeOutcome(outcome.outcome, t, outcome.error)}
fix={canUpgrade ? { label: t('updates.upgrade'), onClick: () => upgrade.ask(source), busy: upgrading, disabled: upgrading, ariaLabel: t('updates.upgradeNamed', { name: title }) } : null}
/>
)
: <Callout tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</Callout>
)}
{shots.length > 0 && (
<ul className="ad-shots" data-testid="addon-shots" aria-label={t('preview.screenshots')}>
@@ -6,11 +6,11 @@ import Icon from '../Icon'
import CatalogView from '../catalog/CatalogView'
import { paramsToQuery, queryToParams } from '../catalog/catalogState'
import { AppIcon, UnknownNotice, VersionText } from './AddonBits'
import { Button, StatusChip } from '../ui'
import { Button, Notice, StatusChip } from '../ui'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
import { addedByEntry } from './addonState'
import { updateView, entryUpdateNote } from './updateState'
import { updateView, entryUpdateNote, updateNoticeSeverity, upgradeAvailability } from './updateState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
import { useAddonUpgrade } from './useAddonUpgrade'
@@ -80,13 +80,29 @@ export default function BrowseView({ api, adapter }) {
</p>
{have && !updatable && (() => {
const note = entryUpdateNote(have.update)
return <p className="ad-entry__note" data-testid="entry-update-note" data-state={note.state} data-tone={note.tone}>{t(`updates.state.${note.state}`, { version: note.version })}</p>
const severity = updateNoticeSeverity(note.state)
const text = t(`updates.state.${note.state}`, { version: note.version })
// Up to date is not a problem: one quiet line. Every other state
// is a notice that says what it is.
if (!severity) return <p className="ad-entry__note" data-testid="entry-update-note" data-state={note.state} data-tone={note.tone}>{text}</p>
return <Notice className="ad-entry__notice" severity={severity} title={text} testId="entry-update-note" data-state={note.state} data-tone={note.tone} />
})()}
{updatable && (() => {
const view = updateView(have.update)
const working = pending || upgrade.working(have.id)
const availability = upgradeAvailability(have, { isAdmin: true, pending: true, checked: upgrade.checks[have.id]?.phase === 'done' })
return (
<Notice
className="ad-entry__notice"
severity="warn"
title={t('updates.state.update-available', { version: view.version })}
testId="entry-upgrade"
fix={availability.startable ? { label: t('updates.upgrade'), onClick: () => upgrade.ask(have), busy: working, disabled: working, testId: 'update-upgrade-card', ariaLabel: t('updates.upgradeNamed', { name: have.title }) } : null}
>
{!availability.startable && <AddonUpgradeAction addon={have} pending isAdmin upgrade={upgrade} variant="secondary" working={working} />}
</Notice>
)
})()}
{updatable && (
<div className="ad-entry__upgrade" data-testid="entry-upgrade">
<AddonUpgradeAction addon={have} pending isAdmin upgrade={upgrade} variant="secondary" working={pending || upgrade.working(have.id)} />
</div>
)}
<EntryFacts entry={entry} have={have} />
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
{!pending && !(dialog && dialog.key === entry.key) && <FlowStatus flow={flow} name={entry.title} onRetry={flow?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />}
@@ -344,6 +344,7 @@
.ad-entry__meta { margin-top: var(--sp-1); }
.ad-entry__line { display: flex; flex-wrap: wrap; gap: 2px var(--sp-3); }
.ad-entry__acts { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-2); }
.ad-entry__notice { margin-top: var(--sp-2); }
.ad-entry__upgrade { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); }
.ad-disclosure { margin-top: var(--sp-2); font-size: var(--fs-small); }
.ad-disclosure > summary {
+15
View File
@@ -72,6 +72,21 @@ export function entryUpdateNote(update) {
return { state: v.state, tone: v.tone, version: v.version }
}
// The severity of the notice that says an add-on's update state: a state with an
// update to apply or a check that did not answer is a warning, a status that was
// never checked is information, and contents that do not match are an error.
// Up to date has no notice.
export function updateNoticeSeverity(state) {
switch (state) {
case 'integrity-error': return 'error'
case 'update-available':
case 'incompatible':
case 'check-failed': return 'warn'
case 'up-to-date': return ''
default: return 'info'
}
}
// The body of an upgrade request: the installed package the page showed and
// the candidate the check resolved. The owner checks both again.
export function upgradeBody(addon, operationId) {
@@ -1,6 +1,8 @@
import { useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import Callout from '../ui/Callout'
import Notice from '../ui/Notice'
import StatusChip from '../ui/StatusChip'
import Button from '../ui/Button'
import Popover from '../Popover'
@@ -14,12 +16,38 @@ import './commandsnotice.css'
// The body of the notice: the title, one line of reason, and what to do.
export function CommandsNotice({ notice, testId = 'chat-commands-notice' }) {
const { t } = useTranslation('chats')
const { info, hidden, hide } = notice
const { info, hidden } = notice
if (!info) return null
const title = t(`tools.commands.${info.titleKey || 'title'}`)
if (hidden) {
return <p className="cmd-notice__line" data-testid={`${testId}-line`} data-state={info.state}>{title}</p>
}
const hide = { label: t('tools.commands.hide'), onClick: notice.hide, ariaLabel: t('tools.commands.hideLabel'), testId: `${testId}-hide` }
// The server's built-in bash is off and no add-on runs commands: what, why,
// who can fix it and the fix, as in the Notice pattern.
if (info.hostOff) {
return (
<Notice
className="cmd-notice"
severity={info.severity}
icon="terminal"
title={title}
reason={t(`tools.commands.${info.textKey}`)}
who={t(`tools.commands.${info.whoKey}`)}
fix={info.link ? { label: t(`tools.commands.${info.link.labelKey}`), to: info.link.to, testId: `${testId}-link` } : null}
secondary={hide}
testId={testId}
data-state={info.state}
>
{info.sandboxLink && (
<span className="ui-notice__extra">
{t('tools.commands.hostOff.sandboxText')}{' '}
<Link to={info.sandboxLink.to} data-testid={`${testId}-sandbox`}>{t(`tools.commands.${info.sandboxLink.labelKey}`)}</Link>
</span>
)}
</Notice>
)
}
return (
<Callout
className="cmd-notice"
@@ -31,7 +59,7 @@ export function CommandsNotice({ notice, testId = 'chat-commands-notice' }) {
actions={(
<>
{info.link && <Button size="sm" variant="primary" to={info.link.to} data-testid={`${testId}-link`}>{t(`tools.commands.${info.link.labelKey}`)}</Button>}
<Button size="sm" variant="ghost" onClick={hide} aria-label={t('tools.commands.hideLabel')} data-testid={`${testId}-hide`}>{t('tools.commands.hide')}</Button>
<Button size="sm" variant="ghost" onClick={notice.hide} aria-label={t('tools.commands.hideLabel')} data-testid={`${testId}-hide`}>{t('tools.commands.hide')}</Button>
</>
)}
>
@@ -2,7 +2,7 @@
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { formatWhen } from '../addons/updateState'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import { Callout, LoadError, SkeletonRows, Switch } from '../ui'
import { chatBuiltinToolsApi } from '../../utils/api'
import { FIELD_BY_KEY, getValue, setValue } from '../../utils/settingsSchema'
@@ -176,6 +176,16 @@ export default function BuiltinToolsSection({ settings, onChange, only, addonLin
const { t } = useTranslation('admin')
const [state, setState] = useState({ loading: true, tools: [], skills: [], error: '' })
const [reload, setReload] = useState(0)
const location = useLocation()
// A link to one row (#settings-tools-row-bash, from the notice of a chat that
// cannot run commands) lands on it once the list is drawn: the list loads
// after the page, so the page's own scroll to a hash finds nothing yet.
const hashRow = location.hash.startsWith('#settings-tools-row-') ? decodeURIComponent(location.hash.slice(1)) : ''
useEffect(() => {
if (!hashRow || state.loading) return
requestAnimationFrame(() => document.getElementById(hashRow)?.scrollIntoView({ block: 'center' }))
}, [hashRow, state.loading])
useEffect(() => {
let live = true
@@ -199,7 +209,7 @@ export default function BuiltinToolsSection({ settings, onChange, only, addonLin
? t('settingsTools.byBuiltin')
: tool.provider ? t('settingsTools.byAddon', { name: tool.titles?.[tool.provider] || tool.provider }) : t('settingsTools.byNone')
return (
<div key={tool.id} className="st-row st-tools__row" data-wide data-tool={tool.id} data-testid={`settings-tools-row-${tool.id}`}>
<div key={tool.id} id={only ? undefined : `settings-tools-row-${tool.id}`} className="st-row st-tools__row" data-wide data-tool={tool.id} data-testid={`settings-tools-row-${tool.id}`}>
<div className="st-row__main">
<div className="st-row__title">
<span className="st-row__label">{t(`settingsTools.labels.${tool.id}`, { defaultValue: tool.label })}</span>
@@ -0,0 +1,67 @@
import Button from './Button'
import Callout from './Callout'
import { NOTICE_ICONS, noticeAction, noticeRole, noticeSeverity } from './noticeModel'
// "This is off or not available, why, and the fix." One notice for every such
// state, in one order: what, why, who can fix it, the fix. It extends Callout,
// so the look is the product's own.
//
// severity info (off by design) | warn (degraded, or an action is
// recommended) | error (something the person asked for does not
// work). Sets the colour, the icon and the live role.
// title what is off or unavailable; short, no trailing period
// reason why, in one plain sentence; the server's own words when it has
// them, never a status code
// who who can fix it ("Ask an administrator of this server.")
// fix the one primary action: { label, onClick | to | href, busy,
// disabled, testId, ariaLabel }. The label is a verb that names
// the result ("Open the setting", "Upgrade").
// secondary an optional second action of the same shape; variant 'ghost'
// (default) or 'secondary'. A dismiss ("Hide") goes here.
// icon a kit icon id, to replace the severity's icon
// children anything that belongs after the who line (a link)
//
// A notice with no fix is a plain statement: the person has nothing to do.
export default function Notice({ severity, title, reason, who, fix, secondary, icon, className = '', testId, children, ...rest }) {
const level = noticeSeverity(severity)
const primary = noticeAction(fix)
const second = noticeAction(secondary)
const draw = (action, variant) => (
<Button
size="sm"
variant={action.variant || variant}
to={action.to}
href={action.href}
onClick={action.onClick}
busy={action.busy}
disabled={action.disabled}
aria-label={action.ariaLabel}
data-testid={action.testId}
>
{action.label}
</Button>
)
const actions = primary || second ? (
<>
{primary && draw(primary, 'primary')}
{second && draw(second, 'ghost')}
</>
) : null
return (
<Callout
tone={level}
icon={icon || NOTICE_ICONS[level]}
role={noticeRole(level)}
className={`ui-notice ${className}`.trim()}
title={title}
actions={actions}
testId={testId}
data-severity={level}
{...rest}
>
{reason && <span className="ui-notice__reason">{reason}</span>}
{who && <span className="ui-notice__who">{who}</span>}
{children}
</Callout>
)
}
@@ -150,6 +150,27 @@ under the text. Use it for something on this page that needs attention ("Tables
1.0.0 is available"). Amber (`warn`) is only for something that needs a
decision.
## Notice
`<Notice severity title reason who fix secondary icon>` children `</Notice>`
The one notice for "this is off or not available, why, and the fix". It draws
a Callout, always in the same order: what (`title`), why (`reason`), who can fix
it (`who`), then one fix button. Do not write a new "off" banner; use this.
| Prop | Notes |
|---|---|
| `severity` | `info` off by design, `warn` degraded or an action is recommended, `error` something the person asked for does not work. Sets colour, icon and live role: `error` is `role="alert"`, `warn` is `role="status"`, `info` is `role="note"`. |
| `title` | What is off, short, no trailing period. |
| `reason` | Why, one plain sentence. Show the server's own words, not a status code. |
| `who` | Who can fix it. Admin and non-admin copy differ here. |
| `fix` | The one primary action: `{ label, onClick or to or href, busy, disabled, testId, ariaLabel }`. The label is a verb that names the result. |
| `secondary` | An optional second action of the same shape (a dismiss such as "Hide" goes here). |
An action with no label or nothing to do is not drawn. The add-on UIs draw
the same markup with `FixNotice` in `addons/internal/connector-ui/src/controls.jsx`;
the rules for both are in `ui.css`.
## StatusChip
`<StatusChip status tone label dot />`
+1
View File
@@ -6,6 +6,7 @@ export { buttonClass, BUTTON_VARIANTS, BUTTON_SIZES } from './buttonClass'
export { default as Callout } from './Callout'
export { default as EmptyState } from './EmptyState'
export { default as LoadError } from './LoadError'
export { default as Notice } from './Notice'
export { default as Page } from './Page'
export { default as SearchField } from './SearchField'
export { default as Segmented } from './Segmented'
+32
View File
@@ -0,0 +1,32 @@
// The pure rules of <Notice>, so the node unit tests drive them without a
// browser. A notice says that something is off or not available, in a fixed
// order: what (title), why (reason), who can fix it (who), the fix (a button).
export const NOTICE_SEVERITIES = ['info', 'warn', 'error']
// The kit icon of each severity. A notice with its own icon (a terminal for
// commands) keeps the severity's colour.
export const NOTICE_ICONS = { info: 'info', warn: 'warning', error: 'alert-circle' }
// What a screen reader does with the notice.
// error role alert (assertive): something the person asked for does not work
// warn role status (polite): degraded, or an action is recommended
// info role note (not live): off by design
const NOTICE_ROLES = { error: 'alert', warn: 'status', info: 'note' }
export function noticeSeverity(value) {
return NOTICE_SEVERITIES.includes(value) ? value : 'info'
}
export function noticeRole(severity) {
return NOTICE_ROLES[noticeSeverity(severity)]
}
// One action of a notice, or null when it has no label or nothing to do. An
// action with `to` is a router link, with `href` an address, with `onClick` a
// button. A notice never draws a button that does nothing.
export function noticeAction(action) {
if (!action || typeof action !== 'object' || !action.label) return null
if (action.to === undefined && action.href === undefined && typeof action.onClick !== 'function') return null
return action
}
@@ -425,6 +425,19 @@
.ui-callout__actions { grid-column: 2; margin: 0; }
}
/* ---- Notice -------------------------------------------------------------- */
/* A callout that says what is off, why, who can fix it, and the fix, in that
order (Notice.jsx). The add-on UIs draw the same markup with their own
component, so these rules are the one place that styles it. */
.ui-notice .ui-callout__title,
.ui-notice__reason,
.ui-notice__who {
display: block;
}
.ui-notice__reason { margin-top: var(--sp-1); }
.ui-notice__who { margin-top: var(--sp-1); }
.ui-notice__extra { display: block; margin-top: var(--sp-1); }
/* ---- Status chip --------------------------------------------------------- */
/* Tone colour, icon and words, 22 px. Text on wash is 4.5:1 in both themes. */
.ui-chip {
+1 -1
View File
@@ -10,7 +10,7 @@ const noMCP = { servers: [], connectors: [], off: [] }
const mcpOf = (res) => ({ servers: res.servers || [], connectors: res.connectors || [], off: res.off || [] })
const noCommands = { state: 'unknown', reason: '', identity: '' }
// The commands state of the list: see commandsState in utils/chatCommands.js.
const commandsOf = (res) => ({ ...commandsState(res), provider: typeof res?.commands_provider === 'string' ? res.commands_provider : '', identity: noticeIdentity(res, '', '') })
const commandsOf = (res) => ({ ...commandsState(res), provider: typeof res?.commands_provider === 'string' ? res.commands_provider : '', hostExec: typeof res?.commands_host_exec === 'string' ? res.commands_host_exec : '', identity: noticeIdentity(res, '', '') })
export { skillKey }
+2 -2
View File
@@ -4,8 +4,8 @@ import { commandsNotice } from '../utils/chatCommands'
// Dismissal is deliberately mount-local: logout/remount never retains it.
// Exact binding, observation and actor/chat context must match, not just state.
export function useCommandsNotice(commands, isAdmin, context) {
const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason, code: commands.code })
const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason, commands.code])
const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason, code: commands.code, hostExec: commands.hostExec })
const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason, commands.code, commands.hostExec])
const [hiddenIdentity, setHiddenIdentity] = useState('')
useEffect(() => { setHiddenIdentity('') }, [identity])
const hide = useCallback(() => {
+14 -1
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react'
import { useOutletContext } from 'react-router-dom'
import {
Button, Callout, EmptyState, LoadError, Page, PageHeader, PaneLayout, SidePane,
Button, Callout, EmptyState, Notice, LoadError, Page, PageHeader, PaneLayout, SidePane,
Skeleton, SkeletonRows, LoadingSpinner, ShellFallback, StatusChip, STATUS, Switch,
} from '../components/ui'
import { useToastAnchor } from '../components/Toast'
@@ -129,6 +129,18 @@ function Callouts() {
)
}
function Notices() {
return (
<Section id="notices" title="Notice" note="Off or not available: what, why, who can fix it, the fix. Error is alert, warn is status, info is note.">
<div className="ui-cat__stack" data-testid="notices">
<Notice severity="info" title="Thread history is off" reason="This add-on was not allowed to read earlier messages." who="An administrator can allow it in the add-on's permissions." />
<Notice severity="warn" title="Commands can't run here" reason="The server's built-in bash is off." who="Ask an administrator of this server." fix={{ label: 'Open the setting', to: '/app/settings' }} secondary={{ label: 'Hide', onClick: () => {} }} />
<Notice severity="error" title="Cannot be turned on" reason="This version is not compatible with this server." who="Upgrade it to a newer version." fix={{ label: 'Upgrade', onClick: () => {} }} />
</div>
</Section>
)
}
function LoadErrors() {
const [state, setState] = useState('error')
const [tries, setTries] = useState(0)
@@ -278,6 +290,7 @@ export default function UiCatalog() {
<Chips />
<Switches />
<Callouts />
<Notices />
<LoadErrors />
<Empties />
<Loading />
+23 -1
View File
@@ -34,10 +34,15 @@ export function commandsState(res) {
return { state, reason }
}
// The bash row of Settings > Tools, and the Add-ons page, as an administrator's
// notice links to them.
export const HOST_EXEC_SETTING = '/app/settings?group=tools#settings-tools-row-bash'
export const ADDONS_PAGE = '/app/addons'
// The notice for a state, or null when commands run (or the state is unknown).
// keys are under chats:tools.commands. link is for administrators only; a
// person who is not one is told to ask one, unless the fix is theirs.
export function commandsNotice(commands, { isAdmin = false, reason = '', code = '' } = {}) {
export function commandsNotice(commands, { isAdmin = false, reason = '', code = '', hostExec = '' } = {}) {
switch (commands) {
case 'provider-host':
case 'unknown':
@@ -57,6 +62,23 @@ export function commandsNotice(commands, { isAdmin = false, reason = '', code =
askKey: isAdmin ? null : 'askFix',
}
case 'none':
// No add-on runs commands and the server says that its built-in bash is
// off: say so, and give an administrator the way to turn it on. Anyone
// else is told to ask one. A server that does not say keeps the plain
// notice.
if (hostExec === 'off') {
return {
state: 'none',
hostOff: true,
severity: 'warn',
textKey: 'hostOff.reason',
whoKey: isAdmin ? 'hostOff.whoAdmin' : 'hostOff.whoMember',
link: isAdmin ? { to: HOST_EXEC_SETTING, labelKey: 'hostOff.fix' } : null,
sandboxLink: isAdmin ? { to: ADDONS_PAGE, labelKey: 'hostOff.sandbox' } : null,
askKey: null,
reason: '',
}
}
return {
state: 'none',
textKey: 'none',
@@ -368,3 +368,22 @@ test('a draft chat keeps its commands state when a tool is switched on and off',
assert.ok(now.commands.identity, `${step}: the state is the list's, not a reset`)
}
})
test('chat commands: with the built-in bash off the notice says who can turn it on', async () => {
const { HOST_EXEC_SETTING, ADDONS_PAGE } = await import('../../src/utils/chatCommands.js')
const admin = commandsNotice('none', { isAdmin: true, hostExec: 'off' })
assert.equal(admin.hostOff, true)
assert.equal(admin.severity, 'warn')
assert.equal(admin.whoKey, 'hostOff.whoAdmin')
assert.deepEqual(admin.link, { to: HOST_EXEC_SETTING, labelKey: 'hostOff.fix' })
assert.deepEqual(admin.sandboxLink, { to: ADDONS_PAGE, labelKey: 'hostOff.sandbox' })
// Anyone else is told to ask: no setting link, no add-on link.
const member = commandsNotice('none', { isAdmin: false, hostExec: 'off' })
assert.equal(member.whoKey, 'hostOff.whoMember')
assert.equal(member.link, null)
assert.equal(member.sandboxLink, null)
// A server that does not say the mode, or says it is on, keeps the plain notice.
for (const hostExec of ['', 'ask', 'on', undefined]) assert.equal(commandsNotice('none', { isAdmin: true, hostExec }).textKey, 'none')
// The setting link opens Settings, Tools, on the bash row.
assert.match(HOST_EXEC_SETTING, /^\/app\/settings\?group=tools#settings-tools-row-bash$/)
})
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { NOTICE_ICONS, noticeRole, noticeSeverity, noticeAction } from '../../src/components/ui/noticeModel.js'
// Compile the production Notice. Button and Callout are boundaries with their
// own markup rules; they draw plain elements here so the test reads the order
// and the actions Notice asks for.
const source = readFileSync(new URL('../../src/components/ui/Notice.jsx', import.meta.url), 'utf8')
const h = React.createElement
const scope = {
React, NOTICE_ICONS, noticeAction, noticeRole, noticeSeverity,
Button: ({ to, href, onClick, children, variant, ...rest }) => (to !== undefined || href !== undefined
? h('a', { href: to ?? href, 'data-variant': variant, ...rest }, children)
: h('button', { type: 'button', onClick, 'data-variant': variant, ...rest }, children)),
Callout: ({ tone, icon, title, actions, role, className, testId, children, ...rest }) => h('div', { className, role, 'data-tone': tone, 'data-icon': icon, 'data-testid': testId, ...rest },
title ? h('strong', null, title) : null, h('div', { className: 'body' }, children), actions ? h('div', { className: 'actions' }, actions) : null),
}
const compiled = ts.transpileModule(source.replace(/^import .*\n/gm, '').replace('export default function', 'function'), {
compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, fileName: 'Notice.jsx',
}).outputText
const Notice = new Function(...Object.keys(scope), `${compiled}; return Notice`)(...Object.values(scope))
const html = (props) => renderToStaticMarkup(h(Notice, props))
test('notice: what, why, who and the fix come in that order', () => {
const out = html({ severity: 'warn', title: 'WHAT', reason: 'WHY', who: 'WHO', fix: { label: 'FIX', to: '/app/settings' } })
const order = ['WHAT', 'WHY', 'WHO', 'FIX'].map(w => out.indexOf(w))
assert.ok(order.every(i => i >= 0), out)
assert.deepEqual([...order].sort((a, b) => a - b), order)
assert.match(out, /role="status"/)
assert.match(out, /data-tone="warn"/)
assert.match(out, /href="\/app\/settings"/)
assert.match(out, /data-variant="primary"/)
})
test('notice: severity sets the role and the icon', () => {
assert.match(html({ severity: 'error', title: 'T' }), /role="alert"/)
assert.match(html({ severity: 'info', title: 'T' }), /role="note"/)
assert.match(html({ severity: 'info', title: 'T', icon: 'terminal' }), /data-icon="terminal"/)
assert.match(html({ severity: 'error', title: 'T' }), /data-icon="alert-circle"/)
assert.match(html({ title: 'T' }), /data-tone="info"/)
})
test('notice: no fix means no button, and a dead action is not drawn', () => {
assert.doesNotMatch(html({ title: 'T', reason: 'R' }), /<button|<a /)
assert.doesNotMatch(html({ title: 'T', fix: { label: 'Go' } }), /<button|<a /)
const both = html({ title: 'T', fix: { label: 'Upgrade', onClick() {} }, secondary: { label: 'Hide', onClick() {} } })
assert.match(both, /data-variant="primary"[^>]*>Upgrade/)
assert.match(both, /data-variant="ghost"[^>]*>Hide/)
})
test('notice: the reason and who lines are left out when empty', () => {
const out = html({ title: 'T' })
assert.doesNotMatch(out, /ui-notice__reason|ui-notice__who/)
assert.match(out, /ui-notice/)
})
test('notice: an add-on update state maps to a severity, up to date has none', async () => {
const { updateNoticeSeverity } = await import('../../src/components/addons/updateState.js')
assert.equal(updateNoticeSeverity('update-available'), 'warn')
assert.equal(updateNoticeSeverity('incompatible'), 'warn')
assert.equal(updateNoticeSeverity('check-failed'), 'warn')
assert.equal(updateNoticeSeverity('integrity-error'), 'error')
assert.equal(updateNoticeSeverity('unknown'), 'info')
assert.equal(updateNoticeSeverity('up-to-date'), '')
})
@@ -101,3 +101,24 @@ test('load state: the error text is the server message', () => {
assert.equal(errorMessage('timeout'), 'timeout')
assert.equal(errorMessage(null), '')
})
test('notice: severity sets the live role, unknown severity reads as info', async () => {
const { noticeRole, noticeSeverity, NOTICE_ICONS } = await import('../../src/components/ui/noticeModel.js')
assert.equal(noticeRole('error'), 'alert')
assert.equal(noticeRole('warn'), 'status')
assert.equal(noticeRole('info'), 'note')
assert.equal(noticeSeverity('loud'), 'info')
assert.equal(noticeRole(undefined), 'note')
for (const level of ['info', 'warn', 'error']) assert.ok(NOTICE_ICONS[level], level)
})
test('notice: an action needs a label and something to do', async () => {
const { noticeAction } = await import('../../src/components/ui/noticeModel.js')
const go = () => {}
assert.equal(noticeAction(null), null)
assert.equal(noticeAction({ label: 'Open', onClick: undefined }), null)
assert.equal(noticeAction({ onClick: go }), null)
assert.deepEqual(noticeAction({ label: 'Open', to: '/app/settings' }), { label: 'Open', to: '/app/settings' })
assert.equal(noticeAction({ label: 'Go', href: '/x' }).href, '/x')
assert.equal(noticeAction({ label: 'Do', onClick: go }).onClick, go)
})