Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+284
-15
@@ -4,13 +4,58 @@
|
||||
* Leads with margin and idle capacity rather than deal counts, because those
|
||||
* are the numbers this business actually turns on. A CRM that opens on
|
||||
* "23 open opportunities" tells you nothing about whether you are making money.
|
||||
*
|
||||
* The one thing that outranks the money is the licence to operate. An export
|
||||
* authorisation nobody renewed converts lawful business into unlawful business,
|
||||
* and the Calendar — where every other dated risk lives — can only ever report
|
||||
* the quarter being read. So a lapse is reported here, in the first screenful,
|
||||
* however long ago it happened.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight, Plus, Server, ShieldAlert } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import {
|
||||
compactNumber,
|
||||
get,
|
||||
money,
|
||||
percent,
|
||||
relativeTime,
|
||||
shortDate,
|
||||
unitPrice,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Skeleton,
|
||||
Stat,
|
||||
cn,
|
||||
} from '@/components/ui';
|
||||
import { withoutDemoPrefix } from '@/lib/utils';
|
||||
import { CommitmentSheet } from '@/components/RecordSheets';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { can } from '@/lib/permissions';
|
||||
|
||||
interface ComplianceItem {
|
||||
id: string;
|
||||
kind: 'authorization' | 'artifact';
|
||||
/** The specific instrument — "Export licence", "SOC 2" — not the table it came from. */
|
||||
label: string;
|
||||
reference: string | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
expiresAt: string;
|
||||
lapsed: boolean;
|
||||
volatile: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface Dashboard {
|
||||
me: { name: string; teams: { team: string; role: string }[] };
|
||||
@@ -19,6 +64,7 @@ interface Dashboard {
|
||||
costCents: number;
|
||||
grossMarginCents: number;
|
||||
grossMarginPct: number | null;
|
||||
marginPerAllocatedGpuHourCents: number | null;
|
||||
utilisation: number;
|
||||
idleGpuHours: number;
|
||||
committedGpuHours: number;
|
||||
@@ -26,7 +72,14 @@ interface Dashboard {
|
||||
};
|
||||
blocks: number;
|
||||
openDemandDeals: number;
|
||||
openDemandAcvCents: number;
|
||||
openSupplyDeals: number;
|
||||
compliance: {
|
||||
horizonDays: number;
|
||||
lapsedCount: number;
|
||||
expiringCount: number;
|
||||
items: ComplianceItem[];
|
||||
};
|
||||
idleAlerts: {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
@@ -40,12 +93,15 @@ interface Dashboard {
|
||||
id: string;
|
||||
type: string;
|
||||
subject: string | null;
|
||||
accountName: string | null;
|
||||
occurredAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
usePageTitle('Overview');
|
||||
const me = useIdentity();
|
||||
const [recordingCommitment, setRecordingCommitment] = useState(false);
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => get<Dashboard>('/api/dashboard'),
|
||||
@@ -54,6 +110,23 @@ export function Overview() {
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
/*
|
||||
* Published unconditionally — hooks cannot hide behind the loading return
|
||||
* below — and labelled from the figures once they arrive, so the dock names
|
||||
* the book the reader is looking at rather than repeating the route.
|
||||
*/
|
||||
usePiggyContext({
|
||||
type: 'page',
|
||||
route: '/',
|
||||
...(data
|
||||
? {
|
||||
label: `Overview — ${percent(data.margin.grossMarginPct, 1)} margin, ${percent(
|
||||
data.margin.utilisation,
|
||||
)} of committed capacity sold`,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -80,8 +153,17 @@ export function Overview() {
|
||||
|
||||
const m = data.margin;
|
||||
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
|
||||
const firstName = data.me.name.split(' ')[0];
|
||||
// The seeded book prefixes every name with its demonstration marker, and a
|
||||
// positional read of that took the label for the person: "Good evening,
|
||||
// DEMO" was the first line of the screen everyone opens.
|
||||
const firstName = withoutDemoPrefix(data.me.name).split(' ')[0];
|
||||
const idleExposureCents = data.idleAlerts.reduce((sum, alert) => sum + alert.idleCostCents, 0);
|
||||
/*
|
||||
* Recording what capacity was bought is a supply lead's authority, not
|
||||
* everyone's. Offering the button to someone the server will refuse turns an
|
||||
* empty screen into a 403, which is a worse dead end than the one it fixes.
|
||||
*/
|
||||
const canRecordCommitment = can(me, 'commitment:write', 'supply');
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
@@ -100,14 +182,22 @@ export function Overview() {
|
||||
<Stat
|
||||
label="Gross margin"
|
||||
value={money(m.grossMarginCents)}
|
||||
hint={`${percent(m.grossMarginPct, 1)} of revenue`}
|
||||
// "— of $0 revenue" is what a percentage of nothing prints, and it
|
||||
// reads as a broken figure rather than an empty book.
|
||||
hint={
|
||||
m.revenueCents === 0
|
||||
? 'Nothing sold yet'
|
||||
: `${percent(m.grossMarginPct, 1)} of ${money(m.revenueCents)} revenue`
|
||||
}
|
||||
tone={marginTone}
|
||||
/>
|
||||
<Stat
|
||||
label="Sold ratio"
|
||||
value={percent(m.utilisation, 1)}
|
||||
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
|
||||
tone={m.utilisation < 0.6 ? 'warning' : 'default'}
|
||||
// Nothing bought cannot be under-sold; warning on 0% of 0 hours is an
|
||||
// alarm about a book that does not exist yet.
|
||||
tone={m.committedGpuHours > 0 && m.utilisation < 0.6 ? 'warning' : 'default'}
|
||||
/>
|
||||
<Stat
|
||||
label="Idle capacity"
|
||||
@@ -115,13 +205,22 @@ export function Overview() {
|
||||
hint="Bought and unsold"
|
||||
tone={m.idleGpuHours > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
{/*
|
||||
The value of what is open rather than a count of it — a count is the
|
||||
one figure on this page nobody can act on. The money is demand ACV
|
||||
alone, because a supply deal carries GPUs and a target cost and never
|
||||
a contract value; both counts stay in the hint, where they now agree
|
||||
with the two pipeline boards.
|
||||
*/}
|
||||
<Stat
|
||||
label="Open deals"
|
||||
value={data.openDemandDeals + data.openSupplyDeals}
|
||||
hint={`${data.openDemandDeals} demand · ${data.openSupplyDeals} supply`}
|
||||
label="Open pipeline"
|
||||
value={money(data.openDemandAcvCents)}
|
||||
hint={`${data.openDemandDeals} demand · ${data.openSupplyDeals} supply open`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<LicenceToOperate compliance={data.compliance} />
|
||||
|
||||
{data.idleAlerts.length > 0 ? (
|
||||
<Card className="border-warning/30">
|
||||
<CardHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
||||
@@ -153,7 +252,7 @@ export function Overview() {
|
||||
{alert.breakEvenPriceCents == null ? null : alert.breakEvenPriceCents > 0 ? (
|
||||
<>
|
||||
{' · '}break even above{' '}
|
||||
<span className="nums">{money(alert.breakEvenPriceCents)}</span>/GPU-hr
|
||||
<span className="nums">{unitPrice(alert.breakEvenPriceCents)}</span>/GPU-hr
|
||||
</>
|
||||
) : (
|
||||
<>{' · '}cost already covered — further sales are upside</>
|
||||
@@ -179,7 +278,9 @@ export function Overview() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* `items-start` so a short book does not stretch to the height of a busy
|
||||
activity feed and open a hole under its last row. */}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">The book</CardTitle>
|
||||
@@ -187,13 +288,22 @@ export function Overview() {
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<Row label="Revenue" value={money(m.revenueCents)} />
|
||||
<Row label="Cost of committed capacity" value={money(m.costCents)} />
|
||||
<div className="border-t border-border pt-2">
|
||||
<div className="space-y-2 border-t border-border pt-2">
|
||||
<Row
|
||||
label="Gross margin"
|
||||
value={money(m.grossMarginCents)}
|
||||
emphasis
|
||||
tone={marginTone}
|
||||
/>
|
||||
{/*
|
||||
The blended rate, which is what a block is actually judged on:
|
||||
a book can clear millions and still be selling GPU-hours for
|
||||
pennies over what they cost.
|
||||
*/}
|
||||
<Row
|
||||
label="Margin per GPU-hour sold"
|
||||
value={unitPrice(m.marginPerAllocatedGpuHourCents)}
|
||||
/>
|
||||
</div>
|
||||
<p className="pt-2 text-xs leading-relaxed text-muted">
|
||||
Cost is charged against the full commitment, not only the hours that sold —
|
||||
@@ -211,12 +321,19 @@ export function Overview() {
|
||||
<p className="py-6 text-center text-sm text-muted">Nothing logged yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{data.recentActivity.slice(0, 8).map((activity) => (
|
||||
{data.recentActivity.slice(0, 6).map((activity) => (
|
||||
<li key={activity.id} className="flex items-start gap-2 text-sm">
|
||||
<Badge tone="neutral" className="mt-0.5 shrink-0">
|
||||
{activity.type.replace('_', ' ')}
|
||||
</Badge>
|
||||
<span className="min-w-0 flex-1 truncate">{activity.subject ?? '—'}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{activity.subject ?? '—'}</span>
|
||||
{activity.accountName ? (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
{activity.accountName}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{relativeTime(activity.occurredAt)}
|
||||
</span>
|
||||
@@ -234,15 +351,167 @@ export function Overview() {
|
||||
<EmptyState
|
||||
icon={<Server className="h-8 w-8" />}
|
||||
title="No capacity on the book yet"
|
||||
description="Record a capacity commitment — what you bought, at what cost, over what term — and margin, utilisation and idle alerts all follow from it."
|
||||
description={
|
||||
canRecordCommitment
|
||||
? 'Record a capacity commitment — what you bought, at what cost, over what term — and margin, utilisation and idle alerts all follow from it.'
|
||||
: 'Margin, utilisation and idle alerts all follow from a recorded capacity commitment. Recording one needs supply-lead authority — ask a supply lead to add the first block.'
|
||||
}
|
||||
action={
|
||||
canRecordCommitment ? (
|
||||
<Button variant="primary" onClick={() => setRecordingCommitment(true)}>
|
||||
<Plus data-icon="inline-start" aria-hidden />
|
||||
Record a capacity commitment
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
Mounted outside the empty state it is opened from: recording the first
|
||||
block makes `blocks` non-zero, and a sheet that unmounts underneath its
|
||||
own success toast closes with a jump.
|
||||
*/}
|
||||
<CommitmentSheet
|
||||
open={recordingCommitment}
|
||||
onOpenChange={setRecordingCommitment}
|
||||
identity={me}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The compliance tile.
|
||||
*
|
||||
* Always rendered, never collapsed to nothing when the news is good: a card
|
||||
* that appears only in trouble teaches the reader that its absence means
|
||||
* nothing was checked. The tone escalates instead — quiet when clear, warning
|
||||
* inside the horizon, and danger the moment anything has lapsed.
|
||||
*/
|
||||
function LicenceToOperate({ compliance }: { compliance: Dashboard['compliance'] }) {
|
||||
const lapsed = compliance.lapsedCount > 0;
|
||||
const expiring = compliance.expiringCount > 0;
|
||||
/*
|
||||
* Three rows, however many are dated. The server sorts lapsed first, so the
|
||||
* rows that survive the cut are never the ones this card exists for; the
|
||||
* remainder is a queue, and a queue belongs on the Calendar's compliance
|
||||
* lane rather than on the screen everyone opens first.
|
||||
*/
|
||||
const shown = compliance.items.slice(0, COMPLIANCE_ROWS);
|
||||
const remaining = compliance.lapsedCount + compliance.expiringCount - shown.length;
|
||||
const hiddenLapsed = compliance.lapsedCount - shown.filter((item) => item.lapsed).length;
|
||||
|
||||
return (
|
||||
<Card className={cn(lapsed && 'border-danger/50', !lapsed && expiring && 'border-warning/30')}>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<ShieldAlert
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
lapsed ? 'text-danger' : expiring ? 'text-warning' : 'text-muted',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="text-base">Licence to operate</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{lapsed
|
||||
? 'An expired export authorisation converts lawful business into unlawful business.'
|
||||
: `Export authorisations and attestations expiring within ${compliance.horizonDays} days.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
tone={lapsed ? 'danger' : expiring ? 'warning' : 'positive'}
|
||||
className="nums shrink-0"
|
||||
>
|
||||
{lapsed
|
||||
? `${compliance.lapsedCount} lapsed`
|
||||
: expiring
|
||||
? `${compliance.expiringCount} expiring`
|
||||
: 'Clear'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{shown.length === 0 ? (
|
||||
<p className="text-sm leading-relaxed text-muted">
|
||||
<span className="text-fg">Nothing on file has lapsed</span>, and nothing expires in the
|
||||
next {compliance.horizonDays} days. A counterparty with no authorisation recorded at all
|
||||
is not covered by this check.
|
||||
</p>
|
||||
) : (
|
||||
shown.map((item) => <ComplianceRow key={item.id} item={item} />)
|
||||
)}
|
||||
{remaining > 0 ? (
|
||||
<Link
|
||||
to="/calendar"
|
||||
className="tap inline-flex min-h-11 items-center gap-1 text-sm font-medium text-accent-fg"
|
||||
>
|
||||
{hiddenLapsed > 0
|
||||
? `${remaining} more, ${hiddenLapsed} of them lapsed`
|
||||
: `${remaining} more expiring within ${compliance.horizonDays} days`}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComplianceRow({ item }: { item: ComplianceItem }) {
|
||||
const days = daysUntil(item.expiresAt);
|
||||
const detail = [
|
||||
item.kind === 'authorization' ? 'Export authorisation' : 'Compliance artefact',
|
||||
item.reference,
|
||||
// The date on file cannot be trusted for this counterparty; say so where
|
||||
// the deadline is read, not on a screen nobody opens.
|
||||
item.volatile ? 'rules in flux, re-verify' : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{item.label}
|
||||
{item.accountName ? <span className="text-muted"> — {item.accountName}</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted">{detail.join(' · ')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:justify-end">
|
||||
<span
|
||||
className={cn(
|
||||
'nums whitespace-nowrap text-sm font-semibold',
|
||||
item.lapsed ? 'text-danger' : 'text-warning',
|
||||
)}
|
||||
>
|
||||
{item.lapsed
|
||||
? `Lapsed ${shortDate(item.expiresAt)} · ${Math.abs(days)}d ago`
|
||||
: `Expires ${shortDate(item.expiresAt)} · ${days}d`}
|
||||
</span>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="tap inline-flex min-h-11 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface"
|
||||
aria-label={`Review ${item.label}${item.accountName ? ` for ${item.accountName}` : ''}`}
|
||||
>
|
||||
Review
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPLIANCE_ROWS = 3;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Whole days, negative once the date has passed. Rounded, as the Calendar rounds. */
|
||||
function daysUntil(value: string): number {
|
||||
return Math.round((new Date(value).getTime() - Date.now()) / DAY_MS);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user