Skip to content
Closed
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
88 changes: 88 additions & 0 deletions app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));
vi.mock('../analytics', () => ({
trackAnalyticsEvent: (...args: unknown[]) => mockTrackAnalyticsEvent(...args),
}));
const mockSetCloudSummarization = vi.fn();

vi.mock('../../utils/tauriCommands', async importOriginal => {
// Inherit everything else (types, sibling wrappers) verbatim so the panel
Expand All @@ -52,6 +53,7 @@ vi.mock('../../utils/tauriCommands', async importOriginal => {
memorySyncStatusList: (...args: unknown[]) => mockSyncStatusList(...args),
memoryTreeRetryFailed: (...args: unknown[]) => mockRetryFailed(...args),
memoryNamespaceSummaries: (...args: unknown[]) => mockNamespaceSummaries(...args),
memoryTreeSetCloudSummarization: (...args: unknown[]) => mockSetCloudSummarization(...args),
};
});

Expand Down Expand Up @@ -87,6 +89,8 @@ describe('<MemoryTreeStatusPanel />', () => {
mockRetryFailed.mockReset();
mockNamespaceSummaries.mockReset();
mockTrackAnalyticsEvent.mockReset();
mockSetCloudSummarization.mockReset();
mockSetCloudSummarization.mockResolvedValue(undefined);
mockSyncStatusList.mockResolvedValue([]); // default: empty, harmless to existing tests
// Same default the inline stub used to hard-code, now re-programmable
// per test so the failure branch can be driven.
Expand Down Expand Up @@ -182,6 +186,90 @@ describe('<MemoryTreeStatusPanel />', () => {
expect(toggle.getAttribute('aria-checked')).toBe('false');
});

it('reflects stored cloud-summarization consent and can withdraw it', async () => {
// The remediation for `summarizer_unavailable` names this flag, and until
// now nothing in the app could set it. The control must show the STORED
// value — a default-rendered switch would misreport whether memory
// summaries are allowed to leave the machine.
mockPipelineStatus
.mockResolvedValueOnce(payload({ cloud_summarization_opt_in: true }))
.mockResolvedValue(payload({ cloud_summarization_opt_in: false }));

render(<MemoryTreeStatusPanel />);

const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle');
await waitFor(() => {
expect(toggle.getAttribute('aria-checked')).toBe('true');
});

fireEvent.click(toggle);

await waitFor(() => {
expect(mockSetCloudSummarization).toHaveBeenCalledWith(false);
});
await waitFor(() => {
expect(toggle.getAttribute('aria-checked')).toBe('false');
});
});

it('offers the cloud-summarization toggle even when the summarizer is fine', async () => {
// A control that only appeared alongside the error could grant consent but
// never withdraw it: fixing the problem would take the switch away.
mockPipelineStatus.mockResolvedValue(
payload({ cloud_summarization_opt_in: false, first_blocking_cause: null })
);

render(<MemoryTreeStatusPanel />);

const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle');
expect(toggle.getAttribute('aria-checked')).toBe('false');

fireEvent.click(toggle);
await waitFor(() => {
expect(mockSetCloudSummarization).toHaveBeenCalledWith(true);
});
});

it('surfaces a failed consent change instead of leaving the switch lying', async () => {
// The toggle is optimistic about nothing: on failure the panel re-reads the
// stored value on the next poll, so the switch must not be left showing a
// consent state the core never recorded. The toast is how the user learns
// that — silently swallowing the rejection would show "off" for a machine
// still summarising in the cloud.
mockPipelineStatus.mockResolvedValue(payload({ cloud_summarization_opt_in: false }));
mockSetCloudSummarization.mockRejectedValueOnce(new Error('core unreachable'));
const onToast = vi.fn();

render(<MemoryTreeStatusPanel onToast={onToast} />);

const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle');
fireEvent.click(toggle);

await waitFor(() => {
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error', message: 'core unreachable' })
);
});

// And the control comes back — a toggle stuck disabled after one failure
// cannot be used to withdraw consent later.
await waitFor(() => {
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect((toggle as HTMLButtonElement).disabled).toBe(false);
});
});

it('treats a core that predates the field as not opted in', async () => {
// `cloud_summarization_opt_in` is `#[serde(default)]` on the wire, so an
// older core omits it. Absent must read as "no consent", never as "on".
mockPipelineStatus.mockResolvedValue(payload());

render(<MemoryTreeStatusPanel />);

const toggle = await screen.findByTestId('memory-tree-cloud-summarization-toggle');
expect(toggle.getAttribute('aria-checked')).toBe('false');
});

it('renders a paused pill with the reason from the wire payload', async () => {
mockPipelineStatus.mockResolvedValueOnce(
payload({ status: 'paused', is_paused: true, reason: 'scheduler gate mode = off' })
Expand Down
67 changes: 66 additions & 1 deletion app/src/components/intelligence/MemoryTreeStatusPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import { useT } from '../../lib/i18n/I18nContext';
import { reportMemoryPipelineFailure, reportMemoryQuarantine } from '../../lib/userErrors/report';
import { useAppDispatch } from '../../store/hooks';
import type { ToastNotification } from '../../types/intelligence';
import { memoryTreeRetryFailed, memoryTreeSetEnabled } from '../../utils/tauriCommands';
import {
memoryTreeRetryFailed,
memoryTreeSetCloudSummarization,
memoryTreeSetEnabled,
} from '../../utils/tauriCommands';
import { trackAnalyticsEvent } from '../analytics';
import { Card } from '../ui';
import Button from '../ui/Button';
Expand Down Expand Up @@ -90,6 +94,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
reportMemoryQuarantine(dispatch, quarantine);
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the fields that matter
}, [dispatch, quarantineKey]);
const [cloudBusy, setCloudBusy] = useState(false);

const handleToggle = useCallback(async () => {
if (!status || toggleBusy) return;
Expand Down Expand Up @@ -147,6 +152,27 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
setRetryBusy(false);
}
}, [retryBusy, refresh, onToast, t]);
const handleCloudSummarizationToggle = useCallback(async () => {
if (!status || cloudBusy) return;
const next = !(status.cloud_summarization_opt_in ?? false);
console.debug('[ui-flow][memory-tree-status] cloud-summarization toggle: entry next=%s', next);
setCloudBusy(true);
try {
await memoryTreeSetCloudSummarization(next);
trackAnalyticsEvent('memory_tree_cloud_summarization_changed', { opted_in: next });
await refresh();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[ui-flow][memory-tree-status] cloud-summarization toggle: error %s', message);
onToast?.({
type: 'error',
title: t('memoryTree.status.cloudSummarizationToggleFailed'),
message,
});
} finally {
setCloudBusy(false);
}
}, [status, cloudBusy, refresh, onToast, t]);

Comment thread
yh928 marked this conversation as resolved.
const statusKind = status?.status ?? 'idle';
// #5324: "Error — 936 unrecoverable failures need action" told the user
Expand Down Expand Up @@ -192,6 +218,7 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
const failedJobs = status?.pipeline_jobs.failed ?? 0;

const checked = !(status?.is_paused ?? false);
const cloudSummarizationOn = status?.cloud_summarization_opt_in ?? false;

const labelClass = 'text-[11px] uppercase tracking-wide text-content-muted mb-1';
const valueClass = 'text-xl font-semibold text-content';
Expand Down Expand Up @@ -408,6 +435,44 @@ export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) {
data-testid="memory-tree-status-toggle"
/>
</div>

{/* Cloud-summarization consent. Rendered unconditionally, next to the
auto-sync toggle, rather than only while `summarizer_unavailable` is
live: a control that appears with the error and vanishes once it is
fixed cannot be used to withdraw the consent it granted. */}
<div
className="flex items-center justify-between gap-3 rounded-lg border border-line bg-surface px-3 py-2"
data-testid="memory-tree-cloud-summarization-row">
<div className="min-w-0">
<div className="text-sm font-medium text-content">
{t('memoryTree.status.cloudSummarizationLabel')}
</div>
<div className="text-xs text-content-muted">
{t('memoryTree.status.cloudSummarizationDescription')}
</div>
</div>
<button
type="button"
role="switch"
aria-label={t('memoryTree.status.cloudSummarizationLabel')}
aria-checked={cloudSummarizationOn}
disabled={cloudBusy || loading || !status}
onClick={() => {
void handleCloudSummarizationToggle();
}}
data-analytics-id="memory-tree-cloud-summarization"
data-testid="memory-tree-cloud-summarization-toggle"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${
cloudSummarizationOn ? 'bg-primary-500' : 'bg-surface-strong'
}`}>
<span
aria-hidden
className={`inline-block h-4 w-4 transform rounded-full bg-surface shadow transition-transform ${
cloudSummarizationOn ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,10 @@ const messages: TranslationMap = {
'memoryTree.status.title': 'شجرة الذاكرة',
'memoryTree.status.autoSyncLabel': 'النظام الآلي',
'memoryTree.status.autoSyncDescription': 'توقف عن الابتلاع (ويكي) الحالي يبقى قابلاً للتساؤل',
'memoryTree.status.cloudSummarizationLabel': 'التلخيص السحابي',
'memoryTree.status.cloudSummarizationDescription':
'يتيح إنشاء أشجار التلخيص دون ذكاء اصطناعي محلي. تُرسَل ملخصات الذاكرة إلى مزوّد السحابة الذي أعددته.',
'memoryTree.status.cloudSummarizationToggleFailed': 'تعذّر تغيير التلخيص السحابي',
'memoryTree.status.statusTile': 'الحالة',
'memoryTree.status.lastSyncTile': 'آخر تزامن',
'memoryTree.status.totalChunksTile': 'أوراق شجرة الملخصات',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'স্বয়ংক্রিয়-sync',
'memoryTree.status.autoSyncDescription':
'নতুন অভিযান থামাতে বিরতি দাও। এখানে বিদ্যমান উইকি অনুসন্ধানের জন্য অপেক্ষা করছে।',
'memoryTree.status.cloudSummarizationLabel': 'ক্লাউড সারাংশ',
'memoryTree.status.cloudSummarizationDescription':
'স্থানীয় AI ছাড়াই সারাংশ ট্রি তৈরি করতে দেয়। মেমরি সারাংশ আপনার কনফিগার করা ক্লাউড প্রদানকারীর কাছে পাঠানো হয়।',
'memoryTree.status.cloudSummarizationToggleFailed': 'ক্লাউড সারাংশ পরিবর্তন করা যায়নি',
'memoryTree.status.statusTile': 'অবস্থা',
'memoryTree.status.lastSyncTile': 'সর্বশেষ সুসংগতি',
'memoryTree.status.totalChunksTile': 'সারাংশ-বৃক্ষের পাতা',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto Sync',
'memoryTree.status.autoSyncDescription':
'Pausieren Sie, um die erneute Einnahme zu stoppen. Vorhandenes Wiki bleibt abfragbar.',
'memoryTree.status.cloudSummarizationLabel': 'Cloud-Zusammenfassung',
'memoryTree.status.cloudSummarizationDescription':
'Ermöglicht Zusammenfassungsbäume ohne lokale KI. Speicher-Zusammenfassungen werden an Ihren konfigurierten Cloud-Anbieter gesendet.',
'memoryTree.status.cloudSummarizationToggleFailed':
'Cloud-Zusammenfassung konnte nicht geändert werden',
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Letzte Synchronisierung',
'memoryTree.status.totalChunksTile': 'Blätter des Zusammenfassungsbaums',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,10 @@ const en: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-sync',
'memoryTree.status.autoSyncDescription':
'Pause to stop new ingestion. Existing wiki stays queryable.',
'memoryTree.status.cloudSummarizationLabel': 'Cloud summarization',
'memoryTree.status.cloudSummarizationDescription':
'Lets summary trees build without local AI. Memory summaries are sent to your configured cloud provider.',
'memoryTree.status.cloudSummarizationToggleFailed': "Couldn't change cloud summarization",
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Last sync',
'memoryTree.status.totalChunksTile': 'Summary-tree leaves',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-sincronización',
'memoryTree.status.autoSyncDescription':
'Pausa para detener la nueva ingestión. El wiki existente sigue siendo consultable.',
'memoryTree.status.cloudSummarizationLabel': 'Resumen en la nube',
'memoryTree.status.cloudSummarizationDescription':
'Permite crear árboles de resumen sin IA local. Los resúmenes de memoria se envían a tu proveedor de nube configurado.',
'memoryTree.status.cloudSummarizationToggleFailed': 'No se pudo cambiar el resumen en la nube',
'memoryTree.status.statusTile': 'Estado',
'memoryTree.status.lastSyncTile': 'Última sincronización',
'memoryTree.status.totalChunksTile': 'Hojas del árbol de resúmenes',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Auto-synchronisation',
'memoryTree.status.autoSyncDescription':
'Pause pour arrêter la nouvelle ingestion. Le wiki existant reste consultable.',
'memoryTree.status.cloudSummarizationLabel': 'Résumé dans le cloud',
'memoryTree.status.cloudSummarizationDescription':
'Permet de créer des arbres de résumé sans IA locale. Les résumés de mémoire sont envoyés à votre fournisseur cloud configuré.',
'memoryTree.status.cloudSummarizationToggleFailed':
'Impossible de modifier le résumé dans le cloud',
'memoryTree.status.statusTile': 'Statut',
'memoryTree.status.lastSyncTile': 'Dernière synchronisation',
'memoryTree.status.totalChunksTile': "Feuilles de l'arbre de synthèse",
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'ऑटो सिंक',
'memoryTree.status.autoSyncDescription':
'नए अंतर्ग्रहण को रोकने के लिए रोकें। मौजूदा विकि क्वेरी योग्य रहता है।',
'memoryTree.status.cloudSummarizationLabel': 'क्लाउड सारांश',
'memoryTree.status.cloudSummarizationDescription':
'स्थानीय AI के बिना सारांश ट्री बनाने देता है। मेमोरी सारांश आपके कॉन्फ़िगर किए गए क्लाउड प्रदाता को भेजे जाते हैं।',
'memoryTree.status.cloudSummarizationToggleFailed': 'क्लाउड सारांश नहीं बदला जा सका',
'memoryTree.status.statusTile': 'स्थिति',
'memoryTree.status.lastSyncTile': 'अंतिम सिंक',
'memoryTree.status.totalChunksTile': 'सारांश-वृक्ष की पत्तियाँ',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Sinkronisasi otomatis',
'memoryTree.status.autoSyncDescription':
'Jeda untuk menghentikan ingest baru. Wiki yang ada tetap dapat dikueri.',
'memoryTree.status.cloudSummarizationLabel': 'Ringkasan cloud',
'memoryTree.status.cloudSummarizationDescription':
'Memungkinkan pohon ringkasan dibuat tanpa AI lokal. Ringkasan memori dikirim ke penyedia cloud yang Anda konfigurasikan.',
'memoryTree.status.cloudSummarizationToggleFailed': 'Tidak dapat mengubah ringkasan cloud',
'memoryTree.status.statusTile': 'Status',
'memoryTree.status.lastSyncTile': 'Sinkronisasi terakhir',
'memoryTree.status.totalChunksTile': 'Daun pohon ringkasan',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Sincronizzazione automatica',
'memoryTree.status.autoSyncDescription':
'Pausa per interrompere la nuova ingestione. La wiki esistente rimane interrogabile.',
'memoryTree.status.cloudSummarizationLabel': 'Riepilogo nel cloud',
'memoryTree.status.cloudSummarizationDescription':
'Consente di creare alberi di riepilogo senza IA locale. I riepiloghi della memoria vengono inviati al provider cloud configurato.',
'memoryTree.status.cloudSummarizationToggleFailed':
'Impossibile modificare il riepilogo nel cloud',
'memoryTree.status.statusTile': 'Stato',
'memoryTree.status.lastSyncTile': 'Ultima sincronizzazione',
'memoryTree.status.totalChunksTile': "Foglie dell'albero dei riassunti",
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,10 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': '자동 동기화',
'memoryTree.status.autoSyncDescription':
'새 수집을 중지하려면 일시 중지하세요. 기존 위키는 계속 쿼리할 수 있습니다.',
'memoryTree.status.cloudSummarizationLabel': '클라우드 요약',
'memoryTree.status.cloudSummarizationDescription':
'로컬 AI 없이도 요약 트리를 만듭니다. 메모리 요약이 설정된 클라우드 제공자로 전송됩니다.',
'memoryTree.status.cloudSummarizationToggleFailed': '클라우드 요약 설정을 변경할 수 없습니다.',
'memoryTree.status.statusTile': '상태',
'memoryTree.status.lastSyncTile': '마지막 동기화',
'memoryTree.status.totalChunksTile': '요약 트리 리프',
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/i18n/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,11 @@ const messages: TranslationMap = {
'memoryTree.status.autoSyncLabel': 'Automatyczna synchronizacja',
'memoryTree.status.autoSyncDescription':
'Wstrzymaj, aby zatrzymać nowe pobieranie. Istniejąca wiki pozostanie dostępna do zapytań.',
'memoryTree.status.cloudSummarizationLabel': 'Podsumowanie w chmurze',
'memoryTree.status.cloudSummarizationDescription':
'Pozwala tworzyć drzewa podsumowań bez lokalnej AI. Podsumowania pamięci są wysyłane do skonfigurowanego dostawcy chmury.',
'memoryTree.status.cloudSummarizationToggleFailed':
'Nie udało się zmienić podsumowania w chmurze',
'memoryTree.status.statusTile': 'Stan',
'memoryTree.status.lastSyncTile': 'Ostatnia synchronizacja',
'memoryTree.status.totalChunksTile': 'Liście drzewa podsumowań',
Expand Down
Loading
Loading