Make mutations confirm themselves, and seed the evidence trail
CI / verify (push) Successful in 2m51s
CI / verify (push) Successful in 2m51s
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 <Toaster /> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -140,6 +140,13 @@ conflict on, do an existence check instead.
|
|||||||
flag set to false was silently on. Use the `envBoolean` helper in
|
flag set to false was silently on. Use the `envBoolean` helper in
|
||||||
`apps/api/src/lib/config.ts`.
|
`apps/api/src/lib/config.ts`.
|
||||||
|
|
||||||
|
**Mutations must confirm themselves.** `<Toaster />` 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
|
**shadcn's `accent` is a SUBTLE surface, not the brand.** shadcn uses
|
||||||
`bg-accent` for hover, focus and selected states — dropdown items, command
|
`bg-accent` for hover, focus and selected states — dropdown items, command
|
||||||
rows, ghost buttons. The brand is `primary`. In `tailwind.config.js`, `accent`
|
rows, ghost buttons. The brand is `primary`. In `tailwind.config.js`, `accent`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile';
|
|||||||
import { Register } from '@/pages/Register';
|
import { Register } from '@/pages/Register';
|
||||||
import { PiggyMark } from '@/components/PiggyMark';
|
import { PiggyMark } from '@/components/PiggyMark';
|
||||||
import { EmptyState } from '@/components/ui';
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
|
|
||||||
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
||||||
@@ -79,6 +80,8 @@ export function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthGate config={config} />
|
<AuthGate config={config} />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export interface AvailabilityRow {
|
export interface AvailabilityRow {
|
||||||
commitmentId: string;
|
commitmentId: string;
|
||||||
@@ -295,10 +296,24 @@ export function AllocationSheet({
|
|||||||
})
|
})
|
||||||
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (allocation, values) => {
|
||||||
await refresh();
|
await refresh();
|
||||||
onOpenChange(false);
|
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({
|
const release = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
@@ -306,8 +321,16 @@ export function AllocationSheet({
|
|||||||
reason: releaseReason.trim() || undefined,
|
reason: releaseReason.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
onMutate: () => setReleaseError(null),
|
onMutate: () => setReleaseError(null),
|
||||||
onSuccess: refresh,
|
onSuccess: async () => {
|
||||||
onError: (error) => setReleaseError(errorMessage(error)),
|
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) => {
|
const chooseCommitment = (id: string) => {
|
||||||
|
|||||||
@@ -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<typeof Sonner>
|
export function Toaster(props: ToasterProps) {
|
||||||
|
const { resolved } = useTheme();
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
|
||||||
const { theme = "system" } = useTheme()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={resolved}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
|
// Above the sheets and dialogs it confirms, and clear of the phone tab
|
||||||
|
// bar and the home indicator beneath it.
|
||||||
|
position="bottom-right"
|
||||||
|
offset="1rem"
|
||||||
|
style={{ marginBottom: 'calc(var(--safe-bottom) + 4rem)' }}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
actionButton:
|
description: 'group-[.toast]:text-muted',
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
cancelButton:
|
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
error: 'group-[.toaster]:text-danger',
|
||||||
|
success: 'group-[.toaster]:text-positive',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Toaster }
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { get, patch, relativeTime } from '@/lib/api';
|
import { get, patch, relativeTime } from '@/lib/api';
|
||||||
import { can } from '@/lib/permissions';
|
import { can } from '@/lib/permissions';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ReviewItem {
|
interface ReviewItem {
|
||||||
fact: SourcedFact;
|
fact: SourcedFact;
|
||||||
@@ -74,7 +75,16 @@ export function FactReview() {
|
|||||||
const decision = useMutation({
|
const decision = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
||||||
patch<DecisionResponse>(`/api/facts/${id}/decision`, { status }),
|
patch<DecisionResponse>(`/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'] });
|
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
import { AdminSettings } from '@/components/AdminSettings';
|
import { AdminSettings } from '@/components/AdminSettings';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
setName('');
|
setName('');
|
||||||
setTitle('');
|
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;
|
if (!me) return null;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { createDatabase } from '../client';
|
|||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
activities,
|
activities,
|
||||||
|
facts,
|
||||||
allocations,
|
allocations,
|
||||||
capacityCommitments,
|
capacityCommitments,
|
||||||
capacityRequests,
|
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<string, unknown>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
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(' 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(' 6 demand deals across the pipeline, 5 supply deals');
|
||||||
console.log(' Allocations including one unconverted hold and internal research burn');
|
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');
|
console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear');
|
||||||
|
|||||||
Reference in New Issue
Block a user