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
133 changes: 97 additions & 36 deletions apps/desktop/src/pages/Rubrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {
saveReviewConfig,
type StandardsPack,
} from '@/lib/review-service';
import { getStandardsPackUsage, isTauriAvailable } from '@/lib/tauri-ipc';
import {
getRubricSettings,
isTauriAvailable,
type RubricSettingsReceipt,
saveRubricPack,
setActiveRubricPack,
} from '@/lib/tauri-ipc';

function fallbackConfig(): ReviewConfig {
return {
Expand Down Expand Up @@ -77,47 +83,76 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
const [usage, setUsage] = useState<Record<string, PackUsage>>({});
const [expandedPreview, setExpandedPreview] = useState<string | null>(null);
const [copiedPreview, setCopiedPreview] = useState<string | null>(null);
const [syncIssue, setSyncIssue] = useState<string | null>(null);

const packs = getStandardsPacks(config);
const activePack = getActiveStandardsPack(config);
const customRules = config.customRules ?? [];

// Usage is keyed by pack NAME (the value persisted on each review), not id.
useEffect(() => {
if (!isTauriAvailable()) return;
let cancelled = false;
getStandardsPackUsage()
.then((rows) => {
getRubricSettings(loadReviewConfig())
.then((receipt) => {
if (cancelled) return;
const map: Record<string, PackUsage> = {};
for (const row of rows) {
map[row.standards_pack] = {
reviewCount: row.review_count,
totalFindings: row.total_findings,
};
}
setUsage(map);
applyCanonicalReceipt(receipt);
})
.catch(() => {
// Non-fatal — packs simply show "no usage yet".
.catch((error) => {
if (cancelled) return;
setSyncIssue(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, []);

function persist(next: ReviewConfig) {
function applyCanonicalReceipt(receipt: RubricSettingsReceipt) {
const standardsPacks = receipt.packs
.filter((pack) => !pack.built_in)
.map(({ id, name, focus, checks }) => ({ id, name, focus, checks }));
const persisted: ReviewConfig = {
customRules: receipt.custom_rules,
standardsPacks,
...(receipt.active_pack_id ? { activeStandardsPack: receipt.active_pack_id } : {}),
};
saveReviewConfig(persisted);
setConfig({
...persisted,
activeStandardsPack: receipt.active_pack_id ?? DEFAULT_STANDARDS_PACKS[0].id,
});
setUsage(
Object.fromEntries(
receipt.packs.map((pack) => [
pack.id,
{ reviewCount: pack.review_count, totalFindings: pack.total_findings },
])
)
);
setSyncIssue(null);
setSaved(true);
window.setTimeout(() => setSaved(false), 1600);
}

function persistLocal(next: ReviewConfig) {
setConfig(next);
saveReviewConfig(next);
setSaved(true);
window.setTimeout(() => setSaved(false), 1600);
}

function selectPack(packId: string) {
persist({ ...config, activeStandardsPack: packId });
async function selectPack(packId: string) {
if (!isTauriAvailable()) {
persistLocal({ ...config, activeStandardsPack: packId });
return;
}
try {
applyCanonicalReceipt(await setActiveRubricPack(packId));
} catch (error) {
setSyncIssue(error instanceof Error ? error.message : String(error));
}
}

function clonePack(source: StandardsPack) {
async function clonePack(source: StandardsPack) {
const cloneName = `${source.name} (copy)`;
const cloneId = uniquePackId(makePackId(cloneName), packs);
const clone: StandardsPack = {
Expand All @@ -126,12 +161,21 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
focus: source.focus,
checks: [...source.checks],
};
persist({
...config,
activeStandardsPack: clone.id,
standardsPacks: [...(config.standardsPacks ?? []), clone],
});
setExpandedPreview(clone.id);
if (!isTauriAvailable()) {
persistLocal({
...config,
activeStandardsPack: clone.id,
standardsPacks: [...(config.standardsPacks ?? []), clone],
});
setExpandedPreview(clone.id);
return;
}
try {
applyCanonicalReceipt(await saveRubricPack(clone));
setExpandedPreview(clone.id);
} catch (error) {
setSyncIssue(error instanceof Error ? error.message : String(error));
}
}

async function copyPreview(pack: StandardsPack) {
Expand All @@ -145,7 +189,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
}
}

function addCustomPack() {
async function addCustomPack() {
const checks = draftChecks
.split('\n')
.map((line) => line.trim())
Expand All @@ -162,14 +206,26 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
checks,
};

persist({
...config,
activeStandardsPack: pack.id,
standardsPacks: [...(config.standardsPacks ?? []), pack],
});
setDraftName('');
setDraftFocus('');
setDraftChecks('');
if (!isTauriAvailable()) {
persistLocal({
...config,
activeStandardsPack: pack.id,
standardsPacks: [...(config.standardsPacks ?? []), pack],
});
setDraftName('');
setDraftFocus('');
setDraftChecks('');
return;
}

try {
applyCanonicalReceipt(await saveRubricPack(pack));
setDraftName('');
setDraftFocus('');
setDraftChecks('');
} catch (error) {
setSyncIssue(error instanceof Error ? error.message : String(error));
}
}

return (
Expand Down Expand Up @@ -214,6 +270,11 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
Saved
</span>
)}
{syncIssue && (
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 px-4 py-3 text-xs text-amber-100">
Canonical rubric sync failed. Existing local settings were kept: {syncIssue}
</div>
)}

<div className="grid gap-5 lg:grid-cols-[1.2fr_0.8fr]">
<section className="grid gap-4">
Expand All @@ -239,7 +300,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
onClick={() => clonePack(pack)}
onClick={() => void clonePack(pack)}
variant="ghost"
size="sm"
title="Duplicate into a new editable pack"
Expand All @@ -249,7 +310,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
</Button>
<Button
type="button"
onClick={() => selectPack(pack.id)}
onClick={() => void selectPack(pack.id)}
variant={active ? 'secondary' : 'default'}
>
{active ? 'Active' : 'Use pack'}
Expand Down Expand Up @@ -343,7 +404,7 @@ export default function Rubrics({ embedded = false }: { embedded?: boolean }) {
placeholder="One check per line"
className="min-h-36 w-full rounded-lg border border-[var(--cv-line)] bg-[var(--cv-surface)] px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-amber-400/40"
/>
<Button type="button" onClick={addCustomPack} className="w-full">
<Button type="button" onClick={() => void addCustomPack()} className="w-full">
<Save size={16} className="mr-2" />
Save and use pack
</Button>
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/pages/TRex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ export default function TRex() {
</ProjectWorkspaceHeader>

<EvidenceScopePlanner
key={selectedRepoPath}
key={`evidence-scope:${selectedRepoPath}`}
repoPath={selectedRepoPath}
consumer="testing"
onConfirm={(plan, candidates) => {
Expand Down Expand Up @@ -439,7 +439,10 @@ export default function TRex() {
onCleanup={handleWarmCleanup}
/>

<DifferentialVerificationPanel key={selectedRepoPath} repoPath={selectedRepoPath} />
<DifferentialVerificationPanel
key={`differential:${selectedRepoPath}`}
repoPath={selectedRepoPath}
/>

<ScenarioCompilerPanel repoPath={selectedRepoPath} />

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/tests/e2e/review-warm-evidence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,8 @@ test('Review presents deterministic coverage and rejected candidate counts', asy
await expect(decision.getByRole('link', { name: 'Runtime evidence' })).toBeVisible();
});

test('Review shows readiness for an external review agent', async ({ page }) => {
test('Review shows readiness for an external review agent', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await installReviewMock(page, false);
await navigateTo(page, '/review');
await waitForNoSpinners(page);
Expand Down
70 changes: 8 additions & 62 deletions apps/desktop/tests/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,67 +15,10 @@ test.describe('Settings page', () => {
consoleErrors.assertNoErrors();
});

// ─── General tab ──────────────────────────────────────────────────────

test('General tab is selected by default and shows AI Provider section', async ({ page }) => {
// "General" should be the active category
await expect(page.locator('text=General').first()).toBeVisible();

// AI Provider section heading
await expect(page.getByRole('heading', { name: 'AI Provider' })).toBeVisible();
});

// ─── Provider dropdown ────────────────────────────────────────────────

test('Can select AI provider from dropdown', async ({ page }) => {
// The provider dropdown is a <select> with the Provider label
const providerSelect = page
.locator('select')
.filter({ has: page.locator('option[value="anthropic"]') });
await expect(providerSelect).toBeVisible();

// Should default to "anthropic"
await expect(providerSelect).toHaveValue('anthropic');

// Change to OpenAI
await providerSelect.selectOption('openai');
await expect(providerSelect).toHaveValue('openai');

// Change to OpenRouter
await providerSelect.selectOption('openrouter');
await expect(providerSelect).toHaveValue('openrouter');

// Change to Custom
await providerSelect.selectOption('custom');
await expect(providerSelect).toHaveValue('custom');

// Custom gateway shows Base URL field
await expect(page.locator('text=Base URL')).toBeVisible();
});

// ─── API key input ────────────────────────────────────────────────────

test('Can enter API key', async ({ page }) => {
// API Key input
const apiKeyInput = page.locator('input[placeholder="sk-..."]');
await expect(apiKeyInput).toBeVisible();

await apiKeyInput.fill('sk-test-key-12345');
await expect(apiKeyInput).toHaveValue('sk-test-key-12345');
});

// ─── Save config button ───────────────────────────────────────────────

test('Save AI Config button is present', async ({ page }) => {
const saveButton = page.locator('button', { hasText: 'Save AI Config' });
await expect(saveButton).toBeVisible();
});

test('Save AI Config button is disabled without required fields', async ({ page }) => {
// Without an API key, the save button should be disabled
// (disabled state depends on !aiApiKey || !aiBaseUrl || !aiModel)
const saveButton = page.locator('button', { hasText: 'Save AI Config' });
await expect(saveButton).toBeDisabled();
test('General is selected by default and exposes current review defaults', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Default Review Tone')).toHaveValue('thorough');
await expect(page.getByText('Session indexing is manual')).toBeVisible();
});

// ─── Category sidebar navigation ─────────────────────────────────────
Expand All @@ -89,6 +32,9 @@ test.describe('Settings page', () => {
'Agent MCP',
'Notifications',
'Usage',
'Rubrics',
'Ops',
'Memories',
'About',
];

Expand All @@ -109,7 +55,7 @@ test.describe('Settings page', () => {

// Click back to General
await page.locator('button', { hasText: 'General' }).first().click();
await expect(page.getByRole('heading', { name: 'AI Provider' })).toBeVisible();
await expect(page.getByLabel('Default Review Tone')).toBeVisible();
});

test('Retention controls stay dry-run-first, labeled, and compact-window safe', async ({
Expand Down
Loading
Loading