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/TriadClosurePanel.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 { computeTriadClosure } from '../../lib/memory/triadClosure';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import TriadClosurePanel from './TriadClosurePanel';

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

// Two intermediaries for (A, C) -> support=2, score = 2 / log(3).
const populated = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]);

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

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

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<TriadClosurePanel 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 summary caption, and the suggested-edge worklist with intermediaries', () => {
render(<TriadClosurePanel result={populated} />);
expect(screen.getByText('Suggested edges')).toBeInTheDocument();
expect(screen.getByText('Candidate pairs')).toBeInTheDocument();
expect(screen.getByText('Minimum support')).toBeInTheDocument();
expect(screen.getByText('Suggested edges to consider')).toBeInTheDocument();
// Subject A and object C appear as the suggested edge.
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
// Intermediary chips B and D render alphabetically.
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('D')).toBeInTheDocument();
// Score 2 / log(3) ≈ 1.820 rounds to 3dp as "1.820".
expect(screen.getByText('1.820')).toBeInTheDocument();
});

it('shows the all-filtered caption when every candidate is below minSupport', () => {
// Single intermediary -> support=1 < default minSupport=2 -> all filtered.
const filtered = computeTriadClosure([rel('A', 'B'), rel('B', 'C')]);
render(<TriadClosurePanel result={filtered} />);
expect(screen.getByText(/1 candidate pairs filtered out by support floor/)).toBeInTheDocument();
});

it('shows the no-candidates caption when the graph has no open wedges', () => {
// Single edge -> no wedge possible.
const flat = computeTriadClosure([rel('A', 'B')]);
render(<TriadClosurePanel result={flat} />);
expect(
screen.getByText('No open triads — the graph has no wedges to close.')
).toBeInTheDocument();
});
});
205 changes: 205 additions & 0 deletions app/src/components/intelligence/TriadClosurePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Triad Closure — presentational view. Pure: renders the summary tiles
* (candidates / minSupport / nodes), an empty-state when minSupport filters
* everything out, and a ranked worklist of suggested edges with their
* Adamic-Adar score + intermediaries. No data fetching, no clock, no
* randomness.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { TriadClosureResult } from '../../lib/memory/triadClosure';

const MAX_ROWS = 25;
const MAX_INTERMEDIARIES_SHOWN = 5;

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

const TriadClosurePanel = ({ result, loading, error, onRetry }: TriadClosurePanelProps) => {
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('triadClosure.title')}</p>
<p>{t('triadClosure.intro')}</p>
</div>
);

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

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

const rows = result.hints.slice(0, MAX_ROWS);
const maxScore = rows.reduce((m, h) => (h.score > m ? h.score : m), 0);

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

{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('triadClosure.metricHints'), value: result.hints.length },
{ label: t('triadClosure.metricCandidates'), value: result.candidatePairCount },
{ label: t('triadClosure.metricSupport'), value: `≥${result.minSupport}` },
].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">
{t('triadClosure.summaryCaption')
.replace('{nodes}', String(result.nodeCount))
.replace('{edges}', String(result.edgeCount))}
{result.truncated && (
<span
title={t('triadClosure.truncatedTitle')}
className="ml-2 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{t('triadClosure.truncatedBadge')}
</span>
)}
</p>

{/* Hints worklist */}
{rows.length === 0 ? (
<p className="py-4 text-center text-xs text-stone-500 dark:text-neutral-400">
{result.candidatePairCount === 0
? t('triadClosure.noCandidates')
: t('triadClosure.allFiltered').replace('{count}', String(result.candidatePairCount))}
</p>
) : (
<section aria-labelledby="triad-closure-heading" className="space-y-2">
<h3
id="triad-closure-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('triadClosure.rankedHeading')}
</h3>
<ul className="space-y-2">
{rows.map((row, i) => {
const widthPct =
maxScore === 0 ? 0 : Math.max(0, Math.min(100, (row.score / maxScore) * 100));
const shownIntermediaries = row.intermediaries.slice(0, MAX_INTERMEDIARIES_SHOWN);
const extra = row.intermediaries.length - shownIntermediaries.length;
return (
<li
key={JSON.stringify([row.subject, row.object])}
className="border-t border-stone-100 dark:border-neutral-800/60 pt-2 text-[11px] text-stone-700 dark:text-neutral-200">
<div className="flex items-center gap-2">
<span className="w-6 shrink-0 text-stone-400 dark:text-neutral-500 tabular-nums">
{i + 1}
</span>
<span className="font-medium break-words">{row.subject}</span>
<span aria-hidden="true" className="text-stone-400 dark:text-neutral-500">
</span>
<span className="sr-only">{t('triadClosure.suggestEdgeTo')}</span>
<span className="font-medium break-words">{row.object}</span>
<span className="ml-auto text-[10px] tabular-nums text-stone-500 dark:text-neutral-400">
{row.score.toFixed(3)}
</span>
</div>
<div className="mt-1 ml-8 flex items-center gap-2">
<div className="flex-1 h-1.5 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div className="h-full bg-primary-400/70" style={{ width: `${widthPct}%` }} />
</div>
<span className="text-[10px] tabular-nums text-stone-400 dark:text-neutral-500">
{t('triadClosure.viaPrefix')}
</span>
{shownIntermediaries.map(b => (
<span
key={b}
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 break-words">
{b}
</span>
))}
{extra > 0 && (
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('triadClosure.extraIntermediaries').replace('{n}', String(extra))}
</span>
)}
</div>
</li>
);
})}
</ul>
</section>
)}
</div>
);
};

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

import { computeTriadClosure } from '../../lib/memory/triadClosure';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import TriadClosureTab from './TriadClosureTab';

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

vi.mock('../../services/api/triadClosureApi', () => ({
loadTriadClosure: (...args: unknown[]) => mockLoadClosure(...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 = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]);

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

it('loads hints (all namespaces) on mount and renders the result', async () => {
render(<TriadClosureTab />);
expect(mockLoadClosure).toHaveBeenCalledWith(undefined);
await waitFor(() =>
expect(screen.getByText('Suggested edges to consider')).toBeInTheDocument()
);
});

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

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