Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import SecurityBanner from './components/SecurityBanner';
import SettingsModal from './components/settings/modal/SettingsModal';
import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
import MemoryEmbeddingBudgetBanner from './components/upsell/MemoryEmbeddingBudgetBanner';
import UserErrorCenter from './components/userErrors/UserErrorCenter';
import AppWalkthrough from './components/walkthrough/AppWalkthrough';
import { MascotFrameProducer } from './features/meet/MascotFrameProducer';
Expand Down Expand Up @@ -292,6 +293,12 @@ export function AppShellDesktop() {
const content = (
<div ref={scrollRef} className="relative h-full overflow-y-auto">
<GlobalUpsellBanner />
{/* #5324: memory-specific budget warning. Distinct from the banner
above — that one sells a plan upgrade, this one steers to the
embedding fixes (local Ollama / BYO key) that keep memory growing.
Only renders for users whose embeddings actually bill against the
managed budget. */}
<MemoryEmbeddingBudgetBanner />
<AppRoutes location={baseLocation} />
{activeProviderAccount && !accountsOverlayOpen && (
<div className="absolute inset-0 z-30">
Expand Down
4 changes: 4 additions & 0 deletions app/src/__tests__/App.webviewOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ vi.mock('../components/layout/shell/SidebarSlot', () => ({
}));
vi.mock('../components/OpenhumanLinkModal', () => ({ default: () => null }));
vi.mock('../components/upsell/GlobalUpsellBanner', () => ({ default: () => null }));
// Same reason as the banner above: it reads billing usage via `useUsageState`,
// which needs the real `CoreStateProvider` snapshot this suite deliberately
// stubs out. This suite is about webview overlay visibility, not banners.
vi.mock('../components/upsell/MemoryEmbeddingBudgetBanner', () => ({ default: () => null }));
vi.mock('../features/meet/MascotFrameProducer', () => ({ MascotFrameProducer: () => null }));
vi.mock('../components/walkthrough/AppWalkthrough', () => ({ default: () => null }));

Expand Down
142 changes: 142 additions & 0 deletions app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import {
const mockPipelineStatus = vi.fn();
const mockSetEnabled = vi.fn();
const mockSyncStatusList = vi.fn();
// #5324: the panel now navigates (budget CTA) and dispatches (escalating the
// blocking cause to the shell-mounted UserErrorCenter). Stub both so the
// suite keeps rendering the panel bare, without a Router or a Redux store.
const mockNavigate = vi.fn();
const mockDispatch = vi.fn();

vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));

vi.mock('../../utils/tauriCommands', async importOriginal => {
// Inherit everything else (types, sibling wrappers) verbatim so the panel
Expand Down Expand Up @@ -408,6 +416,140 @@ describe('<MemoryTreeStatusPanel />', () => {
});
expect(screen.queryByTestId('memory-tree-blocking-cause')).not.toBeInTheDocument();
});

// ── #5324: budget-exhausted state ───────────────────────────────────────

/** A pipeline parked on a spent managed embedding budget. */
function budgetExhaustedPayload() {
return payload({
status: 'error',
reason: '936 unrecoverable failure(s) need action',
pipeline_jobs: { ready: 12, running: 0, failed: 936 },
first_blocking_cause: {
code: 'budget_exhausted',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.budget_exhausted',
},
});
}

it('names the budget-exhausted state instead of a generic error', async () => {
mockPipelineStatus.mockResolvedValueOnce(budgetExhaustedPayload());
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(
/embedding budget reached/i
);
});
// "Error" alone told the user nothing they could act on.
expect(screen.getByTestId('memory-tree-status-label')).not.toHaveTextContent(/^Error$/);
});

it('offers a one-click CTA to the embeddings configuration screen', async () => {
mockPipelineStatus.mockResolvedValueOnce(budgetExhaustedPayload());
render(<MemoryTreeStatusPanel />);

const cta = await screen.findByTestId('memory-tree-budget-cta');
fireEvent.click(cta);
expect(mockNavigate).toHaveBeenCalledWith('/connections?tab=embeddings');
});

it('escalates the budget cause out of this panel into the global error center', async () => {
// The whole point of the issue: a warning only visible inside this panel
// is a warning nobody sees.
mockPipelineStatus.mockResolvedValueOnce(budgetExhaustedPayload());
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(mockDispatch).toHaveBeenCalled();
});
});

it('keeps the paused label when the user paused a tree carrying an old budget failure', async () => {
// `first_blocking_cause` reports the most recent failed job regardless of
// why the pipeline is currently stopped. Relabelling a manually-paused
// tree would hide the real reason it is not running.
mockPipelineStatus.mockResolvedValueOnce(
payload({
status: 'paused',
is_paused: true,
reason: 'scheduler gate mode = off',
first_blocking_cause: {
code: 'budget_exhausted',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.budget_exhausted',
},
})
);
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/paused/i);
});
expect(screen.getByTestId('memory-tree-status-label')).not.toHaveTextContent(
/embedding budget reached/i
);
});

it('does not show the budget CTA for other blocking causes', async () => {
mockPipelineStatus.mockResolvedValueOnce(
payload({
status: 'error',
first_blocking_cause: {
code: 'embedding_dim_mismatch',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.embedding_dim_mismatch',
},
})
);
render(<MemoryTreeStatusPanel />);

await waitFor(() => {
expect(screen.getByTestId('memory-tree-blocking-cause')).toBeInTheDocument();
});
expect(screen.queryByTestId('memory-tree-budget-cta')).not.toBeInTheDocument();
expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/error/i);
});

it('handles the legacy degraded.cause payload shape (no first_blocking_cause)', async () => {
// Older/degraded-only payloads carry the cause on `degraded.cause` and omit
// `first_blocking_cause`. The label, CTA, and escalation must all key off
// the same resolved cause, so this shape must behave exactly like the
// `first_blocking_cause` one — not render the banner while silently
// dropping the budget label, CTA, and the global escalation.
mockPipelineStatus.mockResolvedValueOnce(
payload({
status: 'degraded',
reason: 'queue has not completed any job in 8h — memory is not growing',
degraded: {
semantic_recall: false,
structure: false,
cause: {
code: 'budget_exhausted',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.budget_exhausted',
},
},
})
);
render(<MemoryTreeStatusPanel />);

// Named budget state, not a bare "degraded".
await waitFor(() => {
expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(
/embedding budget reached/i
);
});
// CTA present…
expect(screen.getByTestId('memory-tree-budget-cta')).toBeInTheDocument();
// …and the cause still escalates out of this panel. Escalation runs from an
// effect after the status resolves, so wait for it rather than asserting
// synchronously (matches the `first_blocking_cause` escalation test above).
await waitFor(() => {
expect(mockDispatch).toHaveBeenCalled();
});
});
});

describe('integration health helpers', () => {
Expand Down
63 changes: 58 additions & 5 deletions app/src/components/intelligence/MemoryTreeStatusPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
* `settings/panels/AIPanel.tsx` (switch markup).
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { useT } from '../../lib/i18n/I18nContext';
import { reportMemoryPipelineFailure } from '../../lib/userErrors/report';
import { useAppDispatch } from '../../store/hooks';
import type { ToastNotification } from '../../types/intelligence';
import {
memorySyncStatusList,
Expand Down Expand Up @@ -331,9 +334,31 @@ function IntegrationHealthStrip({
*/
export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
const { t } = useT();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { status, integrations, loading, error, refresh } = useMemoryTreeStatus();
const [toggleBusy, setToggleBusy] = useState(false);

// #002 (FR-004): the single first blocking cause. Prefer the explicit
// `first_blocking_cause`; fall back to the active degradation cause so older
// payload shapes still surface something actionable. Derived ONCE here so the
// escalation, the status label, the CTA, and the banner all key off the same
// cause — a payload that carries only `degraded.cause` (and no
// `first_blocking_cause`) must not render the banner one way while the
// escalation and budget label read a different, empty cause.
const blockingCause = status?.first_blocking_cause ?? status?.degraded?.cause ?? null;

// #5324: this panel was the ONLY place a budget-exhausted memory pipeline
// was ever surfaced, so users who never opened it experienced weeks of
// silently broken memory. Escalate the typed cause into the shell-mounted
// UserErrorCenter, which stays visible across routes and after the panel
// unmounts. The store dedupes on descriptor id, so polling re-reports bump
// the recurrence count rather than stacking entries.
const blockingCauseCode = blockingCause?.code ?? null;
useEffect(() => {
reportMemoryPipelineFailure(dispatch, blockingCauseCode);
}, [dispatch, blockingCauseCode]);

const handleToggle = useCallback(async () => {
if (!status || toggleBusy) return;
const nextEnabled = status.is_paused; // currently paused ⇒ enable
Expand All @@ -352,7 +377,21 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
}, [status, toggleBusy, refresh, onToast, t]);

const statusKind = status?.status ?? 'idle';
// #5324: "Error — 936 unrecoverable failures need action" told the user
// nothing they could act on. When the blocking cause is a spent embedding
// budget, name that state exactly. Derived here rather than as a new wire
// status so older clients keep deserialising the payload unchanged and the
// existing `status` precedence rules stay untouched.
//
// Scoped to the states the budget actually explains. `first_blocking_cause`
// reports the most recent failed job even when the user has since paused the
// tree themselves, so without this guard a manually-paused tree carrying an
// old budget failure would be relabelled and hide the real reason it stopped.
const isBudgetExhausted =
blockingCause?.code === 'budget_exhausted' &&
(statusKind === 'error' || statusKind === 'degraded');
const statusLabel: string = (() => {
if (isBudgetExhausted) return t('memoryTree.status.statusBudgetExhausted');
switch (statusKind) {
case 'running':
return t('memoryTree.status.statusRunning');
Expand All @@ -370,11 +409,8 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
}
})();

// #002 (FR-004): the single first blocking cause, rendered verbatim with a
// localized remediation. Prefer the explicit `first_blocking_cause`; fall
// back to the active degradation cause so older payload shapes still surface
// something actionable.
const blockingCause = status?.first_blocking_cause ?? status?.degraded?.cause ?? null;
// `blockingCause` (derived above) is rendered verbatim in the banner below
// with a localized remediation.
const degraded = status?.degraded;

const checked = !(status?.is_paused ?? false);
Expand Down Expand Up @@ -419,6 +455,23 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
<div className="font-medium" data-testid="memory-tree-blocking-cause-remediation">
{t(blockingCause.remediation_key, t('memory.health.remediation.unknown'))}
</div>
{/* #5324: the remediation text names the fix ("set up local Ollama
embeddings or add your own key") but left the user to find that
screen themselves. One click, no embeddings knowledge required. */}
{isBudgetExhausted ? (
<div className="mt-2">
<Button
variant="secondary"
size="xs"
data-testid="memory-tree-budget-cta"
analyticsId="memory-tree-budget-configure-embeddings"
onClick={() => {
navigate('/connections?tab=embeddings');
}}>
{t('userErrors.action.openEmbeddingsSettings')}
</Button>
</div>
) : null}
{degraded?.semantic_recall || degraded?.structure ? (
<div className="mt-1 flex flex-wrap gap-1.5" data-testid="memory-tree-degraded-badges">
{degraded?.semantic_recall ? (
Expand Down
106 changes: 106 additions & 0 deletions app/src/components/upsell/MemoryEmbeddingBudgetBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Memory-embedding budget banner (#5324).
*
* Shell-mounted beside {@link GlobalUpsellBanner}, so the warning reaches the
* user on whatever screen they are on rather than waiting for them to open
* Memory Tree settings — the failure mode this issue is about.
*
* Escalation, matching the issue's acceptance criteria:
*
* | Consumption | Behaviour |
* | ----------- | ------------------------------------------------------ |
* | ≥ 75% | dismissible warning — "set up local embeddings or …" |
* | ≥ 90% | non-dismissible warning with the same CTA |
* | exhausted | non-dismissible, and memory has already stopped growing |
*
* Dismissal is per-session and per-level on purpose: dismissing the 75%
* warning must not also silence the 90% escalation, or the user is back to a
* silent failure. It is deliberately not persisted — a warning that survives
* a restart it no longer applies to is worse than one shown twice.
*
* The CTA deep-links to the embeddings configuration screen. It never asks
* the user to know what an embedding is: the copy names the two fixes (local
* Ollama, own API key) and the button takes them to the one screen where both
* are done.
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import {
type EmbeddingBudgetLevel,
useEmbeddingBudgetState,
} from '../../hooks/useEmbeddingBudgetState';
import { useT } from '../../lib/i18n/I18nContext';
import { showNativeNotification } from '../../lib/nativeNotifications/tauriBridge';
import UpsellBanner from './UpsellBanner';

/** Where both remediations (local Ollama, BYO key) are configured. */
export const EMBEDDINGS_SETTINGS_ROUTE = '/connections?tab=embeddings';

/** Only the early warning can be silenced; escalations cannot. */
function isDismissible(level: EmbeddingBudgetLevel): boolean {
return level === 'warn';
}

/**
* Module-scoped so the OS notification fires at most once per app session.
* The banner re-renders on every usage poll; without this the user would get
* a notification every 60s, which trains them to mute the app.
*/
let nativeNotificationSent = false;

/** Test seam — resets the once-per-session latch. */
export function __resetNativeNotificationLatchForTests() {
nativeNotificationSent = false;
}

export default function MemoryEmbeddingBudgetBanner() {
const { t } = useT();
const navigate = useNavigate();
const { level, pct } = useEmbeddingBudgetState();
const [dismissedLevel, setDismissedLevel] = useState<EmbeddingBudgetLevel | null>(null);

// Push an OS-level notification the first time the budget is actually spent.
// The in-app banner and UserErrorCenter only reach a user who is looking at
// the app; the whole point of this issue is that memory broke while nobody
// was looking. Email is the backend's job (tracked separately) — this is the
// client-side half.
//
// Fires only on `exhausted`, never on the 75%/90% warnings: those are not
// yet a broken state, and an OS notification for them would be noise.
useEffect(() => {
if (level !== 'exhausted' || nativeNotificationSent) return;
nativeNotificationSent = true;
void showNativeNotification({
title: t('memoryBudget.exhaustedTitle'),
body: t('memoryBudget.exhaustedMessage'),
tag: 'memory-embedding-budget-exhausted',
});
}, [level, t]);

if (level === 'none') return null;
if (dismissedLevel === level) return null;

const isExhausted = level === 'exhausted';
const title = isExhausted ? t('memoryBudget.exhaustedTitle') : t('memoryBudget.approachingTitle');
const message = isExhausted
? t('memoryBudget.exhaustedMessage')
: t('memoryBudget.approachingMessage').replace('{pct}', String(pct));

return (
<div className="relative z-20" data-testid="memory-embedding-budget-banner">
<UpsellBanner
variant="warning"
title={title}
message={message}
ctaLabel={t('memoryBudget.cta')}
rounded={false}
dismissible={isDismissible(level)}
onDismiss={() => setDismissedLevel(level)}
onCtaClick={() => {
navigate(EMBEDDINGS_SETTINGS_ROUTE);
}}
/>
</div>
);
}
Loading
Loading