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

import { computeGraphReach } from '../../lib/memory/graphReach';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphReachPanel from './GraphReachPanel';

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

// Path A-B-C-D: diameter 3, radius 2, center {B,C}.
const path = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'D')]);

describe('<GraphReachPanel />', () => {
it('renders the loading skeleton', () => {
render(<GraphReachPanel result={null} loading />);
expect(screen.getByTestId('graph-reach-loading')).toBeInTheDocument();
});

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

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<GraphReachPanel result={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 metric tiles, the component summary, and the ranked table', () => {
render(<GraphReachPanel result={path} />);
expect(screen.getByText('Entities')).toBeInTheDocument();
expect(screen.getByText('Diameter')).toBeInTheDocument();
expect(screen.getByText('Radius')).toBeInTheDocument();
expect(screen.getByText('Most central entities')).toBeInTheDocument();
// single component holding all four nodes -> singular caption variant.
expect(screen.getByText('1 component · 4 entities')).toBeInTheDocument();
});

it('badges the centers (eccentricity == radius) and not the periphery', () => {
render(<GraphReachPanel result={path} />);
// B and C are the two centers of the path; A and D are not.
expect(screen.getAllByText('center')).toHaveLength(2);
});

it('uses the plural caption when the graph has more than one component', () => {
// Path P-Q-R-S (giant, size 4) plus disjoint edge Y-Z (size 2).
const multi = computeGraphReach([rel('P', 'Q'), rel('Q', 'R'), rel('R', 'S'), rel('Y', 'Z')]);
render(<GraphReachPanel result={multi} />);
expect(screen.getByText('2 components · largest holds 4')).toBeInTheDocument();
});

it('uses the all-singular caption for a single-node component (self-loop-only)', () => {
// The only fact is "Alice→Alice": the engine keeps Alice as a singleton
// (size 1), and the caption renders the all-singular variant — never the
// ungrammatical "1 component · 1 entities".
const lonely = computeGraphReach([rel('Alice', 'Alice')]);
render(<GraphReachPanel result={lonely} />);
expect(screen.getByText('1 component · 1 entity')).toBeInTheDocument();
});
});
215 changes: 215 additions & 0 deletions app/src/components/intelligence/GraphReachPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Graph Reach — presentational view. Pure: renders the reach summary tiles
* (entities / diameter / radius), the component summary, and a ranked table of
* the most-central entities. No data fetching, no clock, no randomness.
*/
import { useMemo } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import type { ReachResult } from '../../lib/memory/graphReach';

const MAX_ROWS = 25;

interface GraphReachPanelProps {
result: ReachResult | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}

const GraphReachPanel = ({ result, loading, error, onRetry }: GraphReachPanelProps) => {
const { t } = useT();

// Per-component diameter so each row's eccentricity bar is relative to its
// OWN component, not the giant component's diameter (which would let a node
// in a smaller-but-longer component render >100% width).
const componentDiameter = useMemo(() => {
const map = new Map<number, number>();
if (result) {
for (const c of result.components) map.set(c.id, c.diameter);
}
return map;
}, [result]);

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('graphReach.title')}</p>
<p>{t('graphReach.intro')}</p>
</div>
);

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

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

const rows = result.nodes.slice(0, MAX_ROWS);

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

{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('graphReach.metricEntities'), value: result.nodeCount },
{ label: t('graphReach.metricDiameter'), value: result.diameter },
{ label: t('graphReach.metricRadius'), value: result.radius },
].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">
{result.componentCount === 1
? result.giantComponentSize === 1
? t('graphReach.summaryCaptionOneAndOne')
: t('graphReach.summaryCaptionOne').replace(
'{giant}',
String(result.giantComponentSize)
)
: t('graphReach.summaryCaption')
.replace('{components}', String(result.componentCount))
.replace('{giant}', String(result.giantComponentSize))}
</p>

{/* Ranked most-central entities */}
<section aria-labelledby="graph-reach-heading" className="space-y-1">
<h3
id="graph-reach-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphReach.rankedHeading')}
</h3>
<table
aria-labelledby="graph-reach-heading"
className="w-full text-left text-[11px] tabular-nums">
<thead className="text-stone-400 dark:text-neutral-500">
<tr>
<th scope="col" className="w-8 py-1 pr-2 font-medium">
{t('graphReach.colRank')}
</th>
<th scope="col" className="py-1 pr-2 font-medium">
{t('graphReach.colEntity')}
</th>
<th scope="col" className="w-1/3 py-1 pr-2 font-medium">
{t('graphReach.colEccentricity')}
</th>
<th scope="col" className="w-12 py-1 text-right font-medium">
{t('graphReach.colLinks')}
</th>
</tr>
</thead>
<tbody>
{rows.map((node, i) => {
const localDiameter = componentDiameter.get(node.componentId) ?? 0;
const barWidth = localDiameter === 0 ? 0 : (node.eccentricity / localDiameter) * 100;
return (
<tr key={node.id} className="border-t border-stone-100 dark:border-neutral-800/60">
<td className="py-1 pr-2 text-stone-400 dark:text-neutral-500">{i + 1}</td>
<td className="py-1 pr-2 text-stone-800 dark:text-neutral-100 break-words">
{node.id}
{node.isCenter && (
<span
title={t('graphReach.centerTitle')}
className="ml-1.5 inline-flex items-center rounded px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{t('graphReach.centerBadge')}
</span>
)}
</td>
<td className="py-1 pr-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${barWidth}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{node.eccentricity}
</span>
</div>
</td>
<td className="py-1 text-right text-stone-500 dark:text-neutral-400">
{node.degree}
</td>
</tr>
);
})}
</tbody>
</table>
</section>
</div>
);
};

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

import { computeGraphReach } from '../../lib/memory/graphReach';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphReachTab from './GraphReachTab';

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

vi.mock('../../services/api/graphReachApi', () => ({
loadReach: (...args: unknown[]) => mockLoadReach(...args),
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
}));

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

const result = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'D')]);

describe('<GraphReachTab />', () => {
beforeEach(() => {
mockLoadReach.mockReset();
mockLoadNamespaces.mockReset();
mockLoadReach.mockResolvedValue(result);
mockLoadNamespaces.mockResolvedValue([]);
});

it('loads reach (all namespaces) on mount and renders the result', async () => {
render(<GraphReachTab />);
expect(mockLoadReach).toHaveBeenCalledWith(undefined);
await waitFor(() => expect(screen.getByText('Most central entities')).toBeInTheDocument());
});

it('shows the namespace selector and re-queries on change', async () => {
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
render(<GraphReachTab />);
await waitFor(() => screen.getByRole('combobox'));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
await waitFor(() => expect(mockLoadReach).toHaveBeenCalledWith('work'));
});

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