Make mutations confirm themselves, and seed the evidence trail
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:
2026-08-13 03:39:00 -07:00
parent 2763531ce4
commit c2c7fb9c19
7 changed files with 242 additions and 20 deletions
+3
View File
@@ -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() {
<BrowserRouter>
<AuthGate config={config} />
</BrowserRouter>
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
);
+26 -3
View File
@@ -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<AllocationRecord>('/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) => {
+30 -16
View File
@@ -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>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
export function Toaster(props: ToasterProps) {
const { resolved } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={resolved}
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={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
error: 'group-[.toaster]:text-danger',
success: 'group-[.toaster]:text-positive',
},
}}
{...props}
/>
)
);
}
export { Toaster }
+11 -1
View File
@@ -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<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'] });
},
});
+5
View File
@@ -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;