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
77 changes: 77 additions & 0 deletions app/src/components/intelligence/MemoryFreshnessPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { computeFreshness } from '../../lib/memory/memoryFreshness';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryFreshnessPanel from './MemoryFreshnessPanel';

const NOW = 1_700_000_000;
const DAY = 86400;

function rel(subject: string, object: string, agoDays: number): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'likes',
object,
attrs: {},
updatedAt: NOW - agoDays * DAY,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

const report = computeFreshness(
[rel('You', 'Berlin', 0), rel('You', 'coffee', 30), rel('You', 'guitar', 90)],
NOW
);

describe('<MemoryFreshnessPanel />', () => {
it('renders the loading skeleton', () => {
render(<MemoryFreshnessPanel report={null} loading />);
expect(screen.getByTestId('memory-freshness-loading')).toBeInTheDocument();
});

it('renders the empty state when there are no facts', () => {
render(<MemoryFreshnessPanel report={computeFreshness([], NOW)} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<MemoryFreshnessPanel report={null} error="graph unavailable" onRetry={onRetry} />);
expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders status tiles and the re-confirm queue (non-fresh facts only)', () => {
render(<MemoryFreshnessPanel report={report} />);
expect(screen.getByText('Fresh')).toBeInTheDocument();
expect(screen.getByText('Fading')).toBeInTheDocument();
expect(screen.getByText('Stale')).toBeInTheDocument();
expect(screen.getByText('Re-confirm queue')).toBeInTheDocument();
// The stale 'guitar' fact and fading 'coffee' fact are in the queue...
expect(screen.getByText(/guitar/)).toBeInTheDocument();
expect(screen.getByText(/coffee/)).toBeInTheDocument();
// ...but the fresh 'Berlin' fact is not.
expect(screen.queryByText(/Berlin/)).not.toBeInTheDocument();
});

it('shows the all-fresh message when nothing needs re-confirming', () => {
const allFresh = computeFreshness([rel('You', 'Berlin', 0)], NOW);
render(<MemoryFreshnessPanel report={allFresh} />);
expect(
screen.getByText('Every fact is still fresh — nothing to re-confirm.')
).toBeInTheDocument();
});

it('notes when the re-confirm queue is truncated past the row cap', () => {
// 60 stale facts -> the queue is capped at 50 and a "showing 50 of 60" note appears.
const many = Array.from({ length: 60 }, (_, i) => rel('You', `fact${i}`, 365));
render(<MemoryFreshnessPanel report={computeFreshness(many, NOW)} />);
expect(screen.getByText('Showing 50 of 60 — address these first.')).toBeInTheDocument();
});
});
203 changes: 203 additions & 0 deletions app/src/components/intelligence/MemoryFreshnessPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Knowledge Freshness — presentational view. Pure: renders the freshness report
* (status tiles + the re-confirm queue). No data fetching, no clock, no RNG.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { FactFreshness, FreshnessReport } from '../../lib/memory/memoryFreshness';

const MAX_QUEUE_ROWS = 50;

interface MemoryFreshnessPanelProps {
report: FreshnessReport | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}

const STATUS_BAR: Record<FactFreshness['status'], string> = {
fresh: 'bg-sage-400/70',
fading: 'bg-amber-400/70',
stale: 'bg-coral-400/70',
};

const STATUS_BADGE: Record<FactFreshness['status'], string> = {
fresh: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300',
fading: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
stale: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300',
};

const pct = (fraction: number): number => Math.round(fraction * 100);

const MemoryFreshnessPanel = ({ report, loading, error, onRetry }: MemoryFreshnessPanelProps) => {
const { t } = useT();

const intro = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('memoryFreshness.title')}</p>
<p>{t('memoryFreshness.intro')}</p>
</div>
);

if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('memoryFreshness.loading')}
data-testid="memory-freshness-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-8"
/>
))}
</div>
</div>
);
}

if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('memoryFreshness.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('memoryFreshness.retry')}
</button>
)}
</div>
</div>
);
}

if (!report || report.total === 0) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('memoryFreshness.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.emptyHint')}
</p>
</div>
</div>
);
}

const queue = report.staleQueue.slice(0, MAX_QUEUE_ROWS);

return (
<div className="space-y-4">
{intro}

{/* Status tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('memoryFreshness.metricFresh'), value: report.freshCount },
{ label: t('memoryFreshness.metricFading'), value: report.fadingCount },
{ label: t('memoryFreshness.metricStale'), value: report.staleCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400 tabular-nums">
{t('memoryFreshness.recallCaption')
.replace('{recall}', String(pct(report.averageRecall)))
.replace('{total}', String(report.total))}
</p>

{/* Re-confirm queue */}
<section aria-labelledby="memory-freshness-heading" className="space-y-1">
<h3
id="memory-freshness-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.queueHeading')}
</h3>
{queue.length === 0 ? (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('memoryFreshness.allFresh')}
</p>
) : (
<ul className="space-y-1.5">
{queue.map(fact => (
<li
key={fact.id}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2"
title={t('memoryFreshness.recallTitle')
.replace('{recall}', String(pct(fact.recall)))
.replace('{halfLife}', String(Math.round(fact.halfLifeDays)))}>
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 text-sm text-stone-800 dark:text-neutral-100 break-words">
{fact.subject} {fact.predicate} {fact.object}
</p>
<span
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${STATUS_BADGE[fact.status]}`}>
{fact.status === 'stale'
? t('memoryFreshness.statusStale')
: t('memoryFreshness.statusFading')}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className={`h-full ${STATUS_BAR[fact.status]}`}
style={{ width: `${pct(fact.recall)}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{pct(fact.recall)}%
</span>
<span className="w-16 shrink-0 text-right text-stone-400 dark:text-neutral-500">
{t('memoryFreshness.ageLabel').replace(
'{days}',
String(Math.round(fact.ageDays))
)}
</span>
</div>
</li>
))}
</ul>
)}
Comment thread
graycyrus marked this conversation as resolved.
{report.staleQueue.length > MAX_QUEUE_ROWS && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('memoryFreshness.queueTruncated')
.replace('{shown}', String(MAX_QUEUE_ROWS))
.replace('{total}', String(report.staleQueue.length))}
</p>
)}
</section>
</div>
);
};

export default MemoryFreshnessPanel;
66 changes: 66 additions & 0 deletions app/src/components/intelligence/MemoryFreshnessTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { computeFreshness } from '../../lib/memory/memoryFreshness';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryFreshnessTab from './MemoryFreshnessTab';

const mockLoadFreshness = vi.fn();
const mockLoadNamespaces = vi.fn();

vi.mock('../../services/api/memoryFreshnessApi', () => ({
loadFreshness: (...args: unknown[]) => mockLoadFreshness(...args),
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
}));

const NOW = 1_700_000_000;

function rel(subject: string, object: string): GraphRelation {
return {
namespace: 'n',
subject,
predicate: 'p',
object,
attrs: {},
updatedAt: NOW,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

const report = computeFreshness([rel('You', 'Berlin')], NOW);

describe('<MemoryFreshnessTab />', () => {
beforeEach(() => {
mockLoadFreshness.mockReset();
mockLoadNamespaces.mockReset();
mockLoadFreshness.mockResolvedValue(report);
mockLoadNamespaces.mockResolvedValue([]);
});

it('loads freshness (all namespaces) on mount and renders the result', async () => {
render(<MemoryFreshnessTab />);
expect(mockLoadFreshness).toHaveBeenCalledTimes(1);
// Called with (nowSeconds, undefined-namespace).
expect(mockLoadFreshness.mock.calls[0][1]).toBeUndefined();
await waitFor(() => expect(screen.getByText('Re-confirm queue')).toBeInTheDocument());
});

it('shows the namespace selector and re-queries on change', async () => {
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
render(<MemoryFreshnessTab />);
await waitFor(() => screen.getByRole('combobox'));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
await waitFor(() => expect(mockLoadFreshness).toHaveBeenCalledTimes(2));
expect(mockLoadFreshness.mock.calls[1][1]).toBe('work');
});

it('surfaces an error when the load fails', async () => {
mockLoadFreshness.mockReset();
mockLoadFreshness.mockRejectedValueOnce(new Error('graph unavailable'));
render(<MemoryFreshnessTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
Loading
Loading