diff --git a/AGENTS.md b/AGENTS.md
index feb4d5c..0274624 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,6 +140,13 @@ conflict on, do an existence check instead.
flag set to false was silently on. Use the `envBoolean` helper in
`apps/api/src/lib/config.ts`.
+**Mutations must confirm themselves.** `` is mounted in `App.tsx`
+inside `ThemeProvider`; use `toast.success` / `toast.error` in every mutation's
+`onSuccess` / `onError`. The shadcn Toaster ships wired to `next-themes`, which
+PIG does not use — it was rewired to PIG's `useTheme`. Before that it was never
+mounted, so toasts already written in RecordSheets fired into nothing and every
+save completed in silence.
+
**shadcn's `accent` is a SUBTLE surface, not the brand.** shadcn uses
`bg-accent` for hover, focus and selected states — dropdown items, command
rows, ghost buttons. The brand is `primary`. In `tailwind.config.js`, `accent`
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4974c1c..dc2f05e 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark';
import { EmptyState } from '@/components/ui';
+import { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title';
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
@@ -79,6 +80,8 @@ export function App() {
+ {/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
+
);
diff --git a/apps/web/src/components/AllocationSheet.tsx b/apps/web/src/components/AllocationSheet.tsx
index c65f476..c811951 100644
--- a/apps/web/src/components/AllocationSheet.tsx
+++ b/apps/web/src/components/AllocationSheet.tsx
@@ -37,6 +37,7 @@ import {
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
+import { toast } from 'sonner';
export interface AvailabilityRow {
commitmentId: string;
@@ -295,10 +296,24 @@ export function AllocationSheet({
})
: post('/api/allocations', { ...body, status: values.status });
},
- onSuccess: async () => {
+ onSuccess: async (allocation, values) => {
await refresh();
onOpenChange(false);
+ // The sheet closes on success, so without this the only evidence the
+ // write landed is a number moving somewhere off-screen. Report what
+ // actually happened, in the units the seller was thinking in.
+ const hours = Number(allocation.gpuHours ?? values.gpuHours).toLocaleString();
+ toast.success(
+ values.kind === 'hold' ? 'Capacity held' : 'Capacity allocated',
+ {
+ description:
+ values.kind === 'hold'
+ ? `${hours} GPU-hours reserved. The hold releases automatically when it expires.`
+ : `${hours} GPU-hours committed. Margin and utilisation have been updated.`,
+ },
+ );
},
+ onError: (error) => toast.error('Could not save', { description: errorMessage(error) }),
});
const release = useMutation({
mutationFn: (id: string) =>
@@ -306,8 +321,16 @@ export function AllocationSheet({
reason: releaseReason.trim() || undefined,
}),
onMutate: () => setReleaseError(null),
- onSuccess: refresh,
- onError: (error) => setReleaseError(errorMessage(error)),
+ onSuccess: async () => {
+ await refresh();
+ toast.success('Hold released', {
+ description: 'The capacity is available to sell again.',
+ });
+ },
+ onError: (error) => {
+ setReleaseError(errorMessage(error));
+ toast.error('Could not release the hold', { description: errorMessage(error) });
+ },
});
const chooseCommitment = (id: string) => {
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 1128edf..b6bdf41 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -1,29 +1,43 @@
-import { useTheme } from "next-themes"
-import { Toaster as Sonner } from "sonner"
+/**
+ * Toast host.
+ *
+ * The shadcn original reads the theme from `next-themes`, which PIG does not
+ * use — it has its own provider so a user's choice can be persisted server-side
+ * and follow them between devices. Importing next-themes here would have thrown
+ * at module load, which is why the Toaster was never mounted and every mutation
+ * in the app completed in silence.
+ *
+ * Rewired to PIG's `useTheme`, which already resolves `system` to a concrete
+ * light or dark value.
+ */
+import { Toaster as Sonner, type ToasterProps } from 'sonner';
+import { useTheme } from '@/lib/theme';
-type ToasterProps = React.ComponentProps
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme()
+export function Toaster(props: ToasterProps) {
+ const { resolved } = useTheme();
return (
- )
+ );
}
-
-export { Toaster }
diff --git a/apps/web/src/pages/FactReview.tsx b/apps/web/src/pages/FactReview.tsx
index 9b39b5c..b1a33d4 100644
--- a/apps/web/src/pages/FactReview.tsx
+++ b/apps/web/src/pages/FactReview.tsx
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { get, patch, relativeTime } from '@/lib/api';
import { can } from '@/lib/permissions';
import { usePageTitle } from '@/lib/title';
+import { toast } from 'sonner';
interface ReviewItem {
fact: SourcedFact;
@@ -74,7 +75,16 @@ export function FactReview() {
const decision = useMutation({
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
patch(`/api/facts/${id}/decision`, { status }),
- onSuccess: async () => {
+ onSuccess: async (_result, variables) => {
+ toast.success(
+ variables.status === 'approved' ? 'Evidence accepted' : 'Claim dismissed',
+ {
+ description:
+ variables.status === 'approved'
+ ? 'Recorded as reviewed. Applying it to the record is a separate, field-aware step.'
+ : 'It will not be proposed again from the same evidence.',
+ },
+ );
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
},
});
diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx
index ada565c..8ca9113 100644
--- a/apps/web/src/pages/Settings.tsx
+++ b/apps/web/src/pages/Settings.tsx
@@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '
import { useState } from 'react';
import { usePageTitle } from '@/lib/title';
import { AdminSettings } from '@/components/AdminSettings';
+import { toast } from 'sonner';
interface Me {
id: string;
@@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) {
void queryClient.invalidateQueries({ queryKey: ['me'] });
setName('');
setTitle('');
+ // The form clears itself on success, which without confirmation reads as
+ // though the input was discarded rather than saved.
+ toast.success('Profile saved');
},
+ onError: () => toast.error('Could not save your profile'),
});
if (!me) return null;
diff --git a/packages/db/src/seed/demo.ts b/packages/db/src/seed/demo.ts
index cb701cc..adc3d97 100644
--- a/packages/db/src/seed/demo.ts
+++ b/packages/db/src/seed/demo.ts
@@ -41,6 +41,7 @@ import { createDatabase } from '../client';
import {
accounts,
activities,
+ facts,
allocations,
capacityCommitments,
capacityRequests,
@@ -660,7 +661,166 @@ async function seedDemo() {
}
}
+ // -------------------------------------------------- agent-derived facts
+ //
+ // Without these the fact-review queue and every provenance tooltip are
+ // empty, which hides the thing that makes an agent-written CRM trustworthy:
+ // that each claim carries a score, a band, evidence and a source, and that
+ // only verified claims apply themselves.
+ //
+ // The mix is deliberate. Two `applied` facts show what a confident agent
+ // writes unprompted; four `proposed` show what waits for a human; one is a
+ // near-miss that a reviewer should reject, so the queue is not a row of
+ // obvious approvals.
+ const factSeeds: {
+ accountDomain?: string;
+ contactName?: string;
+ field: string;
+ value: string;
+ score: string;
+ band: 'verified' | 'probable' | 'possible';
+ status: 'applied' | 'proposed';
+ method: string;
+ sourceUrl?: string;
+ evidence: Record;
+ }[] = [
+ {
+ accountDomain: 'coreweave.com',
+ field: 'supplierType',
+ value: 'neocloud',
+ score: '0.960',
+ band: 'verified',
+ status: 'applied',
+ method: 'web_search',
+ sourceUrl: 'https://www.coreweave.com/',
+ evidence: {
+ quote: 'Describes itself as an AI hyperscaler providing GPU cloud infrastructure.',
+ corroboration: 2,
+ },
+ },
+ {
+ accountDomain: 'nebius.com',
+ field: 'jurisdiction',
+ value: 'European Union',
+ score: '0.910',
+ band: 'verified',
+ status: 'applied',
+ method: 'web_search',
+ sourceUrl: 'https://nebius.com/',
+ evidence: {
+ quote: 'Operates a datacentre in Finland, inside the EU data-residency perimeter.',
+ matters: 'Determines eligibility for customers with EU residency requirements.',
+ },
+ },
+ {
+ accountDomain: 'crusoe.ai',
+ field: 'certifications',
+ value: 'SOC 2 Type II',
+ score: '0.720',
+ band: 'probable',
+ status: 'proposed',
+ method: 'web_search',
+ sourceUrl: 'https://crusoe.ai/',
+ evidence: {
+ quote: 'A trust page references SOC 2, but the report scope and observation window are not stated.',
+ caution: 'Scope matters — a report can cover only some products.',
+ },
+ },
+ {
+ accountDomain: 'lambda.ai',
+ field: 'supplierType',
+ value: 'neocloud',
+ score: '0.680',
+ band: 'probable',
+ status: 'proposed',
+ method: 'web_search',
+ sourceUrl: 'https://lambda.ai/',
+ evidence: { quote: 'Markets GPU cloud and on-premises clusters.' },
+ },
+ {
+ contactName: 'Dana Whitfield',
+ field: 'title',
+ value: 'VP Infrastructure',
+ score: '0.540',
+ band: 'possible',
+ status: 'proposed',
+ method: 'inference',
+ evidence: {
+ reasoning: 'A conference bio lists a VP title; the CRM records Head of Infrastructure.',
+ conflict: 'Sources disagree, and neither is dated.',
+ },
+ },
+ {
+ accountDomain: 'runpod.io',
+ field: 'customerSegment',
+ value: 'frontier_lab',
+ score: '0.310',
+ band: 'possible',
+ status: 'proposed',
+ method: 'inference',
+ evidence: {
+ reasoning: 'Inferred from a blog post mentioning large training runs.',
+ warning:
+ 'Weak. This is a supply-side provider, not a frontier lab — a reviewer should reject it.',
+ },
+ },
+ ];
+
+ let factsAdded = 0;
+ for (const seed of factSeeds) {
+ let accountId: string | undefined;
+ let contactId: string | undefined;
+
+ if (seed.accountDomain) {
+ const [row] = await db
+ .select({ id: accounts.id })
+ .from(accounts)
+ .where(eq(accounts.domain, seed.accountDomain))
+ .limit(1);
+ accountId = row?.id;
+ }
+ if (seed.contactName) {
+ const [row] = await db
+ .select({ id: contacts.id })
+ .from(contacts)
+ .where(eq(contacts.fullName, seed.contactName))
+ .limit(1);
+ contactId = row?.id;
+ }
+ if (!accountId && !contactId) continue;
+
+ // Idempotent on the natural key: one claim per subject per field per value.
+ const [existing] = await db
+ .select({ id: facts.id })
+ .from(facts)
+ .where(
+ and(
+ accountId ? eq(facts.accountId, accountId) : eq(facts.contactId, contactId!),
+ eq(facts.field, seed.field),
+ eq(facts.value, seed.value),
+ ),
+ )
+ .limit(1);
+ if (existing) continue;
+
+ await db.insert(facts).values({
+ accountId,
+ contactId,
+ field: seed.field,
+ value: seed.value,
+ score: seed.score,
+ band: seed.band,
+ status: seed.status,
+ method: seed.method,
+ sourceUrl: seed.sourceUrl,
+ evidence: seed.evidence,
+ observedAt: at(-Math.round(Math.random() * 6) - 1),
+ });
+ factsAdded += 1;
+ }
+
console.log(' 4 capacity commitments, with sites, MSAs and negotiated SLAs');
+ console.log(` ${factSeeds.length} agent-derived facts (${factsAdded} new) — 2 applied, 4 awaiting review`);
console.log(' 6 demand deals across the pipeline, 5 supply deals');
console.log(' Allocations including one unconverted hold and internal research burn');
console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear');