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

import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import NamespaceOverviewPanel from './NamespaceOverviewPanel';

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

const report = computeNamespaceOverview([
rel('work', 'A', 'B'),
rel('work', 'B', 'C'),
rel(null, 'P', 'Q'),
]);

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

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

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<NamespaceOverviewPanel 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 summary tiles and the per-namespace list (un-namespaced labeled)', () => {
render(<NamespaceOverviewPanel report={report} />);
expect(screen.getByText('Namespaces')).toBeInTheDocument();
expect(screen.getByText('Facts')).toBeInTheDocument();
expect(screen.getByText('By namespace')).toBeInTheDocument();
expect(screen.getByText('work')).toBeInTheDocument();
// the null namespace renders with the "un-namespaced" label
expect(screen.getByText('(un-namespaced)')).toBeInTheDocument();
});
});
186 changes: 186 additions & 0 deletions app/src/components/intelligence/NamespaceOverviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Namespace Overview — presentational view. Pure: renders per-namespace fact /
* entity counts as a ranked bar list + summary tiles. No data fetching, no
* clock, no RNG.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview';

const MAX_ROWS = 50;

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

const NamespaceOverviewPanel = ({
report,
loading,
error,
onRetry,
}: NamespaceOverviewPanelProps) => {
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('namespaceOverview.title')}</p>
<p>{t('namespaceOverview.intro')}</p>
</div>
);

if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('namespaceOverview.loading')}
data-testid="namespace-overview-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].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-6"
/>
))}
</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('namespaceOverview.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('namespaceOverview.retry')}
</button>
)}
</div>
</div>
);
}

if (!report || report.namespaceCount === 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('namespaceOverview.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('namespaceOverview.emptyHint')}
</p>
</div>
</div>
);
}

const maxFacts = report.namespaces[0]?.factCount || 1;
const rows = report.namespaces.slice(0, MAX_ROWS);
const truncated = report.namespaces.length > MAX_ROWS;

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

{/* Summary tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('namespaceOverview.metricNamespaces'), value: report.namespaceCount },
{ label: t('namespaceOverview.metricFacts'), value: report.totalFacts },
{ label: t('namespaceOverview.metricEntities'), value: report.totalEntities },
].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>

{/* Ranked namespace list */}
<section aria-labelledby="namespace-overview-heading" className="space-y-1">
<h3
id="namespace-overview-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('namespaceOverview.heading')}
</h3>
<ul className="space-y-1">
{rows.map(stat => (
<li
key={JSON.stringify(stat.namespace)}
className="flex items-center gap-2 text-[11px] tabular-nums">
<span
className={`w-28 shrink-0 truncate ${
stat.namespace === null
? 'italic text-stone-400 dark:text-neutral-500'
: 'text-stone-700 dark:text-neutral-200'
}`}
title={stat.namespace ?? t('namespaceOverview.unnamespaced')}>
{stat.namespace ?? t('namespaceOverview.unnamespaced')}
</span>
<div className="flex-1 h-3 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${(stat.factCount / maxFacts) * 100}%` }}
/>
</div>
<span
className="w-16 shrink-0 text-right text-stone-500 dark:text-neutral-400"
title={t('namespaceOverview.factsLabel').replace(
'{count}',
String(stat.factCount)
)}>
{stat.factCount}
</span>
<span
className="w-16 shrink-0 text-right text-stone-400 dark:text-neutral-500"
title={t('namespaceOverview.entitiesLabel').replace(
'{count}',
String(stat.entityCount)
)}>
{t('namespaceOverview.entitiesShort').replace('{count}', String(stat.entityCount))}
</span>
</li>
))}
</ul>
{truncated && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('namespaceOverview.truncated')
.replace('{shown}', String(rows.length))
.replace('{total}', String(report.namespaces.length))}
</p>
)}
</section>
</div>
);
};

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

import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import NamespaceOverviewTab from './NamespaceOverviewTab';

const mockLoad = vi.fn();

vi.mock('../../services/api/namespaceOverviewApi', () => ({
loadNamespaceOverview: (...args: unknown[]) => mockLoad(...args),
}));

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

const report = computeNamespaceOverview([rel('work', 'A', 'B')]);

describe('<NamespaceOverviewTab />', () => {
beforeEach(() => {
mockLoad.mockReset();
mockLoad.mockResolvedValue(report);
});

it('loads on mount and renders the per-namespace list', async () => {
render(<NamespaceOverviewTab />);
await waitFor(() => expect(screen.getByText('By namespace')).toBeInTheDocument());
expect(mockLoad).toHaveBeenCalledTimes(1);
});

it('surfaces an error when the load fails', async () => {
mockLoad.mockReset();
mockLoad.mockRejectedValueOnce(new Error('graph unavailable'));
render(<NamespaceOverviewTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
49 changes: 49 additions & 0 deletions app/src/components/intelligence/NamespaceOverviewTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Namespace Overview tab (container). Loads the whole graph on mount and
* delegates rendering to the pure <NamespaceOverviewPanel>. Read-only. No
* namespace selector — this view's axis IS the namespace, so it shows them all.
*/
import { useCallback, useEffect, useRef, useState } from 'react';

import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview';
import { loadNamespaceOverview } from '../../services/api/namespaceOverviewApi';
import NamespaceOverviewPanel from './NamespaceOverviewPanel';

const NamespaceOverviewTab = () => {
const [report, setReport] = useState<NamespaceOverviewReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Monotonic token: ignore a response if a newer load has since started.
const latestRequestId = useRef(0);

const load = useCallback(async () => {
const requestId = (latestRequestId.current += 1);
setLoading(true);
setError(null);
try {
const next = await loadNamespaceOverview();
if (requestId !== latestRequestId.current) return;
setReport(next);
} catch (err) {
if (requestId !== latestRequestId.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (requestId === latestRequestId.current) setLoading(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

return (
<NamespaceOverviewPanel
report={report}
loading={loading}
error={error}
onRetry={() => void load()}
/>
);
};

export default NamespaceOverviewTab;
18 changes: 18 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ const messages: TranslationMap = {
'memory.tab.calls': 'المكالمات',
'memory.tab.diagram': 'Diagram',
'memory.tab.centrality': 'Centrality',
'memory.tab.namespaces': 'مساحات الأسماء',
'memory.tab.timeline': 'Timeline',
'memory.tab.settings': 'الإعدادات',
'memory.analyzeNow': 'تحليل الآن',
Expand All @@ -296,6 +297,23 @@ const messages: TranslationMap = {
'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})',
'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.',
'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.',
'namespaceOverview.title': 'نظرة عامة على مساحات الأسماء',
'namespaceOverview.intro':
'كيفية توزيع معرفتك عبر السياقات — عدد الحقائق والكيانات المتميزة المسجَّلة في كل مساحة اسم.',
'namespaceOverview.loading': 'تجميع مساحات الأسماء…',
'namespaceOverview.errorPrefix': 'لا يمكن تحميل الرسم البياني:',
'namespaceOverview.retry': 'إعادة المحاولة',
'namespaceOverview.empty': 'لا يوجد رسم بياني للمعرفة بعد.',
'namespaceOverview.emptyHint': 'عندما يسجّل المساعد حقائق عبر السياقات، ستظهر كل مساحة اسم هنا.',
'namespaceOverview.metricNamespaces': 'مساحات الأسماء',
'namespaceOverview.metricFacts': 'الحقائق',
'namespaceOverview.metricEntities': 'الكيانات',
'namespaceOverview.heading': 'حسب مساحة الاسم',
'namespaceOverview.unnamespaced': '(بدون مساحة اسم)',
'namespaceOverview.factsLabel': '{count} حقيقة',
'namespaceOverview.entitiesLabel': '{count} كيان',
'namespaceOverview.entitiesShort': '{count} كيان',
'namespaceOverview.truncated': 'عرض أعلى {shown} من أصل {total} مساحة اسم.',
'graphCentrality.title': 'مركز المعرفة',
'graphCentrality.intro':
'ويظهر الإرسال على صور ذاكرتك مراكز الحمل - والكيانات الموصلة التي تربط المجموعات المنفصلة عن بعضها البعض، والتي لا يمكن لإحصاء الترددات الخام أن يكشف عنها.',
Expand Down
Loading
Loading