diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 7b7278c8..653dea16 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -9,6 +9,7 @@ import { ClaimsView } from './views/ClaimsView' import { DashboardView } from './views/DashboardView' import { PendingView } from './views/PendingView' import { ReviewView } from './views/ReviewView' +import { SessionsView } from './views/SessionsView' import { StatsView } from './views/StatsView' export default function App() { @@ -25,6 +26,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/webapp/src/components/Shell.test.tsx b/webapp/src/components/Shell.test.tsx index d4d34ba4..e1b6f040 100644 --- a/webapp/src/components/Shell.test.tsx +++ b/webapp/src/components/Shell.test.tsx @@ -40,6 +40,7 @@ test('shows nav, endpoint pill, and outlet content when connected', async () => expect(screen.getByRole('link', { name: /chat/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /review/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /browse/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /sessions/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /stats/i })).toBeInTheDocument() expect(screen.getByText('home content')).toBeInTheDocument() await waitFor(() => expect(screen.getByText('127.0.0.1:8731')).toBeInTheDocument()) diff --git a/webapp/src/components/Shell.tsx b/webapp/src/components/Shell.tsx index 81a8341a..719cd575 100644 --- a/webapp/src/components/Shell.tsx +++ b/webapp/src/components/Shell.tsx @@ -1,4 +1,4 @@ -import { Activity, BadgeCheck, FileClock, Inbox, LayoutDashboard, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' +import { Activity, BadgeCheck, FileClock, History, Inbox, LayoutDashboard, Library, MessageSquare, Plug, SunMoon } from 'lucide-react' import { useEffect, useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { ConnectDialog } from '../connection/ConnectDialog' @@ -16,6 +16,7 @@ const NAV = [ { to: '/pending', label: 'Pending', icon: Inbox }, { to: '/claims', label: 'Claims', icon: BadgeCheck }, { to: '/browse', label: 'Browse', icon: Library }, + { to: '/sessions', label: 'Sessions', icon: History }, { to: '/stats', label: 'Stats', icon: Activity }, ] @@ -25,6 +26,7 @@ const TITLES: Record = { '/pending': 'Pending review', '/claims': 'Approved claims', '/browse': 'Knowledge', + '/sessions': 'Sessions — compiled conversation history', '/dashboard': 'Dashboard — KB activity', '/stats': 'Stats & health', } diff --git a/webapp/src/views/SessionsView.test.tsx b/webapp/src/views/SessionsView.test.tsx new file mode 100644 index 00000000..35fdd82f --- /dev/null +++ b/webapp/src/views/SessionsView.test.tsx @@ -0,0 +1,120 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc } from '../lib/rpc' +import { Route, Routes } from 'react-router-dom' +import { renderWithProviders, seedConnection } from '../test/utils' +import { SessionsView } from './SessionsView' + +const CAPS = { name: 'vouch', level: 3, methods: ['kb.list_pages'], review_gated: true } + +const SESSION_PAGES = [ + { + id: 'session-fix-the-latin1-locale-crash', + title: 'session: fix the latin-1 locale crash', + body: '# session\n…', + type: 'session', + status: 'draft', + created_at: '2026-07-20T09:00:00Z', + metadata: { + session_id: 'locale-fix', + enrich_summary: 'Fixed the stdio crash by reconfiguring utf-8 at cli import.', + subjects: [{ name: 'locale-crash', type: 'decision', description: 'the crash and its fix' }], + }, + }, + { + id: 'session-compare-ditto-with-vouch', + title: 'session: compare ditto with vouch', + body: '# session\n…', + type: 'session', + status: 'draft', + created_at: '2026-07-27T07:48:05Z', + metadata: { + session_id: 'demo-compare', + enrich_summary: 'Deep-dived ditto as a competitor and built VouchBench.', + subjects: [ + { name: 'Ditto', type: 'project', description: 'competitor memory network' }, + { name: 'VouchBench', type: 'project', description: 'benchmark answer' }, + ], + }, + }, +] + +function renderSessions() { + return renderWithProviders( + + } /> + browse probe

} /> +
, + { route: '/sessions' }, + ) +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + vi.mocked(rpc).mockImplementation(async (_c, method, params) => { + if (method === 'kb.list_pages' && (params as { type?: string })?.type === 'session') + return SESSION_PAGES + throw new Error(`unexpected ${method} ${JSON.stringify(params)}`) + }) + seedConnection() +}) + +test('lists compiled session cards newest first, with summary and date', async () => { + renderSessions() + const cards = await screen.findAllByTestId('session-card') + expect(cards).toHaveLength(2) + expect(cards[0]).toHaveTextContent('session: compare ditto with vouch') + expect(cards[0]).toHaveTextContent('2026-07-27') + expect(cards[0]).toHaveTextContent(/deep-dived ditto as a competitor/i) + expect(cards[1]).toHaveTextContent('session: fix the latin-1 locale crash') +}) + +test('requests only session-type pages from the server', async () => { + renderSessions() + await screen.findAllByTestId('session-card') + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.list_pages', { type: 'session' }) +}) + +test('renders the compiled subjects as chips on each card', async () => { + renderSessions() + await screen.findAllByTestId('session-card') + expect(screen.getByText('Ditto')).toBeInTheDocument() + expect(screen.getByText('VouchBench')).toBeInTheDocument() + expect(screen.getByText('locale-crash')).toBeInTheDocument() +}) + +test('filter narrows cards by title, summary, or subject', async () => { + renderSessions() + await screen.findAllByTestId('session-card') + await userEvent.type(screen.getByPlaceholderText(/filter/i), 'VouchBench') + expect(screen.getAllByTestId('session-card')).toHaveLength(1) + expect(screen.queryByText('session: fix the latin-1 locale crash')).not.toBeInTheDocument() +}) + +test('clicking a card deep-links into the browse page drawer', async () => { + renderSessions() + const cards = await screen.findAllByTestId('session-card') + await userEvent.click(cards[0]) + expect(await screen.findByText('browse probe')).toBeInTheDocument() +}) + +test('empty result shows an instructive empty state', async () => { + vi.mocked(rpc).mockResolvedValue([]) + renderSessions() + expect(await screen.findByText(/no compiled sessions yet/i)).toBeInTheDocument() +}) + +test('un-advertised kb.list_pages renders an unavailable note, not endless loading', async () => { + vi.mocked(fetchCapabilities).mockResolvedValue({ ...CAPS, methods: ['kb.list_claims'] }) + renderSessions() + expect(await screen.findByText(/not available on this endpoint/i)).toBeInTheDocument() +}) diff --git a/webapp/src/views/SessionsView.tsx b/webapp/src/views/SessionsView.tsx new file mode 100644 index 00000000..b920e488 --- /dev/null +++ b/webapp/src/views/SessionsView.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { EmptyState } from '../components/EmptyState' +import { ErrorCard } from '../components/ErrorCard' +import { useErrorToast } from '../components/Toast' +import { useConnection } from '../connection/ConnectionContext' +import type { ProjectState } from '../connection/ConnectionContext' +import { useFanout } from '../lib/fanout' +import type { Page } from '../lib/types' + +/** Shape of the enrich pipeline's metadata on a type=session page. */ +interface SessionSubject { + name: string + type?: string + description?: string +} +interface SessionMeta { + session_id?: string + enrich_summary?: string + subjects?: SessionSubject[] +} + +function meta(page: Page): SessionMeta { + const m = page.metadata + return m && typeof m === 'object' ? (m as SessionMeta) : {} +} + +interface Row { + project: ProjectState + page: Page +} + +export function SessionsView() { + const { aggregated } = useConnection() + const navigate = useNavigate() + const [filter, setFilter] = useState('') + + const result = useFanout(['list', 'session-page'], 'kb.list_pages', { type: 'session' }) + useErrorToast(result.errors.length > 0, result.errors[0]?.error) + + const needle = filter.trim().toLowerCase() + const rows: Row[] = result.rows + .flatMap((r) => r.data.map((page) => ({ project: r.project, page }))) + .filter(({ page }) => { + if (needle === '') return true + const m = meta(page) + return [page.title, page.id, m.enrich_summary ?? '', ...(m.subjects ?? []).map((s) => s.name)] + .some((text) => text.toLowerCase().includes(needle)) + }) + .sort((a, b) => (b.page.created_at ?? '').localeCompare(a.page.created_at ?? '')) + + const openPage = ({ page, project }: Row) => { + const p = aggregated ? `?p=${encodeURIComponent(project.conn.endpoint)}` : '' + navigate(`/browse/page/${encodeURIComponent(page.id)}${p}`) + } + + return ( +
+
+

+ {rows.length} compiled session{rows.length === 1 ? '' : 's'} +

+ setFilter(e.target.value)} + placeholder="filter…" + className="ml-auto w-56 rounded-lg border border-rule bg-paper-2 px-3 py-1.5 text-sm text-ink outline-none placeholder:text-sepia focus:border-accent" + /> +
+ +
+ {result.unavailable && ( + + )} + {!result.unavailable && result.isPending &&

loading…

} + {result.isError && ( + + )} + {!result.unavailable && !result.isPending && !result.isError && rows.length === 0 && ( + + )} + +
    + {rows.map((row) => { + const { project, page } = row + const m = meta(page) + return ( +
  • + +
  • + ) + })} +
+
+
+ ) +}