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

import { computeKnowledgeGaps } from '../../lib/memory/knowledgeGaps';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import KnowledgeGapsPanel from './KnowledgeGapsPanel';

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

const report = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('D', 'D')]);

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

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

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<KnowledgeGapsPanel 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 gap list with kind badges', () => {
render(<KnowledgeGapsPanel report={report} />);
expect(screen.getByText('Entities')).toBeInTheDocument();
expect(screen.getByText('Orphans')).toBeInTheDocument();
expect(screen.getByText('Leaves')).toBeInTheDocument();
expect(screen.getByText('Sparse entities')).toBeInTheDocument();
expect(screen.getByText('D')).toBeInTheDocument(); // orphan
expect(screen.getByText('orphan')).toBeInTheDocument();
expect(screen.getAllByText('leaf').length).toBeGreaterThanOrEqual(1);
});

it('renders the all-connected message when there are no gaps', () => {
const clean = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]);
render(<KnowledgeGapsPanel report={clean} />);
expect(screen.getByText('Every entity is well-connected — no gaps found.')).toBeInTheDocument();
});
});
177 changes: 177 additions & 0 deletions app/src/components/intelligence/KnowledgeGapsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Knowledge Gaps — presentational view. Pure: renders the orphan/leaf stub list
* + summary tiles. No data fetching, no clock, no RNG.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { GapEntity, KnowledgeGapsReport } from '../../lib/memory/knowledgeGaps';

const MAX_ROWS = 50;

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

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

const KIND_BADGE: Record<GapEntity['kind'], string> = {
orphan: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300',
leaf: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
};

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

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

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

const rows = report.gaps.slice(0, MAX_ROWS);
const truncated = report.gaps.length > MAX_ROWS;

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

{/* Summary tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('knowledgeGaps.metricEntities'), value: String(report.entityCount) },
{ label: t('knowledgeGaps.metricOrphans'), value: String(report.orphanCount) },
{ label: t('knowledgeGaps.metricLeaves'), value: String(report.leafCount) },
].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('knowledgeGaps.ratioCaption').replace('{ratio}', String(pct(report.gapRatio)))}
</p>

{report.gaps.length === 0 ? (
<p className="py-4 text-center text-sm text-sage-700 dark:text-sage-300">
{t('knowledgeGaps.allConnected')}
</p>
) : (
<section aria-labelledby="knowledge-gaps-heading" className="space-y-1.5">
<h3
id="knowledge-gaps-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('knowledgeGaps.heading')}
</h3>
<ul className="space-y-1.5">
{rows.map(gap => (
<li
key={gap.id}
className="flex items-center justify-between gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
<span className="min-w-0 break-words text-sm text-stone-800 dark:text-neutral-100">
{gap.id}
{gap.objectOnly && (
<span className="ml-2 text-[11px] text-stone-400 dark:text-neutral-500">
{t('knowledgeGaps.objectOnly')}
</span>
)}
</span>
<span
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${KIND_BADGE[gap.kind]}`}>
{gap.kind === 'orphan'
? t('knowledgeGaps.kindOrphan')
: t('knowledgeGaps.kindLeaf')}
</span>
</li>
))}
</ul>
{truncated && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('knowledgeGaps.truncated')
.replace('{shown}', String(rows.length))
.replace('{total}', String(report.gaps.length))}
</p>
)}
</section>
)}
</div>
);
};

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

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

vi.mock('../../services/api/knowledgeGapsApi', () => ({
loadKnowledgeGaps: (...args: unknown[]) => mockLoad(...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 report = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C')]);

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

it('loads on mount and renders the gap list', async () => {
render(<KnowledgeGapsTab />);
await waitFor(() => expect(screen.getByText('Sparse entities')).toBeInTheDocument());
Comment on lines +41 to +43

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

Make the mount-call assertion async to avoid timing flakes.

loadKnowledgeGaps is triggered from an effect, so asserting it immediately after render can race in some runs. Wrap it in waitFor.

Suggested fix
   it('loads on mount and renders the gap list', async () => {
     render(<KnowledgeGapsTab />);
-    expect(mockLoad).toHaveBeenCalledWith(undefined);
+    await waitFor(() => expect(mockLoad).toHaveBeenCalledWith(undefined));
     await waitFor(() => expect(screen.getByText('Sparse entities')).toBeInTheDocument());
   });
As per coding guidelines, “keep tests deterministic with no real network calls or time-sensitive flakes.”
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('loads on mount and renders the gap list', async () => {
render(<KnowledgeGapsTab />);
expect(mockLoad).toHaveBeenCalledWith(undefined);
await waitFor(() => expect(screen.getByText('Sparse entities')).toBeInTheDocument());
it('loads on mount and renders the gap list', async () => {
render(<KnowledgeGapsTab />);
await waitFor(() => expect(mockLoad).toHaveBeenCalledWith(undefined));
await waitFor(() => expect(screen.getByText('Sparse entities')).toBeInTheDocument());
});
🤖 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/KnowledgeGapsTab.test.tsx` around lines 41 -
44, The test currently asserts mockLoad was called immediately after render
which can race because loadKnowledgeGaps is invoked in an effect; change the
assertion to be async by awaiting a waitFor that checks mockLoad was called with
undefined (e.g., await waitFor(() =>
expect(mockLoad).toHaveBeenCalledWith(undefined))); keep the subsequent waitFor
for the rendered text and reference KnowledgeGapsTab, mockLoad, and
loadKnowledgeGaps when locating the code to update.

expect(mockLoad).toHaveBeenCalledWith(undefined);
});

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

it('surfaces an error when the load fails', async () => {
mockLoad.mockReset();
mockLoad.mockRejectedValueOnce(new Error('graph unavailable'));
render(<KnowledgeGapsTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
79 changes: 79 additions & 0 deletions app/src/components/intelligence/KnowledgeGapsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Knowledge Gaps tab (container). Load-on-mount + namespace selector; delegates
* rendering to the pure <KnowledgeGapsPanel>. Read-only.
*/
import { useCallback, useEffect, useRef, useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import type { KnowledgeGapsReport } from '../../lib/memory/knowledgeGaps';
import { loadKnowledgeGaps, loadNamespaces } from '../../services/api/knowledgeGapsApi';
import KnowledgeGapsPanel from './KnowledgeGapsPanel';

const KnowledgeGapsTab = () => {
const { t } = useT();
const [report, setReport] = useState<KnowledgeGapsReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [namespace, setNamespace] = useState('');
// Monotonic token: ignore a response if a newer load has since started.
const latestRequestId = useRef(0);

const load = useCallback(async (ns: string) => {
const requestId = (latestRequestId.current += 1);
setLoading(true);
setError(null);
try {
const next = await loadKnowledgeGaps(ns || undefined);
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(() => {
loadNamespaces()
.then(setNamespaces)
.catch(() => setNamespaces([]));
void load('');
}, [load]);

const handleNamespace = (next: string): void => {
setNamespace(next);
void load(next);
};

return (
<div className="space-y-4">
{namespaces.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('knowledgeGaps.namespaceLabel')}
<select
value={namespace}
onChange={e => handleNamespace(e.target.value)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm text-stone-800 dark:text-neutral-100">
<option value="">{t('knowledgeGaps.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}

<KnowledgeGapsPanel
report={report}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};

export default KnowledgeGapsTab;
Loading
Loading