{ setState({ status: 'loading', apps: [], error: null, stale: false }); setReload(n => n + 1) }}
+ onRetry={() => { setState({ status: 'loading', apps: [], error: null, stale: false, staleReason: '' }); setReload(n => n + 1) }}
>
{[
- describeError(state.error, t),
+ describeError(loadError, t),
kind !== 'auth' && kind !== 'denied' && kind !== 'invalid' ? t('apps.loadError.paused') : '',
t('common:ui.loadError.nothingChanged'),
].filter(Boolean).join(' ')}
@@ -315,9 +337,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
const showAttention = !firstRun && (attention.failed.length > 0 || attention.updates.length > 0 || approvals.length > 0)
return (
- {state.stale && (
- {t('apps.stale')}
- )}
+ {state.stale && reloadRef.current()} />}
{Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => (
{describeOutcome(r.outcome, t, r.error)}
))}
@@ -430,7 +450,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
isAdmin={isAdmin}
upgrade={upgrade}
variant="secondary"
- working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id)}
+ working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id) || Boolean(tile.app.operation)}
/>
) : null}
/>
@@ -510,3 +530,26 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
)
}
+
+// The banner over a list that may be out of date. It says why, names the
+// add-ons the owner is upgrading when the last list knew of it, and offers a
+// retry now; the page also asks again by itself, slower after each failure.
+function StaleNotice({ state, onRetry }) {
+ const { t } = useTranslation('addons')
+ const kind = staleKind(state)
+ const names = upgradingTitles(state.apps)
+ const title = kind === 'unreachable'
+ ? t('apps.busy.unreachable')
+ : names.length > 0 ? t('apps.busy.upgrading', { names: names.join(', ') }) : t('apps.busy.generic')
+ return (
+
+ )
+}
diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js
index 4e0128c7f4..8572d976cc 100644
--- a/core/http/react-ui/src/components/addons/addonState.js
+++ b/core/http/react-ui/src/components/addons/addonState.js
@@ -254,3 +254,62 @@ export function skillsOf(app) {
const list = Array.isArray(app?.skills) ? app.skills : []
return list.filter(s => s && typeof s.name === 'string').map(s => ({ name: s.name, replaces: s.shadows === s.name }))
}
+
+// ---- Operations on the owner ----------------------------------------------
+
+const OPERATION_KINDS = new Set(['install', 'upgrade', 'uninstall', 'enable', 'disable'])
+const OPERATION_PHASES = new Set(['queued', 'downloading', 'backing-up', 'installing', 'migrating', 'starting', 'working'])
+
+// The operation the owner runs on an add-on now, as the list reports it. A kind
+// or phase this page does not know is shown as plain work, never dropped:
+// the add-on is still being changed.
+export function operationOf(app) {
+ const op = app && typeof app === 'object' ? app.operation : null
+ if (!op || typeof op !== 'object') return null
+ return {
+ kind: OPERATION_KINDS.has(op.kind) ? op.kind : 'working',
+ phase: OPERATION_PHASES.has(op.phase) ? op.phase : 'working',
+ since: typeof op.since === 'string' ? op.since : '',
+ }
+}
+
+// The titles of the add-ons the owner is upgrading, for the busy banner.
+export function upgradingTitles(apps) {
+ return (Array.isArray(apps) ? apps : [])
+ .filter(a => a && a.operation && a.operation.kind === 'upgrade')
+ .map(a => a.title || a.id)
+}
+
+// Why the list on screen may be out of date: 'busy' (the owner did not answer
+// in time or the server sent its last copy), 'unreachable' or '' when it is
+// current.
+export function staleKind({ error, stale, staleReason }) {
+ if (!stale) return ''
+ if (error && error.kind === 'busy') return 'busy'
+ if (staleReason === 'owner_busy') return 'busy'
+ if (error || staleReason === 'owner_unavailable') return 'unreachable'
+ return 'busy'
+}
+
+// ---- The last list a browser got -------------------------------------------
+
+const LAST_KEY = 'localai.addons.lastKnownApps.v1'
+
+// A page loaded while the owner is busy has no list in memory. The last list
+// this browser tab got for the same person stands in for it, marked stale.
+// Storage can be absent or full: every call is allowed to do nothing.
+export function saveLastKnown(storage, actor, apps) {
+ try {
+ storage.setItem(LAST_KEY, JSON.stringify({ actor, apps, at: Date.now() }))
+ } catch { /* the page works without it */ }
+}
+
+export function loadLastKnown(storage, actor, maxAgeMs = 24 * 3600 * 1000, now = Date.now()) {
+ try {
+ const raw = JSON.parse(storage.getItem(LAST_KEY) || 'null')
+ if (!raw || raw.actor !== actor || !Array.isArray(raw.apps) || !Number.isFinite(raw.at) || now - raw.at > maxAgeMs) return null
+ return raw.apps
+ } catch {
+ return null
+ }
+}
diff --git a/core/http/react-ui/src/components/addons/errors.js b/core/http/react-ui/src/components/addons/errors.js
index c3b26f81e4..5da91bf46a 100644
--- a/core/http/react-ui/src/components/addons/errors.js
+++ b/core/http/react-ui/src/components/addons/errors.js
@@ -21,6 +21,7 @@ export function describeError(error, t) {
case 'invalid': return serverReason(error) || t('errors.invalid')
case 'unknown_outcome': return t('errors.unknownOutcome')
case 'network': return t('errors.network')
+ case 'busy': return t('errors.busy')
default: return t('errors.unavailable')
}
}
diff --git a/core/http/react-ui/src/components/catalog/catalogState.js b/core/http/react-ui/src/components/catalog/catalogState.js
index d037ea1476..e2add705c1 100644
--- a/core/http/react-ui/src/components/catalog/catalogState.js
+++ b/core/http/react-ui/src/components/catalog/catalogState.js
@@ -102,6 +102,9 @@ export function classifyResponse(status, body) {
if (status === 503 && type === 'outcome_unknown') {
return { kind: 'unknown_outcome', status, message, operationId: typeof err.operationId === 'string' ? err.operationId : '' }
}
+ // The owner is up but did not answer in time (an upgrade runs on it). The
+ // page keeps what it shows and asks again; this is never "no add-ons".
+ if (status === 503 && type === 'owner_busy') return { kind: 'busy', status, message, type }
return { kind: 'unavailable', status, message }
}
diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js
index 311eeb51d7..6369ce5b95 100644
--- a/core/http/react-ui/src/utils/addonsApi.js
+++ b/core/http/react-ui/src/utils/addonsApi.js
@@ -115,8 +115,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
dispatchFile,
apps: {
async list(signal) {
+ return (await api.apps.read(signal)).apps
+ },
+ // The list with the marks the server puts on it: stale is true when the
+ // server answers with the last list it got from the owner (the owner is
+ // busy or not reachable), so a page can say so and keep showing it.
+ async read(signal) {
const out = await call('/api/addons/apps', { signal })
- return Array.isArray(out?.apps) ? out.apps : []
+ return {
+ apps: Array.isArray(out?.apps) ? out.apps : [],
+ stale: out?.stale === true,
+ staleReason: typeof out?.staleReason === 'string' ? out.staleReason : '',
+ staleSince: typeof out?.staleSince === 'string' ? out.staleSince : '',
+ }
},
async start(id, { operationId, generation }, signal) {
const out = await call(`/api/addons/apps/${encodeURIComponent(id)}/start`, { method: 'POST', body: { operationId, generation }, signal })
diff --git a/core/http/react-ui/tests/unit/addon-session.test.mjs b/core/http/react-ui/tests/unit/addon-session.test.mjs
index 359485e730..44dc854b2c 100644
--- a/core/http/react-ui/tests/unit/addon-session.test.mjs
+++ b/core/http/react-ui/tests/unit/addon-session.test.mjs
@@ -72,7 +72,7 @@ for (const switchUser of [false, true]) test(`installed records reject late admi
let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, hasFeature: () => true }
const pending = deferred()
let reads = 0
- const api = { apps: { list: async () => [{ id: 'notes', generation: 1 }] }, installed: () => { reads++; return pending.promise } }
+ const api = { apps: { read: async () => ({ apps: [{ id: 'notes', generation: 1 }], stale: false, staleReason: '' }) }, installed: () => { reads++; return pending.promise } }
const text = readFileSync(new URL('../../src/components/addons/AppsView.jsx', import.meta.url), 'utf8')
// Full Apps body/effects; JSX is compiled, leaf hooks are external boundaries.
const names = [...text.matchAll(/^import \{ ([^}]+) \} from/gm)].flatMap(m => m[1].split(',').map(n => n.trim()))
diff --git a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs
index 3b7c7fe304..fd89679a59 100644
--- a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs
+++ b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs
@@ -257,7 +257,7 @@ for (const accountSwitch of [false, true]) test(`Apps registry: late list cannot
let registry = r.render()
let auth = { user:{id:'alice'}, sessionEpoch:1, isAdmin:true, hasFeature:() => true }
const reads = []
- const api = { apps:{ list:() => { const d = deferred(); reads.push(d); return d.promise } } }
+ const api = { apps:{ read:() => { const d = deferred(); reads.push(d); return d.promise.then(apps => ({ apps, stale:false, staleReason:'' })) } } }
const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', {
useAuth:() => auth, useContext:() => registry, RegistryContext:{}, useTranslation:() => ({t:x=>x,i18n:{}}),
FEATURE_ADDONS:'addons', pollDelay:() => 1000, setTimeout:()=>1, clearTimeout:()=>{},
diff --git a/core/http/react-ui/tests/unit/addons.test.mjs b/core/http/react-ui/tests/unit/addons.test.mjs
index 6eba3f7d8a..03467963f3 100644
--- a/core/http/react-ui/tests/unit/addons.test.mjs
+++ b/core/http/react-ui/tests/unit/addons.test.mjs
@@ -13,6 +13,7 @@ import {
resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary,
shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, addedByEntry, screenshotsOf, listState, featuredEntries,
attentionOf, sortByAttention, filterByState, hueOf, monogram, versionLabel, skillShadowNotes, skillsOf,
+ operationOf, upgradingTitles, staleKind, saveLastKnown, loadLastKnown,
} from '../../src/components/addons/addonState.js'
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
import { runLifecycle, settleOperation, unavailableWait } from '../../src/components/addons/lifecycleFlow.js'
@@ -615,3 +616,48 @@ test('addons: the details list the skills of an add-on and which replace a built
[{ name: 'xlsx', replaces: true }, { name: 'office-tips', replaces: false }])
assert.deepEqual(skillsOf({}), [])
})
+
+test('addons: a busy owner is its own kind of answer, and the list read carries the stale mark', async () => {
+ assert.equal(classifyResponse(503, { error: { type: 'owner_busy', message: 'busy' } }).kind, 'busy')
+ assert.equal(classifyResponse(503, { error: { type: 'owner_unavailable' } }).kind, 'unavailable')
+ const busy = new AddonsError(classifyResponse(503, { error: { type: 'owner_busy' } }))
+ assert.match(describeError(busy, key => key), /errors\.busy/)
+ const api = createAddonsApi({ fetch: async () => json(200, { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' }) })
+ assert.deepEqual(await api.apps.read(), { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' })
+ assert.deepEqual((await api.apps.list()).map(a => a.id), ['notes'])
+ const plain = createAddonsApi({ fetch: async () => json(200, { apps: [] }) })
+ assert.equal((await plain.apps.read()).stale, false)
+})
+
+test('addons: an error is never an empty list, and the page names what the owner is upgrading', () => {
+ const apps = [
+ { id: 'notes', title: 'Notes', operation: { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' } },
+ { id: 'sandbox', title: 'Sandbox', operation: { kind: 'upgrade', phase: 'starting' } },
+ { id: 'plain', title: 'Plain' },
+ ]
+ assert.deepEqual(upgradingTitles(apps), ['Notes', 'Sandbox'])
+ assert.deepEqual(upgradingTitles(null), [])
+ assert.deepEqual(operationOf(apps[0]), { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' })
+ assert.equal(operationOf(apps[2]), null)
+ // A kind or phase this page does not know is still work in progress.
+ assert.deepEqual(operationOf({ operation: { kind: 'defrag', phase: 'later' } }), { kind: 'working', phase: 'working', since: '' })
+ assert.equal(staleKind({ stale: false, error: new Error('x') }), '')
+ assert.equal(staleKind({ stale: true, error: { kind: 'busy' } }), 'busy')
+ assert.equal(staleKind({ stale: true, staleReason: 'owner_busy' }), 'busy')
+ assert.equal(staleKind({ stale: true, error: { kind: 'network' } }), 'unreachable')
+ assert.equal(staleKind({ stale: true, staleReason: 'owner_unavailable' }), 'unreachable')
+})
+
+test('addons: the last list of a tab stands in for a page loaded while the owner is busy', () => {
+ const data = new Map()
+ const store = { getItem: k => (data.has(k) ? data.get(k) : null), setItem: (k, v) => { data.set(k, v) } }
+ assert.equal(loadLastKnown(store, 'a'), null)
+ saveLastKnown(store, 'a', [{ id: 'notes' }])
+ assert.deepEqual(loadLastKnown(store, 'a'), [{ id: 'notes' }])
+ assert.equal(loadLastKnown(store, 'someone-else'), null)
+ assert.equal(loadLastKnown(store, 'a', 1000, Date.now() + 5000), null)
+ // Storage that throws is the same as no storage.
+ const broken = { getItem() { throw new Error('blocked') }, setItem() { throw new Error('full') } }
+ saveLastKnown(broken, 'a', [])
+ assert.equal(loadLastKnown(broken, 'a'), null)
+})