Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026 soundminds.ai
//
// SPDX-License-Identifier: Apache-2.0

import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { JudgmentListsTable } from '@/components/judgments/judgment-lists-table';
import type { JudgmentListSummary } from '@/lib/api/judgments';
import type { DataTableUrlStateApi } from '@/hooks/use-data-table-url-state';

vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));

const urlState = {
sort: null,
filters: {},
q: null,
cursor: null,
pageSize: 50,
setSort: vi.fn(),
setFilter: vi.fn(),
setQ: vi.fn(),
setCursor: vi.fn(),
setPageSize: vi.fn(),
clearAllMatchers: vi.fn(),
anyMatcherActive: false,
} as unknown as DataTableUrlStateApi;

const row: JudgmentListSummary = {
id: 'jl-1',
name: 'Prod relevance labels',
status: 'complete',
target: 'products',
cluster_id: 'c-1',
query_set_id: 'qs-1',
created_at: '2026-05-12T00:00:00Z',
description: null,
};

describe('JudgmentListsTable (/judgments index)', () => {
it('links each judgment list name to its detail page', () => {
render(
<JudgmentListsTable
rows={[row]}
totalCount={1}
has_more={false}
next_cursor={null}
isLoading={false}
isError={false}
urlState={urlState}
/>,
);
const link = screen.getByRole('link', { name: 'Prod relevance labels' });
expect(link).toHaveAttribute('href', '/judgments/jl-1');
});

it('renders a helpful empty state when there are no judgment lists', () => {
render(
<JudgmentListsTable
rows={[]}
totalCount={0}
has_more={false}
next_cursor={null}
isLoading={false}
isError={false}
urlState={urlState}
/>,
);
expect(screen.getByText('No judgment lists yet')).toBeInTheDocument();
});
});
33 changes: 33 additions & 0 deletions ui/src/__tests__/hooks/use-document-title.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 soundminds.ai
//
// SPDX-License-Identifier: Apache-2.0

import { render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { useDocumentTitle } from '@/hooks/use-document-title';

function Titled({ title }: { title: string | null }) {
useDocumentTitle(title);
return null;
}

afterEach(() => {
document.title = 'RelyLoop';
});

describe('useDocumentTitle', () => {
it('sets "<title> · RelyLoop" and restores the previous title on unmount', () => {
document.title = 'RelyLoop';
const { unmount } = render(<Titled title="Studies" />);
expect(document.title).toBe('Studies · RelyLoop');
unmount();
expect(document.title).toBe('RelyLoop');
});

it('leaves the title untouched when passed null (e.g. entity still loading)', () => {
document.title = 'RelyLoop';
render(<Titled title={null} />);
expect(document.title).toBe('RelyLoop');
});
});
2 changes: 2 additions & 0 deletions ui/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { CursorPaginator } from '@/components/common/cursor-paginator';
import { EmptyState } from '@/components/common/empty-state';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useConversations, useCreateConversation } from '@/lib/api/conversations';

function ChatPageInner() {
useDocumentTitle('Chat');
const router = useRouter();
const [pageSize, setPageSize] = useState(50);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
Expand Down
7 changes: 6 additions & 1 deletion ui/src/app/clusters/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export function ClusterDetailView({ clusterId }: { clusterId: string }) {
← All clusters
</Link>
</div>
<DetailPageShell query={query} entityLabel="cluster" notFoundErrorCode="CLUSTER_NOT_FOUND">
<DetailPageShell
query={query}
entityLabel="cluster"
notFoundErrorCode="CLUSTER_NOT_FOUND"
documentTitle={(c) => c.name}
>
{(cluster) => (
<>
<ClusterDetailSummary cluster={cluster} />
Expand Down
2 changes: 2 additions & 0 deletions ui/src/app/clusters/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { RegisterClusterModal } from '@/components/clusters/register-cluster-mod
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableUrlState } from '@/hooks/use-data-table-url-state';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useClusters } from '@/lib/api/clusters';

function ClustersPageInner() {
useDocumentTitle('Clusters');
const urlState = useDataTableUrlState('clusters', clustersColumns, { defaultPageSize: 50 });
const [registerOpen, setRegisterOpen] = useState(false);

Expand Down
1 change: 1 addition & 0 deletions ui/src/app/judgments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function JudgmentListView({ listId }: { listId: string }) {
query={list}
entityLabel="judgment list"
notFoundErrorCode="JUDGMENT_LIST_NOT_FOUND"
documentTitle={(l) => l.name}
>
{(listData) => (
<>
Expand Down
58 changes: 58 additions & 0 deletions ui/src/app/judgments/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 soundminds.ai
//
// SPDX-License-Identifier: Apache-2.0

'use client';
import { Suspense } from 'react';

import { JudgmentListsTable } from '@/components/judgments/judgment-lists-table';
import { judgmentListsColumns } from '@/components/judgments/judgment-lists-table.column-config';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableUrlState } from '@/hooks/use-data-table-url-state';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useJudgmentLists } from '@/lib/api/judgments';

function JudgmentsPageInner() {
useDocumentTitle('Judgments');
const urlState = useDataTableUrlState('judgment-lists', judgmentListsColumns, {
defaultPageSize: 50,
});

const query = useJudgmentLists({
cursor: urlState.cursor ?? undefined,
limit: urlState.pageSize,
});

return (
<main className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Judgments</h1>
<p className="mt-1 text-sm text-muted-foreground">
Judgment lists are the graded query→document relevance labels a study optimizes against.
Generate one from a query set (LLM-as-judge) or from UBI click data.
</p>
</div>
<Card>
<CardContent className="pt-6">
<JudgmentListsTable
rows={query.data?.data ?? []}
totalCount={query.data?.totalCount}
has_more={query.data?.has_more ?? false}
next_cursor={query.data?.next_cursor ?? null}
isLoading={query.isPending}
isError={query.isError}
urlState={urlState}
/>
</CardContent>
</Card>
</main>
);
}

export default function JudgmentsPage() {
return (
<Suspense fallback={<main className="mx-auto max-w-7xl p-6">Loading…</main>}>
<JudgmentsPageInner />
</Suspense>
);
}
4 changes: 3 additions & 1 deletion ui/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export default function DashboardPage() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">
Recent activity across studies, proposals, and judgments.
RelyLoop automatically tunes your search relevance and ships the winning config as a pull
request. Register a cluster, generate judgments, then run a study — the checklist below
walks you through your first one.
</p>
</div>
{allFailed ? (
Expand Down
1 change: 1 addition & 0 deletions ui/src/app/proposals/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export function ProposalDetailView({ proposalId }: { proposalId: string }) {
query={proposalQ}
entityLabel="proposal"
notFoundErrorCode="PROPOSAL_NOT_FOUND"
documentTitle={(p) => `Proposal · ${p.cluster.name}`}
>
{(proposal) => (
<>
Expand Down
2 changes: 2 additions & 0 deletions ui/src/app/proposals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { proposalsColumns } from '@/components/proposals/proposals-table.column-
import { ShowSupersededFilterChip } from '@/components/proposals/show-superseded-filter-chip';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableUrlState } from '@/hooks/use-data-table-url-state';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useProposals } from '@/lib/api/proposals';
import { PROPOSAL_STATUS_VALUES, type ProposalStatus } from '@/lib/enums';

function ProposalsPageInner() {
useDocumentTitle('Proposals');
const urlState = useDataTableUrlState('proposals', proposalsColumns, { defaultPageSize: 50 });

// Validate URL ?status= against the canonical allowlist — invalid values
Expand Down
11 changes: 9 additions & 2 deletions ui/src/app/query-sets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,22 @@ export function QuerySetDetailView({ querySetId }: { querySetId: string }) {
query={query}
entityLabel="query set"
notFoundErrorCode="QUERY_SET_NOT_FOUND"
documentTitle={(qs) => qs.name}
>
{(querySet) => (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{querySet.name}</h1>
<p className="text-sm text-muted-foreground">
Cluster <span className="font-mono">{querySet.cluster_id}</span> ·{' '}
{querySet.query_count.toLocaleString()} queries
Cluster{' '}
<Link
href={`/clusters/${querySet.cluster_id}`}
className="font-mono text-blue-600 underline-offset-4 hover:underline"
>
{querySet.cluster_id}
</Link>{' '}
· {querySet.query_count.toLocaleString()} queries
</p>
</div>
<Button onClick={() => setAddQueriesOpen(true)} data-testid="open-add-queries">
Expand Down
7 changes: 6 additions & 1 deletion ui/src/app/studies/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ export function StudyDetailView({ studyId }: { studyId: string }) {
← All studies
</Link>
</div>
<DetailPageShell query={studyQ} entityLabel="study" notFoundErrorCode="STUDY_NOT_FOUND">
<DetailPageShell
query={studyQ}
entityLabel="study"
notFoundErrorCode="STUDY_NOT_FOUND"
documentTitle={(s) => s.name}
>
{(study) => (
<>
<div className="flex items-center justify-between">
Expand Down
2 changes: 2 additions & 0 deletions ui/src/app/studies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import { RecentChainsCard } from '@/components/studies/recent-chains-card';
import { StudiesTable } from '@/components/studies/studies-table';
import { studiesColumns } from '@/components/studies/studies-table.column-config';
import { useDataTableUrlState } from '@/hooks/use-data-table-url-state';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useStudies, useStudy } from '@/lib/api/studies';

function StudiesPageInner() {
useDocumentTitle('Studies');
const urlState = useDataTableUrlState('studies', studiesColumns, { defaultPageSize: 50 });
const router = useRouter();
const searchParams = useSearchParams();
Expand Down
7 changes: 6 additions & 1 deletion ui/src/app/templates/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export default function TemplateDetailPage({ params }: RouteProps) {
← All templates
</Link>
</div>
<DetailPageShell query={query} entityLabel="template" notFoundErrorCode="TEMPLATE_NOT_FOUND">
<DetailPageShell
query={query}
entityLabel="template"
notFoundErrorCode="TEMPLATE_NOT_FOUND"
documentTitle={(t) => t.name}
>
{(template) => (
<>
<div className="flex items-center justify-between">
Expand Down
2 changes: 2 additions & 0 deletions ui/src/app/templates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { templatesColumns } from '@/components/templates/templates-table.column-
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableUrlState } from '@/hooks/use-data-table-url-state';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useTemplates } from '@/lib/api/query-templates';

function TemplatesPageInner() {
useDocumentTitle('Templates');
const urlState = useDataTableUrlState('templates', templatesColumns, { defaultPageSize: 50 });
const [createOpen, setCreateOpen] = useState(false);

Expand Down
14 changes: 9 additions & 5 deletions ui/src/components/chat/example-prompts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ import { Button } from '@/components/ui/button';
* copy, so they live in this component rather than `ui/src/lib/glossary.ts`
* (glossary is reserved for tooltip / popover help text).
*/
// Capability-shaped, not entity-named: a fresh (or non-demo) install has no
// "prod-es" cluster or "trial 47", so entity-named chips would send the agent
// after things that don't exist. These describe what the agent can do and let
// it resolve the user's actual data.
const EXAMPLE_PROMPTS: readonly string[] = [
'Tell me about the prod-es cluster',
'Run a study optimizing NDCG@10 for the product-search index',
'Why did trial 47 get pruned?',
'Open a PR for the latest proposal',
'Generate judgments for the e-commerce query set',
'Summarize one of my clusters',
'Run a study to optimize NDCG@10',
'Explain why a trial was pruned',
'Open a PR for my latest proposal',
'Generate judgments for one of my query sets',
];

export interface ExamplePromptsProps {
Expand Down
12 changes: 12 additions & 0 deletions ui/src/components/common/detail-page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { type UseQueryResult } from '@tanstack/react-query';
import { EmptyState } from '@/components/common/empty-state';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { type ApiError } from '@/lib/api-errors';

export interface DetailPageShellProps<T> {
Expand Down Expand Up @@ -100,6 +101,12 @@ export interface DetailPageShellProps<T> {
* `"Refresh after re-launching the API."`.
*/
unreachableMessage?: string;
/**
* Optional: derive the browser `document.title` from the loaded entity
* (e.g. `(study) => study.name`). Applied once data resolves and restored on
* unmount, so tabs/history/bookmarks are named instead of a bare "RelyLoop".
*/
documentTitle?: (data: T) => string;
/**
* Render function invoked with the loaded data. Per Q2's locked
* decision: children-as-function rather than compound component —
Expand All @@ -121,9 +128,14 @@ export function DetailPageShell<T>(props: DetailPageShellProps<T>) {
notFoundErrorCode,
notFoundMessage,
unreachableMessage,
documentTitle,
children,
} = props;

// Hooks must run before the early returns; null title leaves the tab
// untouched until the entity name is known.
useDocumentTitle(query.data && documentTitle ? documentTitle(query.data) : null);

if (query.isPending) {
// Skeleton sized to a typical detail header + body so the layout doesn't
// jump when the real content arrives.
Expand Down
Loading
Loading