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

import { computeGraphBridges } from '../../lib/memory/graphBridges';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphBridgesPanel from './GraphBridgesPanel';

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

// Two triangles joined by a single edge: C and D are articulations; (C,D) is
// the only bridge. articulationCount=2, bridges=[{a:'C',b:'D'}].
const joined = computeGraphBridges([
rel('A', 'B'),
rel('B', 'C'),
rel('C', 'A'),
rel('D', 'E'),
rel('E', 'F'),
rel('F', 'D'),
rel('C', 'D'),
]);

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

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

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<GraphBridgesPanel 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, articulation badges, and the bridge list', () => {
render(<GraphBridgesPanel result={joined} />);
expect(screen.getByText('Entities')).toBeInTheDocument();
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByText('Articulations')).toBeInTheDocument();
expect(screen.getByText('Articulation entities')).toBeInTheDocument();
expect(screen.getByText('Bridge relations')).toBeInTheDocument();
// both articulation badges present.
expect(screen.getAllByText('cut vertex')).toHaveLength(2);
// single bridge row C — D: each id appears in BOTH the articulation table
// row and the bridge list, so use getAllByText with the matching length.
expect(screen.getAllByText('C')).toHaveLength(2);
expect(screen.getAllByText('D')).toHaveLength(2);
// exactly one bridge and one connected component -> the singular-bridge,
// singular-component caption is rendered (no "1 bridges" ungrammar).
expect(screen.getByText('1 bridge · single component')).toBeInTheDocument();
});

it('uses the plural caption when there are 2+ bridges and 2+ components', () => {
// Path A-B-C (2 bridges, 1 component) plus disconnected Y-Z (1 bridge, 2nd
// component). Total: 3 bridges across 2 components -> fully plural caption.
const multi = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('Y', 'Z')]);
render(<GraphBridgesPanel result={multi} />);
expect(screen.getByText('3 bridges · 2 components')).toBeInTheDocument();
});

it('shows the no-fragiles / no-bridges notes for a fully-cyclic graph', () => {
const triangle = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]);
render(<GraphBridgesPanel result={triangle} />);
expect(
screen.getByText(
'No structural single-points-of-failure — every link sits in at least one cycle.'
)
).toBeInTheDocument();
expect(
screen.getByText('No bridges — every relation sits in at least one cycle.')
).toBeInTheDocument();
});
});
224 changes: 224 additions & 0 deletions app/src/components/intelligence/GraphBridgesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* Graph Bridges — presentational view. Pure: renders the cut summary tiles
* (entities / connections / articulations), the articulation entity list, and
* the bridge relations list. No data fetching, no clock, no randomness.
*/
import { useMemo } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import type { BridgeResult } from '../../lib/memory/graphBridges';

const MAX_ROWS = 25;

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

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

const articulationRows = useMemo(
() => (result ? result.nodes.filter(n => n.isArticulation).slice(0, MAX_ROWS) : []),
[result]
);
const bridgeRows = useMemo(() => (result ? result.bridges.slice(0, MAX_ROWS) : []), [result]);
Comment on lines +23 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silent truncation: tables cap at 25 rows while tiles/caption show full counts.

articulationRows/bridgeRows cap at MAX_ROWS (25), but the metric tiles and caption render the full articulationCount / bridges.length. When either set exceeds 25, the lists are truncated with no indicator, so a user could read the tile as "120 articulations" yet only see 25 rows and assume that's the complete set. Consider surfacing a "+N more" / "showing top 25" hint via useT() when the underlying count exceeds what is rendered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/intelligence/GraphBridgesPanel.tsx` around lines 23 - 27,
The lists are silently truncated to MAX_ROWS in articulationRows and bridgeRows
while the metric tiles/caption show full articulationCount / bridges.length;
update the component to compute the overflow (e.g., extraArticulations =
articulationCount - MAX_ROWS and extraBridges = bridges.length - MAX_ROWS) and
pass that to the UI so you render a localized hint (via useT()) like "showing
top 25 (+N more)" or "+N more" next to the list or caption when extra > 0;
change the logic around articulationRows, bridgeRows and the tile/caption render
paths to display this message conditionally whenever the underlying count
exceeds MAX_ROWS.


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

if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('graphBridges.loading')}
data-testid="graph-bridges-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('graphBridges.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('graphBridges.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('graphBridges.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('graphBridges.emptyHint')}
</p>
</div>
</div>
);
}

// Four-way singular/plural switch over (bridges, components) — keeps the
// English caption grammatical for the common single-bridge / single-component
// cases instead of rendering "1 bridges" / "1 components".
const bridgesCount = String(result.bridges.length);
const componentsCount = String(result.componentCount);
const oneBridge = result.bridges.length === 1;
const oneComponent = result.componentCount === 1;
const summaryCaption = oneBridge
? oneComponent
? t('graphBridges.summaryCaptionOneBridgeOneComponent')
: t('graphBridges.summaryCaptionOneBridge').replace('{components}', componentsCount)
: oneComponent
? t('graphBridges.summaryCaptionOne').replace('{bridges}', bridgesCount)
: t('graphBridges.summaryCaption')
.replace('{bridges}', bridgesCount)
.replace('{components}', componentsCount);

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

{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('graphBridges.metricEntities'), value: result.nodeCount },
{ label: t('graphBridges.metricConnections'), value: result.edgeCount },
{ label: t('graphBridges.metricArticulations'), value: result.articulationCount },
].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">{summaryCaption}</p>

{/* Articulation entities */}
<section aria-labelledby="graph-bridges-articulations-heading" className="space-y-1">
<h3
id="graph-bridges-articulations-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphBridges.articulationsHeading')}
</h3>
{articulationRows.length === 0 ? (
<p className="py-2 text-[11px] text-stone-500 dark:text-neutral-400">
{t('graphBridges.noFragiles')}
</p>
) : (
<table
aria-labelledby="graph-bridges-articulations-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('graphBridges.colRank')}
</th>
<th scope="col" className="py-1 pr-2 font-medium">
{t('graphBridges.colEntity')}
</th>
<th scope="col" className="w-12 py-1 text-right font-medium">
{t('graphBridges.colLinks')}
</th>
</tr>
</thead>
<tbody>
{articulationRows.map((node, i) => (
<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}
<span
title={t('graphBridges.articulationTitle')}
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('graphBridges.articulationBadge')}
</span>
</td>
<td className="py-1 text-right text-stone-500 dark:text-neutral-400">
{node.degree}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>

{/* Bridge relations */}
<section aria-labelledby="graph-bridges-edges-heading" className="space-y-1">
<h3
id="graph-bridges-edges-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphBridges.bridgesHeading')}
</h3>
{bridgeRows.length === 0 ? (
<p className="py-2 text-[11px] text-stone-500 dark:text-neutral-400">
{t('graphBridges.noBridges')}
</p>
) : (
<ul className="space-y-1">
{bridgeRows.map(edge => (
<li
key={JSON.stringify([edge.a, edge.b])}
className="border-t border-stone-100 dark:border-neutral-800/60 pt-1 text-[11px] text-stone-700 dark:text-neutral-200 break-words">
<span className="font-medium">{edge.a}</span>
<span className="mx-1.5 text-stone-400 dark:text-neutral-500">—</span>
<span className="font-medium">{edge.b}</span>
</li>
))}
</ul>
)}
</section>
</div>
);
};

export default GraphBridgesPanel;
61 changes: 61 additions & 0 deletions app/src/components/intelligence/GraphBridgesTab.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 { computeGraphBridges } from '../../lib/memory/graphBridges';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import GraphBridgesTab from './GraphBridgesTab';

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

vi.mock('../../services/api/graphBridgesApi', () => ({
loadBridges: (...args: unknown[]) => mockLoadBridges(...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 = computeGraphBridges([rel('A', 'B'), rel('B', 'C')]);

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

it('loads bridges (all namespaces) on mount and renders the result', async () => {
render(<GraphBridgesTab />);
expect(mockLoadBridges).toHaveBeenCalledWith(undefined);
await waitFor(() => expect(screen.getByText('Articulation entities')).toBeInTheDocument());
});

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

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