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 (
+ -
+
+
+ )
+ })}
+
+
+
+ )
+}