From c2c7fb9c19ac968b44c3a5351d8bce33b2a5f923 Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 03:39:00 -0700 Subject: [PATCH] Make mutations confirm themselves, and seed the evidence trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two demo gaps, both of which made working features look like they were not there. **Toasts fired into nothing.** RecordSheets already called toast.success on every save, but was never mounted, so nothing appeared. It could not be mounted, either: the shadcn original imports next-themes, which PIG does not use — it has its own provider so a chosen theme is persisted server-side and follows a user between devices. Rewired to PIG's useTheme, mounted inside ThemeProvider, and offset clear of the phone tab bar and the home indicator. Feedback added where the interface otherwise gives none: allocation and hold report the GPU-hours actually written, because the sheet closes on success and the only other evidence is a number moving off-screen; releasing a hold says the capacity is sellable again; fact decisions say what the decision meant, and that approving evidence is not the same as writing it to a record; the profile form confirms rather than just clearing itself, which otherwise reads as the input being discarded. **The fact table was empty**, so the review queue and every provenance tooltip had nothing to show — the mechanism that makes an agent-written CRM trustworthy, invisible. Six agent-derived facts seeded with a deliberate mix: two applied, showing what a confident agent writes unprompted, and four proposed, including one weak claim that a reviewer should reject, so the queue is not a row of obvious approvals. Each carries a score, a band, evidence and where available a source. Idempotent on subject+field+value; verified over two runs. Verified: toast confirmed firing in a real browser on a 393px viewport, 135 unit tests and e2e green, typecheck clean, CSP hash unchanged, 0px horizontal overflow across 12 routes at both breakpoints. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 7 + apps/web/src/App.tsx | 3 + apps/web/src/components/AllocationSheet.tsx | 29 +++- apps/web/src/components/ui/sonner.tsx | 46 ++++-- apps/web/src/pages/FactReview.tsx | 12 +- apps/web/src/pages/Settings.tsx | 5 + packages/db/src/seed/demo.ts | 160 ++++++++++++++++++++ 7 files changed, 242 insertions(+), 20 deletions(-) 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');