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:
@@ -40,7 +40,9 @@
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"sonner": "^2.0.8",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipel
|
||||
const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline })));
|
||||
const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings })));
|
||||
const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts })));
|
||||
const Account = lazy(() => import('@/pages/Account').then(({ Account }) => ({ default: Account })));
|
||||
const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin })));
|
||||
const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview })));
|
||||
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
||||
@@ -240,6 +241,13 @@ function AppRoutes() {
|
||||
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
||||
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
||||
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
|
||||
{/*
|
||||
The first record route in the product. Registered after the list so
|
||||
the list keeps `/accounts` exactly; react-router matches the more
|
||||
specific path regardless of order, but keeping them adjacent is how
|
||||
the next four record routes will read.
|
||||
*/}
|
||||
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
|
||||
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
|
||||
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
|
||||
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
@@ -12,17 +16,45 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { TEAM_LABELS, TEAM_ROLES, TEAMS, type Team, type TeamRole } from '@pig/core';
|
||||
import { api, get, patch, post, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cn,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { IntegrationSettings } from './IntegrationSettings';
|
||||
|
||||
/**
|
||||
* What the server can honestly say about Piggy, all of it derived from the
|
||||
* deployment environment or from a live probe of the chat server. Nothing here
|
||||
* comes from `platform_settings`, because nothing in `apps/piggy` reads it.
|
||||
*/
|
||||
interface PiggyRuntimeStatus {
|
||||
enabledByEnvironment: boolean;
|
||||
chatEnabled: boolean;
|
||||
internalUrlConfigured: boolean;
|
||||
internalTokenConfigured: boolean;
|
||||
model: string | null;
|
||||
inferenceBase: string | null;
|
||||
inferenceIsolated: boolean;
|
||||
/** Null means the API did not probe for this response, not "down". */
|
||||
reachable: boolean | null;
|
||||
reportedModel: string | null;
|
||||
}
|
||||
|
||||
interface AdminRuntimeSettings {
|
||||
piggyModel: string;
|
||||
piggyInferenceBase: string;
|
||||
piggyEnabled: boolean;
|
||||
piggy: PiggyRuntimeStatus;
|
||||
primeComputeBase: string;
|
||||
primeApiKey: {
|
||||
configured: boolean;
|
||||
@@ -102,8 +134,6 @@ export function AdminSettings() {
|
||||
|
||||
function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [model, setModel] = useState(settings.piggyModel);
|
||||
const [inferenceBase, setInferenceBase] = useState(settings.piggyInferenceBase);
|
||||
const [piggyEnabled, setPiggyEnabled] = useState(settings.piggyEnabled);
|
||||
const [syncEnabled, setSyncEnabled] = useState(settings.primeSyncEnabled);
|
||||
const [interval, setIntervalValue] = useState(String(settings.primeSyncIntervalMinutes));
|
||||
@@ -114,8 +144,6 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
patch<AdminRuntimeSettings>('/api/admin/settings', {
|
||||
piggyModel: model,
|
||||
piggyInferenceBase: inferenceBase,
|
||||
piggyEnabled,
|
||||
primeSyncEnabled: syncEnabled,
|
||||
primeSyncIntervalMinutes: Number(interval),
|
||||
@@ -133,24 +161,7 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
return (
|
||||
<form className="flex flex-col gap-5" onSubmit={(event) => { event.preventDefault(); setMessage(null); save.mutate(); }}>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2"><Bot className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Piggy intelligence</CardTitle></div>
|
||||
<p className="text-sm text-muted">Inference is deliberately isolated from the compute API.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5" htmlFor="piggy-model">
|
||||
<span className="text-sm font-medium">Model</span>
|
||||
<Input id="piggy-model" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
<span className="text-xs text-muted">Nemotron runs tool calls with reasoning disabled to prevent think-aloud truncation.</span>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="inference-base">
|
||||
<span className="text-sm font-medium">Inference endpoint</span>
|
||||
<Input id="inference-base" type="url" value={inferenceBase} onChange={(event) => setInferenceBase(event.target.value)} />
|
||||
</label>
|
||||
<ToggleRow id="piggy-enabled" label="Piggy worker" description="Allow the configured worker to process queued tasks." checked={piggyEnabled} onCheckedChange={setPiggyEnabled} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PiggyCard status={settings.piggy} chatEnabled={piggyEnabled} onChatEnabledChange={setPiggyEnabled} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -183,13 +194,273 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ id, label, description, checked, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; onCheckedChange(value: boolean): void }) {
|
||||
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
|
||||
/**
|
||||
* Piggy's control panel, which for the most part controls nothing.
|
||||
*
|
||||
* This card used to offer an editable model and inference endpoint. Both saved
|
||||
* happily into `platform_settings`, and `apps/piggy` has never read that table:
|
||||
* it takes its model, its endpoint and its inference key from `process.env` at
|
||||
* boot. An admin could therefore change the model here, be told it was saved,
|
||||
* and watch the old one keep answering. They are reported as environment facts
|
||||
* now, and the only genuinely live control — the chat switch — is labelled with
|
||||
* what it actually gates.
|
||||
*/
|
||||
function PiggyCard({
|
||||
status,
|
||||
chatEnabled,
|
||||
onChatEnabledChange,
|
||||
}: {
|
||||
status: PiggyRuntimeStatus;
|
||||
chatEnabled: boolean;
|
||||
onChatEnabledChange(value: boolean): void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const verdict = piggyVerdict(status);
|
||||
// Two containers, two copies of PIGGY_MODEL. When they disagree, the process
|
||||
// doing the inference wins, and the operator is looking at the wrong one.
|
||||
const modelDisagrees =
|
||||
status.reportedModel !== null && status.model !== null && status.reportedModel !== status.model;
|
||||
|
||||
function recheck() {
|
||||
setRechecking(true);
|
||||
void queryClient
|
||||
.refetchQueries({ queryKey: ['admin-settings'] })
|
||||
.finally(() => setRechecking(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="text-accent-fg" aria-hidden />
|
||||
<CardTitle className="text-base">Piggy intelligence</CardTitle>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={recheck} disabled={rechecking}>
|
||||
<RefreshCw className={cn('size-4', rechecking && 'animate-spin')} aria-hidden />
|
||||
{rechecking ? 'Checking…' : 'Recheck'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Piggy reads its model, endpoint and inference key from the deployment environment once, at boot. Nothing on this page can change them.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className={cn('rounded-xl border p-3', VERDICT_SURFACE[verdict.tone])}>
|
||||
<p className={cn('text-sm font-medium', VERDICT_TEXT[verdict.tone])}>{verdict.title}</p>
|
||||
<p className="mt-1 text-xs text-muted">{verdict.detail}</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
<PiggyFact
|
||||
state={status.enabledByEnvironment ? 'ok' : 'bad'}
|
||||
label="Deployment gate"
|
||||
detail={status.enabledByEnvironment ? 'PIGGY_ENABLED is set' : 'PIGGY_ENABLED is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.internalUrlConfigured ? 'ok' : 'bad'}
|
||||
label="Relay address"
|
||||
detail={status.internalUrlConfigured ? 'PIGGY_INTERNAL_URL is set' : 'PIGGY_INTERNAL_URL is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.internalTokenConfigured ? 'ok' : 'bad'}
|
||||
label="Internal token"
|
||||
detail={status.internalTokenConfigured ? 'PIGGY_INTERNAL_TOKEN is set' : 'PIGGY_INTERNAL_TOKEN is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.reachable === null ? 'unknown' : status.reachable ? 'ok' : 'bad'}
|
||||
label="Chat server"
|
||||
detail={
|
||||
status.reachable === null
|
||||
? 'Not probed'
|
||||
: status.reachable
|
||||
? 'Answering /internal/health'
|
||||
: 'No answer on /internal/health'
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
|
||||
{status.inferenceIsolated ? null : (
|
||||
<p className="flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-px size-4 shrink-0" aria-hidden />
|
||||
PIGGY_INFERENCE_BASE points at the Prime compute API host. Inference lives on a different host and no model call can succeed against this one.
|
||||
</p>
|
||||
)}
|
||||
{modelDisagrees ? (
|
||||
<p className="flex items-start gap-2 text-xs text-warning">
|
||||
<AlertTriangle className="mt-px size-4 shrink-0" aria-hidden />
|
||||
This API container is configured for {status.model}, but the Piggy process reports {status.reportedModel}. The two environments disagree; the one Piggy holds is the one being billed.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<EnvironmentValue
|
||||
label="Model"
|
||||
variable="PIGGY_MODEL"
|
||||
value={status.reportedModel ?? status.model}
|
||||
note={
|
||||
status.reportedModel
|
||||
? 'Reported by the running chat server, which is the copy that matters.'
|
||||
: 'From this API container. The Piggy process holds its own copy and only it can confirm what is in force.'
|
||||
}
|
||||
/>
|
||||
<EnvironmentValue
|
||||
label="Inference endpoint"
|
||||
variable="PIGGY_INFERENCE_BASE"
|
||||
value={status.inferenceBase}
|
||||
note={
|
||||
status.inferenceIsolated
|
||||
? 'A different host from the Prime compute API, as it must be. The inference key that goes with it never reaches this container.'
|
||||
: 'It should name an inference host. The inference key that goes with it never reaches this container.'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Locked rather than merely ineffective when the environment gate is
|
||||
shut: a switch that saves and changes nothing is the exact failure
|
||||
this card was rewritten to remove. */}
|
||||
<ToggleRow
|
||||
id="piggy-enabled"
|
||||
label="Interactive chat"
|
||||
description={
|
||||
status.enabledByEnvironment
|
||||
? 'Lets people open Piggy and ask questions. The background task worker ignores this switch entirely — it runs whenever the Piggy process is up.'
|
||||
: 'Locked until PIGGY_ENABLED is set in the environment. It gates the chat panel only; the background task worker never reads it.'
|
||||
}
|
||||
checked={chatEnabled}
|
||||
disabled={!status.enabledByEnvironment}
|
||||
onCheckedChange={onChatEnabledChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const VERDICT_SURFACE = {
|
||||
positive: 'border-positive bg-positive/10',
|
||||
warning: 'border-warning bg-warning/10',
|
||||
danger: 'border-danger bg-danger/10',
|
||||
neutral: 'border-border bg-surface-2',
|
||||
} as const;
|
||||
|
||||
const VERDICT_TEXT = {
|
||||
positive: 'text-positive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
neutral: 'text-fg',
|
||||
} as const;
|
||||
|
||||
interface PiggyVerdict {
|
||||
tone: keyof typeof VERDICT_SURFACE;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered outermost gate first, because only the first unmet condition is
|
||||
* actionable: telling an operator their chat server is unreachable when
|
||||
* PIGGY_ENABLED is unset sends them to read container logs for a service they
|
||||
* never asked to run.
|
||||
*
|
||||
* Read from the saved status rather than the pending switch, so an unsaved
|
||||
* toggle cannot make the panel describe a state that is not in force.
|
||||
*/
|
||||
function piggyVerdict(status: PiggyRuntimeStatus): PiggyVerdict {
|
||||
if (!status.enabledByEnvironment) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Piggy is switched off in this deployment',
|
||||
detail:
|
||||
'PIGGY_ENABLED is unset, so chat is hidden for everyone and the switch below is locked. Set it in the environment and restart the API.',
|
||||
};
|
||||
}
|
||||
if (!status.internalUrlConfigured || !status.internalTokenConfigured) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
title: 'Piggy is enabled but not wired up',
|
||||
detail:
|
||||
'The API has no authenticated route to the chat server. Chat stays unavailable until both PIGGY_INTERNAL_URL and PIGGY_INTERNAL_TOKEN are set on this container.',
|
||||
};
|
||||
}
|
||||
if (status.reachable === null) {
|
||||
return {
|
||||
tone: 'neutral',
|
||||
title: 'Chat server not checked',
|
||||
detail: 'Press Recheck to probe it.',
|
||||
};
|
||||
}
|
||||
if (!status.reachable) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
title: 'The chat server is not answering',
|
||||
detail:
|
||||
'PIGGY_INFERENCE_API_KEY never reaches this container, and Piggy exits at boot without it — a missing key looks exactly like this. Check the Piggy container logs before anything else.',
|
||||
};
|
||||
}
|
||||
if (!status.chatEnabled) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Reachable, but chat is switched off',
|
||||
detail:
|
||||
'The chat server answered and the queued task worker is running, but nobody can open the chat panel until the switch below is on and saved.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: 'positive',
|
||||
title: 'Piggy is answering',
|
||||
detail: status.reportedModel
|
||||
? `The chat server is up and running ${status.reportedModel}.`
|
||||
: 'The chat server is up.',
|
||||
};
|
||||
}
|
||||
|
||||
function PiggyFact({ state, label, detail }: { state: 'ok' | 'bad' | 'unknown'; label: string; detail: string }) {
|
||||
const Icon = state === 'ok' ? CircleCheck : state === 'bad' ? CircleX : CircleDashed;
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
state === 'ok' ? 'text-positive' : state === 'bad' ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted">{detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value an admin may need to read and quote, but must not be invited to edit.
|
||||
* Rendered as text rather than a disabled input on purpose: a greyed-out field
|
||||
* still reads as "editable later", and this one never will be.
|
||||
*/
|
||||
function EnvironmentValue({ label, variable, value, note }: { label: string; variable: string; value: string | null; note: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Badge>Set by environment</Badge>
|
||||
</div>
|
||||
<p className="min-w-0 break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs">
|
||||
{value ?? 'unset'}
|
||||
</p>
|
||||
<span className="text-xs text-muted">
|
||||
<code className="font-mono">{variable}</code> · {note}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ id, label, description, checked, disabled, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; disabled?: boolean; onCheckedChange(value: boolean): void }) {
|
||||
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} /></div>;
|
||||
}
|
||||
|
||||
function InviteManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
|
||||
const ledger = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
|
||||
const [email, setEmail] = useState('');
|
||||
const [team, setTeam] = useState<Team | 'any'>('any');
|
||||
const [role, setRole] = useState<TeamRole>('member');
|
||||
@@ -210,13 +481,73 @@ function InviteManager() {
|
||||
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
|
||||
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
|
||||
</form></CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader><CardContent className="flex flex-col gap-2">{data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader>{/* A failed ledger read must not render as "no invites issued": an admin who
|
||||
believes the workspace is empty issues a second code to someone who
|
||||
already has one. */}
|
||||
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites…</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab an admin is sent to in order to grant someone access.
|
||||
*
|
||||
* It used to destructure `data = []` with no loading or error branch, so a slow
|
||||
* or failed request drew a heading over nothing — indistinguishable from a
|
||||
* workspace with no members, and no indication that anything had gone wrong.
|
||||
*/
|
||||
function MemberManager() {
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
|
||||
return <div className="flex flex-col gap-3"><div className="flex items-center gap-2"><Users className="text-accent-fg" aria-hidden /><div><h3 className="font-semibold">Team and role administration</h3><p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p></div></div>{data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)}</div>;
|
||||
const query = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-accent-fg" aria-hidden />
|
||||
<div>
|
||||
<h3 className="font-semibold">Team and role administration</h3>
|
||||
<p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p>
|
||||
</div>
|
||||
</div>
|
||||
{query.isPending ? (
|
||||
<div className="flex flex-col gap-3" aria-busy>
|
||||
<span className="sr-only">Loading members…</span>
|
||||
{/* Shaped like a member row rather than a plain bar, so the tab does
|
||||
not visibly reflow the moment the request lands. */}
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Card key={row}>
|
||||
<CardContent className="flex flex-col gap-4 p-4 sm:p-5 xl:flex-row xl:items-center">
|
||||
<div className="flex flex-col gap-2 xl:w-64"><Skeleton className="h-4 w-32" /><Skeleton className="h-3 w-44" /></div>
|
||||
<div className="grid flex-1 gap-2 sm:grid-cols-3">{[0, 1, 2].map((column) => <Skeleton key={column} className="h-11" />)}</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle aria-hidden />}
|
||||
title="Access list unavailable"
|
||||
description={query.error.message}
|
||||
action={<Button type="button" variant="outline" onClick={() => void query.refetch()}><RefreshCw aria-hidden />Try again</Button>}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : query.data.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Users aria-hidden />}
|
||||
title="No active members"
|
||||
description="Everyone with an account has been deactivated. Issue an invite from the Invites tab to bring someone back in."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
query.data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberAccess({ member }: { member: Member }) {
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { ApiError, compactNumber, dateRange, get, percent, post, shortDate, unitPrice } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AvailabilityRow {
|
||||
@@ -439,7 +439,12 @@ export function AllocationSheet({
|
||||
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
|
||||
) : options.length === 0 && !availabilityLoading ? (
|
||||
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
No currently available commitment remains in this context. Run the matcher again before promising capacity.
|
||||
{/* Two different dead ends. Told to re-run a matcher they
|
||||
never ran, someone with an empty book has nowhere to go —
|
||||
the answer there is to record what capacity was bought. */}
|
||||
{matches
|
||||
? 'No currently available commitment remains in this context. Run the matcher again before promising capacity.'
|
||||
: 'No capacity commitment has any hours left to sell. Record what capacity has been committed to buy before promising any.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -495,7 +500,7 @@ export function AllocationSheet({
|
||||
<Badge tone={allocation.status === 'planned' ? 'warning' : 'positive'}>{allocation.status === 'planned' ? 'Held' : allocation.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {shortDate(allocation.startsAt)}–{shortDate(allocation.endsAt)}
|
||||
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {dateRange(allocation.startsAt, allocation.endsAt)}
|
||||
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -562,12 +567,12 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted">Contract window</dt><dd className="text-right">{shortDate(row.startsAt)}–{shortDate(row.endsAt)}</dd>
|
||||
<dt className="text-muted">Contract window</dt><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
|
||||
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{money(row.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${money(row.breakEvenPriceCents)}/GPU-hr`}</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
|
||||
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
|
||||
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{money(Math.round(delta * 100))}/GPU-hr</dd></> : null}
|
||||
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
|
||||
</dl>
|
||||
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Search } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { activeNavItem, visibleNav } from '@/lib/nav';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { CommandPalette, searchLabel, searchPlaceholder } from './CommandPalette';
|
||||
import { PiggyLogo } from './PiggyMark';
|
||||
import { PiggyDockToggle } from './PiggyDock';
|
||||
import { AudioControl } from './AudioControl';
|
||||
@@ -44,6 +44,10 @@ export function AppHeader() {
|
||||
const current = activeNavItem(items, pathname);
|
||||
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
// The button says what the palette will actually search, so the two cannot
|
||||
// disagree about whether this person's grants reach the book.
|
||||
const label = searchLabel(identity);
|
||||
const placeholder = searchPlaceholder(identity);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -99,7 +103,7 @@ export function AppHeader() {
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">Search pages and workflows…</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}…</span>
|
||||
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
@@ -110,7 +114,7 @@ export function AppHeader() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted md:hidden"
|
||||
aria-label="Search and navigate"
|
||||
aria-label={placeholder}
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-5" aria-hidden />
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
/**
|
||||
* ⌘K — pages and the book, in one ranking.
|
||||
*
|
||||
* This used to search page names only, so every account, deal and contract in
|
||||
* the book answered "No pages found". That is the opposite of what anyone
|
||||
* presses ⌘K for: the reflex is the palette, then a customer's name.
|
||||
*
|
||||
* Records are read through the SAME react-query keys the list pages use, so a
|
||||
* palette opened after a visit to Accounts or a pipeline board costs nothing,
|
||||
* and typing costs no requests at all — the book is fetched once per open and
|
||||
* filtered in memory. A search that issued a request per keystroke would be
|
||||
* slower than opening the page it is trying to save you from.
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { Building2, FileText, Handshake, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
DEMAND_STAGE_LABELS,
|
||||
SUPPLY_STAGE_LABELS,
|
||||
type AccountSide,
|
||||
type ContractStatus,
|
||||
type ContractType,
|
||||
type DemandStage,
|
||||
type SupplyStage,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -11,6 +34,9 @@ import {
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { get, money } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny, type PermissionIdentity } from '@/lib/permissions';
|
||||
|
||||
export interface CommandDestination {
|
||||
to: string;
|
||||
@@ -20,6 +46,312 @@ export interface CommandDestination {
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a record lives.
|
||||
*
|
||||
* One line each, deliberately. `/accounts/:id` is a real detail route; the
|
||||
* deal boards and the contract list still select in local state, so those two
|
||||
* land on the right page with the id carried in the query string — the honest
|
||||
* destination today, and a one-line change to a detail route the moment one
|
||||
* exists.
|
||||
*/
|
||||
const RECORD_ROUTES = {
|
||||
account: (id: string) => `/accounts/${id}`,
|
||||
demandDeal: (id: string) => `/demand?deal=${id}`,
|
||||
supplyDeal: (id: string) => `/supply?deal=${id}`,
|
||||
contract: (id: string) => `/contracts?contract=${id}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Enough rows to recognise the one you meant, few enough that the palette does
|
||||
* not become the list page. The heading says when there are more, because a
|
||||
* silent cap is indistinguishable from a missing record.
|
||||
*/
|
||||
const ROWS_PER_GROUP = 5;
|
||||
|
||||
type RecordKind = 'account' | 'deal' | 'contract';
|
||||
|
||||
interface RecordHit {
|
||||
kind: RecordKind;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Secondary line: what tells two similarly named records apart. */
|
||||
meta: string;
|
||||
/** Right-hand figure — money or capacity — where the record has one. */
|
||||
trailing?: string;
|
||||
to: string;
|
||||
/** Lowercased match text, including terms the row does not display. */
|
||||
haystack: string;
|
||||
}
|
||||
|
||||
const RECORD_GROUPS: readonly { kind: RecordKind; heading: string; icon: LucideIcon }[] = [
|
||||
{ kind: 'account', heading: 'Accounts', icon: Building2 },
|
||||
{ kind: 'deal', heading: 'Deals', icon: Handshake },
|
||||
{ kind: 'contract', heading: 'Contracts', icon: FileText },
|
||||
];
|
||||
|
||||
const SIDE_LABELS: Record<AccountSide, string> = {
|
||||
supply: 'Supply',
|
||||
demand: 'Demand',
|
||||
both: 'Supply & demand',
|
||||
};
|
||||
|
||||
const CONTRACT_TYPE_LABELS: Record<ContractType, string> = {
|
||||
msa: 'MSA',
|
||||
dpa: 'DPA',
|
||||
sla: 'SLA',
|
||||
order_form: 'Order form',
|
||||
capacity_commitment: 'Capacity commitment',
|
||||
nda: 'NDA',
|
||||
amendment: 'Amendment',
|
||||
};
|
||||
|
||||
/** Enum values are snake_case everywhere; this is presentation, not a table. */
|
||||
function humanise(value: string): string {
|
||||
const spaced = value.replace(/_/g, ' ');
|
||||
return spaced.charAt(0).toLocaleUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function joinMeta(parts: (string | null | undefined)[]): string {
|
||||
return parts.filter((part): part is string => Boolean(part)).join(' · ');
|
||||
}
|
||||
|
||||
/** The words a row must contain. An empty query asks nothing and matches all. */
|
||||
function queryWords(query: string): string[] {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return needle ? needle.split(/\s+/) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every word, in any order — so "labs tess" and "tess labs" both find
|
||||
* Tessellate Labs, which a plain substring test would not.
|
||||
*
|
||||
* This gate is also what keeps the ranking sane. cmdk scores with
|
||||
* command-score, which is a subsequence matcher: it rates "Import Records"
|
||||
* against "tess" at 0.003 rather than zero, and it leaves groups in the order
|
||||
* they were written. Left to itself the palette therefore put four irrelevant
|
||||
* pages above the account someone had just typed the name of, with the first
|
||||
* of them selected — so Enter opened Import. Filtering both pages and records
|
||||
* on whole words first means everything cmdk still sees is a genuine match.
|
||||
*/
|
||||
function matchesWords(haystack: string, words: readonly string[]): boolean {
|
||||
return words.every((word) => haystack.includes(word));
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
side: AccountSide;
|
||||
country: string | null;
|
||||
customerSegment: string | null;
|
||||
supplierType: string | null;
|
||||
}
|
||||
|
||||
interface DealBoard<T> {
|
||||
deals: { deal: T; accountName: string | null }[];
|
||||
}
|
||||
|
||||
interface DemandDealRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: DemandStage;
|
||||
productLine: string;
|
||||
acvCents: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface SupplyDealRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: SupplyStage;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
}
|
||||
|
||||
interface ContractRow {
|
||||
contract: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ContractType;
|
||||
status: ContractStatus;
|
||||
valueCents: number | null;
|
||||
currency: string;
|
||||
};
|
||||
accountName: string | null;
|
||||
}
|
||||
|
||||
function hit(fields: Omit<RecordHit, 'haystack'> & { hidden?: string }): RecordHit {
|
||||
const { hidden, ...record } = fields;
|
||||
return {
|
||||
...record,
|
||||
haystack: `${record.name} ${record.meta} ${record.trailing ?? ''} ${hidden ?? ''}`.toLocaleLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function accountHits(rows: AccountRow[] | undefined): RecordHit[] {
|
||||
return (rows ?? []).map((account) =>
|
||||
hit({
|
||||
kind: 'account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
meta: joinMeta([SIDE_LABELS[account.side], account.domain, account.country]),
|
||||
to: RECORD_ROUTES.account(account.id),
|
||||
hidden: joinMeta([account.customerSegment, account.supplierType]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function demandDealHits(board: DealBoard<DemandDealRow> | undefined): RecordHit[] {
|
||||
return (board?.deals ?? []).map(({ deal, accountName }) =>
|
||||
hit({
|
||||
kind: 'deal',
|
||||
id: deal.id,
|
||||
name: deal.name,
|
||||
meta: joinMeta(['Demand', accountName, DEMAND_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
|
||||
trailing: deal.acvCents == null ? undefined : money(deal.acvCents, deal.currency),
|
||||
to: RECORD_ROUTES.demandDeal(deal.id),
|
||||
hidden: humanise(deal.productLine),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function supplyDealHits(board: DealBoard<SupplyDealRow> | undefined): RecordHit[] {
|
||||
return (board?.deals ?? []).map(({ deal, accountName }) =>
|
||||
hit({
|
||||
kind: 'deal',
|
||||
id: deal.id,
|
||||
name: deal.name,
|
||||
meta: joinMeta(['Supply', accountName, SUPPLY_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
|
||||
trailing:
|
||||
deal.gpuCount != null && deal.gpuType ? `${deal.gpuCount}× ${deal.gpuType}` : undefined,
|
||||
to: RECORD_ROUTES.supplyDeal(deal.id),
|
||||
hidden: deal.gpuType ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function contractHits(rows: ContractRow[] | undefined): RecordHit[] {
|
||||
return (rows ?? []).map(({ contract, accountName }) =>
|
||||
hit({
|
||||
kind: 'contract',
|
||||
id: contract.id,
|
||||
name: contract.title,
|
||||
meta: joinMeta([
|
||||
accountName,
|
||||
CONTRACT_TYPE_LABELS[contract.type],
|
||||
humanise(contract.status),
|
||||
]),
|
||||
trailing:
|
||||
contract.valueCents == null
|
||||
? undefined
|
||||
: money(contract.valueCents, contract.currency),
|
||||
to: RECORD_ROUTES.contract(contract.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hit on the record's own name beats one that only matched its second line,
|
||||
* so typing an account's name puts the account above the several deals that
|
||||
* merely mention it. cmdk re-scores whatever survives; this decides which rows
|
||||
* survive the cap, which is the decision cmdk cannot make for us.
|
||||
*/
|
||||
function rankOf(record: RecordHit, needle: string): number {
|
||||
const name = record.name.toLocaleLowerCase();
|
||||
if (name.startsWith(needle)) return 0;
|
||||
if (name.includes(needle)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
interface BookSearch {
|
||||
hits: RecordHit[];
|
||||
isLoading: boolean;
|
||||
/** Every source failed — the palette can only offer pages. */
|
||||
isUnavailable: boolean;
|
||||
/** At least one source failed, so the results are known to be incomplete. */
|
||||
isIncomplete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The book, filtered.
|
||||
*
|
||||
* Every key here is copied from the page that owns it — `['accounts', 'all']`
|
||||
* from Accounts, the endpoint-keyed boards from Pipeline, `['contracts']` from
|
||||
* Contracts — so this shares their cache rather than shadowing it with a
|
||||
* fourth copy of the same rows.
|
||||
*/
|
||||
function useBookSearch(query: string, enabled: boolean): BookSearch {
|
||||
const accounts = useQuery({
|
||||
queryKey: ['accounts', 'all'],
|
||||
queryFn: () => get<AccountRow[]>('/api/accounts'),
|
||||
enabled,
|
||||
});
|
||||
const demand = useQuery({
|
||||
queryKey: ['/api/deals/demand'],
|
||||
queryFn: () => get<DealBoard<DemandDealRow>>('/api/deals/demand'),
|
||||
enabled,
|
||||
});
|
||||
const supply = useQuery({
|
||||
queryKey: ['/api/deals/supply'],
|
||||
queryFn: () => get<DealBoard<SupplyDealRow>>('/api/deals/supply'),
|
||||
enabled,
|
||||
});
|
||||
const contracts = useQuery({
|
||||
queryKey: ['contracts'],
|
||||
queryFn: () => get<ContractRow[]>('/api/contracts'),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const all = useMemo(
|
||||
() => [
|
||||
...accountHits(accounts.data),
|
||||
...demandDealHits(demand.data),
|
||||
...supplyDealHits(supply.data),
|
||||
...contractHits(contracts.data),
|
||||
],
|
||||
[accounts.data, demand.data, supply.data, contracts.data],
|
||||
);
|
||||
|
||||
const hits = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const words = queryWords(query);
|
||||
if (words.length === 0) return [];
|
||||
return all
|
||||
.filter((record) => matchesWords(record.haystack, words))
|
||||
.sort((left, right) => rankOf(left, needle) - rankOf(right, needle));
|
||||
}, [all, query]);
|
||||
|
||||
const queries: UseQueryResult<unknown>[] = [accounts, demand, supply, contracts];
|
||||
const failed = queries.filter((result) => result.isError).length;
|
||||
return {
|
||||
hits,
|
||||
isLoading: queries.some((result) => result.isLoading),
|
||||
isUnavailable: failed === queries.length,
|
||||
isIncomplete: failed > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the palette can actually search for this person, said honestly.
|
||||
*
|
||||
* Exported because the header's search button makes the same promise, and a
|
||||
* button offering to find accounts to somebody whose grants stop the palette
|
||||
* from loading any is a promise the dialog then breaks. No trailing ellipsis:
|
||||
* these are accessible names as well as placeholders, and a screen reader
|
||||
* announces the dots.
|
||||
*/
|
||||
export function searchPlaceholder(identity: PermissionIdentity | undefined): string {
|
||||
return canAny(identity, 'book:read')
|
||||
? 'Search accounts, deals, contracts and pages'
|
||||
: 'Search pages and workflows';
|
||||
}
|
||||
|
||||
/** The same promise, short enough to survive the header button at 224px. */
|
||||
export function searchLabel(identity: PermissionIdentity | undefined): string {
|
||||
return canAny(identity, 'book:read') ? 'Search records and pages' : 'Search pages';
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
destinations,
|
||||
open,
|
||||
@@ -31,7 +363,36 @@ export function CommandPalette({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
|
||||
const identity = useIdentity();
|
||||
const placeholder = searchPlaceholder(identity);
|
||||
const canSearchRecords = canAny(identity, 'book:read');
|
||||
|
||||
/*
|
||||
* Filtering is deliberately synchronous with the keystroke, and there is no
|
||||
* timer anywhere in this file.
|
||||
*
|
||||
* The debounce that matters happened already: the book is fetched once per
|
||||
* open and matched in memory, so typing costs no requests. Deferring the
|
||||
* *render* on top of that — `useDeferredValue`, a timeout, either — is not
|
||||
* free but actively broken: cmdk picks the item Enter will open in the same
|
||||
* pass that handles the keystroke, so rows arriving a frame later are rows
|
||||
* it has already decided are not there. Measured, with the rows deferred:
|
||||
* typing "demo msa" listed four contracts with none selected, and Enter did
|
||||
* nothing at all. Eighty rows of string matching is far cheaper than that.
|
||||
*/
|
||||
const records = useBookSearch(query, open && canSearchRecords);
|
||||
const words = useMemo(() => queryWords(query), [query]);
|
||||
const pages = useMemo(
|
||||
() =>
|
||||
destinations.filter((destination) =>
|
||||
matchesWords(
|
||||
`${destination.label} ${destination.group ?? 'Navigate'}`.toLocaleLowerCase(),
|
||||
words,
|
||||
),
|
||||
),
|
||||
[destinations, words],
|
||||
);
|
||||
const groups = Array.from(new Set(pages.map((destination) => destination.group ?? 'Navigate')));
|
||||
|
||||
// Clear on close rather than on open: reopening must not present yesterday's
|
||||
// query over a list it is already silently filtering. Keyed off `open` and
|
||||
@@ -54,6 +415,8 @@ export function CommandPalette({
|
||||
if (open) opener.current = document.activeElement as HTMLElement | null;
|
||||
}, [open]);
|
||||
|
||||
const typing = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
@@ -73,16 +436,27 @@ export function CommandPalette({
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Search pages and workflows…"
|
||||
aria-label="Search pages and workflows"
|
||||
placeholder={`${placeholder}…`}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
<CommandList className="max-h-[min(70dvh,32rem)] p-1">
|
||||
<CommandEmpty>No pages found.</CommandEmpty>
|
||||
{/*
|
||||
Three states, three sentences. "Nothing matches" while the book is
|
||||
still arriving is a lie that sends someone off to check whether the
|
||||
record exists at all.
|
||||
*/}
|
||||
<CommandEmpty>
|
||||
{records.isLoading
|
||||
? 'Searching accounts, deals and contracts…'
|
||||
: records.isUnavailable
|
||||
? `No pages match “${query.trim()}”.`
|
||||
: `Nothing matches “${query.trim()}”.`}
|
||||
</CommandEmpty>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group}>
|
||||
{index > 0 ? <CommandSeparator /> : null}
|
||||
<CommandGroup heading={group}>
|
||||
{destinations
|
||||
{pages
|
||||
.filter((destination) => (destination.group ?? 'Navigate') === group)
|
||||
.map((destination) => (
|
||||
<CommandItem
|
||||
@@ -104,7 +478,70 @@ export function CommandPalette({
|
||||
</CommandGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
{/*
|
||||
Records only once there is something to match on. With an empty query
|
||||
they would bury the navigation under sixty rows of book, which is the
|
||||
palette failing at the job it already did well.
|
||||
*/}
|
||||
{typing
|
||||
? RECORD_GROUPS.map(({ kind, heading, icon: Icon }) => {
|
||||
const matches = records.hits.filter((record) => record.kind === kind);
|
||||
const shown = matches.slice(0, ROWS_PER_GROUP);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
// No separator: cmdk hides those while a search is running, and
|
||||
// records only ever render while one is. The headings carry the
|
||||
// division on their own.
|
||||
<CommandGroup
|
||||
key={kind}
|
||||
heading={
|
||||
matches.length > shown.length
|
||||
? `${heading} · closest ${shown.length} of ${matches.length}`
|
||||
: heading
|
||||
}
|
||||
>
|
||||
{shown.map((record) => (
|
||||
<CommandItem
|
||||
key={`${kind}-${record.id}`}
|
||||
// The id keeps the value unique where two records share a
|
||||
// name; it is never rendered, and cannot widen the match
|
||||
// because these rows are pre-filtered above.
|
||||
value={`${record.haystack} ${record.id}`}
|
||||
className="min-h-11 items-start rounded-lg"
|
||||
onSelect={() => {
|
||||
navigate(record.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden className="mt-0.5 text-muted" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{record.name}</span>
|
||||
<span className="truncate text-xs text-muted">{record.meta}</span>
|
||||
</span>
|
||||
{record.trailing ? (
|
||||
<span className="nums mt-0.5 shrink-0 text-xs text-muted">
|
||||
{record.trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</CommandList>
|
||||
{/*
|
||||
Outside the list, so a failed fetch reads as a status rather than as a
|
||||
result. Silently returning pages only would leave someone convinced
|
||||
their customer is not in the book.
|
||||
*/}
|
||||
{typing && records.isIncomplete ? (
|
||||
<p className="border-t border-border px-3 py-2 text-xs text-muted">
|
||||
{records.isUnavailable
|
||||
? 'Record search is unavailable just now — pages only.'
|
||||
: 'Some records could not be searched, so this list may be incomplete.'}
|
||||
</p>
|
||||
) : null}
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
/**
|
||||
* The sortable, paginated, column-choosable table.
|
||||
*
|
||||
* It is deliberately the *only* table primitive: pages that hand-roll a
|
||||
* `<table>` get none of this, and the divergence shows — Margin's own markup
|
||||
* cannot sort or paginate, and within Accounts the desktop empty state is a
|
||||
* bare row of text while the phone layout renders a full EmptyState for the
|
||||
* same condition. Loading, error and empty are therefore states this component
|
||||
* owns rather than states each adopting page invents, so the next page to move
|
||||
* across brings its query straight here.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -12,7 +23,16 @@ import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,7 +43,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui';
|
||||
import { EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,7 +64,24 @@ import {
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
/** One line of text for the empty case. `empty` supersedes it when given. */
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* The empty state in full — an `EmptyState` with an icon and, where there is
|
||||
* one, the action that would fill the table. Prefer it to `emptyMessage`:
|
||||
* a table that is empty because nobody has created a record yet should say
|
||||
* so and offer the way out, not print "No results." at someone.
|
||||
*/
|
||||
empty?: ReactNode;
|
||||
/**
|
||||
* The first load only — react-query's `isLoading`, never `isFetching`.
|
||||
* Blanking populated rows into skeletons on every background refetch is how
|
||||
* a table flickers under the reader's cursor.
|
||||
*/
|
||||
loading?: boolean;
|
||||
error?: Error | null;
|
||||
/** Wired to a retry button on the error state; omitted means no button. */
|
||||
onRetry?: () => void;
|
||||
filterColumn?: string;
|
||||
filterPlaceholder?: string;
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
@@ -54,6 +91,10 @@ export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage = 'No results.',
|
||||
empty,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
filterColumn,
|
||||
filterPlaceholder = 'Filter results',
|
||||
initialColumnVisibility = {},
|
||||
@@ -78,6 +119,16 @@ export function DataTable<TData, TValue>({
|
||||
});
|
||||
const activeFilter = filterColumn ? table.getColumn(filterColumn) : undefined;
|
||||
const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide());
|
||||
const rows = table.getRowModel().rows;
|
||||
const columnCount = Math.max(table.getVisibleLeafColumns().length, 1);
|
||||
// Precedence matters: an error that arrives mid-load must not be reported as
|
||||
// "no results", which is a true statement and a false explanation.
|
||||
const state = error ? 'error' : loading ? 'loading' : rows.length ? 'rows' : 'empty';
|
||||
// Only when there is no dataset at all. Disabling the filter whenever the
|
||||
// table looks empty would trap the reader inside a search that matched
|
||||
// nothing, with no way to clear it.
|
||||
const controlsDisabled = state === 'loading' || state === 'error';
|
||||
const filterValue = (activeFilter?.getFilterValue() as string | undefined) ?? '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -85,10 +136,11 @@ export function DataTable<TData, TValue>({
|
||||
{activeFilter ? (
|
||||
<Input
|
||||
type="search"
|
||||
value={(activeFilter.getFilterValue() as string | undefined) ?? ''}
|
||||
value={filterValue}
|
||||
onChange={(event) => activeFilter.setFilterValue(event.target.value)}
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
disabled={controlsDisabled}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
) : (
|
||||
@@ -96,7 +148,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="tap sm:ml-auto">
|
||||
<Button variant="outline" className="tap sm:ml-auto" disabled={controlsDisabled}>
|
||||
<SlidersHorizontal data-icon="inline-start" aria-hidden />
|
||||
Columns
|
||||
</Button>
|
||||
@@ -135,37 +187,101 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={Math.max(table.getVisibleLeafColumns().length, 1)} className="h-28 text-center text-muted">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{state === 'rows'
|
||||
? rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
|
||||
{/* Skeletons in the real grid, not one bar over the whole table:
|
||||
the column widths the reader is about to get are part of the
|
||||
answer, and settling into them costs nothing to show. */}
|
||||
{state === 'loading'
|
||||
? Array.from({ length: SKELETON_ROWS }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} aria-hidden>
|
||||
{Array.from({ length: columnCount }, (_, cell) => (
|
||||
<TableCell key={cell}>
|
||||
<Skeleton className="h-4 w-full max-w-[12rem]" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
|
||||
{state === 'error' ? (
|
||||
<MessageRow colSpan={columnCount}>
|
||||
<EmptyState
|
||||
icon={<TriangleAlert aria-hidden />}
|
||||
title="Results unavailable"
|
||||
description={error?.message}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" className="tap" onClick={onRetry}>
|
||||
<RefreshCw data-icon="inline-start" aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</MessageRow>
|
||||
) : null}
|
||||
|
||||
{state === 'empty' ? (
|
||||
<MessageRow colSpan={columnCount}>
|
||||
{/* A filter that matched nothing is not an empty table, and
|
||||
telling someone to create their first record when they have
|
||||
simply mistyped a search is how a product loses trust. */}
|
||||
{data.length > 0 ? (
|
||||
<EmptyState
|
||||
title="No results match"
|
||||
description="Nothing here matches the current filter."
|
||||
action={
|
||||
filterValue ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="tap"
|
||||
onClick={() => activeFilter?.setFilterValue('')}
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
(empty ?? <p className="py-10 text-sm text-muted">{emptyMessage}</p>)
|
||||
)}
|
||||
</MessageRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* "0 results · Page 1 of 1" while a request is still in flight is a
|
||||
count of something nobody has counted yet. */}
|
||||
<p className="text-sm text-muted" aria-live="polite">
|
||||
{table.getFilteredRowModel().rows.length} result
|
||||
{table.getFilteredRowModel().rows.length === 1 ? '' : 's'} · Page{' '}
|
||||
{table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
|
||||
{state === 'loading'
|
||||
? 'Loading results…'
|
||||
: state === 'error'
|
||||
? 'Results could not be loaded.'
|
||||
: `${table.getFilteredRowModel().rows.length} result${
|
||||
table.getFilteredRowModel().rows.length === 1 ? '' : 's'
|
||||
} · Page ${table.getState().pagination.pageIndex + 1} of ${Math.max(
|
||||
table.getPageCount(),
|
||||
1,
|
||||
)}`}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
||||
<Select
|
||||
value={String(table.getState().pagination.pageSize)}
|
||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-[7.5rem]" aria-label="Rows per page">
|
||||
<SelectValue />
|
||||
@@ -235,6 +351,26 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
/** Enough to read as a table settling in, few enough not to imply a page size. */
|
||||
const SKELETON_ROWS = 5;
|
||||
|
||||
/**
|
||||
* One cell spanning the grid, for the states that replace the rows.
|
||||
*
|
||||
* `h-40` rather than the rows' natural height so that loading, empty and error
|
||||
* occupy roughly the same space: a table that changes height as it resolves
|
||||
* pushes whatever sits beneath it around the screen.
|
||||
*/
|
||||
function MessageRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {
|
||||
return (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={colSpan} className="h-40 p-0 text-center align-middle">
|
||||
<div className="flex items-center justify-center">{children}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function columnLabel(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CircleStop,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
Send,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Bot, CircleStop, Database, Loader2, MessageCircleMore, Send, Sparkles, XCircle } from 'lucide-react';
|
||||
import { get } from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import {
|
||||
streamPiggyChat,
|
||||
PIGGY_MESSAGE_MAX_LENGTH,
|
||||
isRetryable,
|
||||
usePiggyConversation,
|
||||
type PiggyChatContext,
|
||||
type PiggyChatEvent,
|
||||
type PiggyChatTurn,
|
||||
// Aliased because the transcript viewport below is also called
|
||||
// `PiggyConversation`: one is the state a panel is driven by, the other is
|
||||
// the element it is drawn in, and they meet in this file only.
|
||||
type PiggyConversation as PiggyConversationState,
|
||||
type PiggyStatus,
|
||||
type TranscriptMessage,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } from '@/lib/piggy-suggestions';
|
||||
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
|
||||
import { PiggyMessageActions } from './piggy/message-actions';
|
||||
import { PiggyReasoning } from './piggy/reasoning';
|
||||
import { PiggyResponse } from './piggy/response';
|
||||
import { PiggyToolStep } from './piggy/tool';
|
||||
import { Badge, Button, EmptyState, cn } from './ui';
|
||||
import {
|
||||
Drawer,
|
||||
@@ -39,23 +39,13 @@ import {
|
||||
} from './ui/sheet';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TranscriptMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
tools?: ToolStep[];
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
/**
|
||||
* The composer starts counting down only near the cap. A counter that is
|
||||
* always on reads as a limit the user is expected to work within; one that
|
||||
* appears in the last few hundred characters reads as a warning, which is what
|
||||
* it is — past 4,000 the relay answers 400 and the send is lost.
|
||||
*/
|
||||
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;
|
||||
|
||||
export function PiggyAskButton({
|
||||
context,
|
||||
@@ -97,9 +87,28 @@ export function PiggyAskButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The height the workspace panel and its placeholder both take.
|
||||
*
|
||||
* Named once because the two must agree: a placeholder of a different height
|
||||
* makes the page jump the moment the status query answers. It is sized to land
|
||||
* just inside the page rather than just outside it — the panel scrolls, so a
|
||||
* page scrolling behind it means following an answer moves two things at once
|
||||
* and the composer drifts under the fold. Below `lg` the subtraction is larger:
|
||||
* the phone layout stacks the page header above and the tab bar below.
|
||||
*
|
||||
* The floor yields to the viewport rather than being a flat 32rem, because a
|
||||
* flat one is taller than a phone held sideways: at 852x393 the panel was 512px
|
||||
* inside a 393px window, which put the composer 230px below the fold on a page
|
||||
* whose only control is the composer. `min()` keeps the comfortable floor
|
||||
* everywhere it fits and stops claiming space that does not exist.
|
||||
*/
|
||||
const WORKSPACE_HEIGHT =
|
||||
'h-[calc(100dvh-19rem)] min-h-[min(32rem,calc(100dvh-11rem))] lg:h-[calc(100dvh-13rem)]';
|
||||
|
||||
export function PiggyChatWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
if (status.isLoading) return <div className="h-96 animate-pulse rounded-xl bg-surface-2" />;
|
||||
if (status.isLoading) return <div className={cn(WORKSPACE_HEIGHT, 'animate-pulse rounded-xl bg-surface-2')} />;
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -113,7 +122,7 @@ export function PiggyChatWorkspace() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
|
||||
return <PiggyChatPanel className={cn(WORKSPACE_HEIGHT, 'rounded-xl border border-border bg-surface')} />;
|
||||
}
|
||||
|
||||
export function ResponsivePiggyChat({
|
||||
@@ -131,6 +140,11 @@ export function ResponsivePiggyChat({
|
||||
// which meant a 900px tablet got the desktop side sheet sliding in behind
|
||||
// the phone tab bar it was still showing.
|
||||
const desktop = !useIsMobile();
|
||||
// Held here, one level above the overlay, because both the Sheet and the
|
||||
// Drawer unmount their children when they close. With the thread inside,
|
||||
// dismissing the overlay for two seconds to look at the record underneath
|
||||
// destroyed the conversation, the draft and any answer still streaming.
|
||||
const conversation = usePiggyConversation({ context, initialPrompt });
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -139,7 +153,7 @@ export function ResponsivePiggyChat({
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
<PiggyChatPanel conversation={conversation} context={context} autoFocusComposer className="min-h-0 flex-1" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -151,7 +165,9 @@ export function ResponsivePiggyChat({
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
{/* No autofocus on the phone: focusing the composer raises the keyboard
|
||||
over most of the drawer before the user has read anything. */}
|
||||
<PiggyChatPanel conversation={conversation} context={context} className="min-h-0 flex-1" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
@@ -171,117 +187,188 @@ export function PiggyChatPanel({
|
||||
initialPrompt = '',
|
||||
className,
|
||||
compact = false,
|
||||
conversation,
|
||||
autoFocusComposer = false,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* A conversation owned by something that outlives this panel. The overlays
|
||||
* pass one because they unmount their children on close; the dock and the
|
||||
* workspace page stay mounted and let the panel keep its own.
|
||||
*/
|
||||
conversation?: PiggyConversationState;
|
||||
autoFocusComposer?: boolean;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const [running, setRunning] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
// Called unconditionally — hooks must be — and then ignored when a
|
||||
// conversation was handed in. It holds no resources until something is sent.
|
||||
const own = usePiggyConversation({ context, initialPrompt });
|
||||
const { messages, draft, setDraft, running, send, stop, retry } = conversation ?? own;
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// The dock keeps one. Nothing fits two on a line at 22rem, so the second is a
|
||||
// whole extra row of chrome taken off the shortest transcript of the three.
|
||||
const followUps = messages.length
|
||||
? piggyFollowUps(context, userQuestions(messages)).slice(0, compact ? 1 : PIGGY_FOLLOW_UP_COUNT)
|
||||
: [];
|
||||
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: running ? 'auto' : 'smooth' }), [messages, running]);
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || running) return;
|
||||
const user: TranscriptMessage = { id: crypto.randomUUID(), role: 'user', content: message };
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history: PiggyChatTurn[] = messages
|
||||
.filter((entry) => entry.content.trim())
|
||||
.slice(-20)
|
||||
.map((entry) => ({ role: entry.role, content: entry.content }));
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
user,
|
||||
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
|
||||
]);
|
||||
setDraft('');
|
||||
setRunning(true);
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId ? applyEvent(entry, event) : entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId
|
||||
? { ...entry, pending: false, error: error instanceof Error ? error.message : 'Piggy chat failed.' }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!autoFocusComposer) return;
|
||||
const composer = composerRef.current;
|
||||
if (!composer) return;
|
||||
// Radix moves focus to the first tabbable element in the sheet — its own
|
||||
// close button — from a layout effect that runs after this one, so a
|
||||
// synchronous focus here is immediately undone. A frame later it is not.
|
||||
// Without this, "Ask Piggy" opened with a prefilled prompt and put the
|
||||
// caret nowhere.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
composer.focus();
|
||||
composer.setSelectionRange(composer.value.length, composer.value.length);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [autoFocusComposer]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{/* The viewport owns the scrolling, the log role and the follow-the-tail
|
||||
behaviour. There is deliberately no scroll effect left in this file:
|
||||
the `scrollIntoView` it replaced fired once per streamed token, which
|
||||
made re-reading an earlier answer mid-stream impossible and dragged
|
||||
the page behind the dock down with it. Gutters go on the scrollport
|
||||
so they scroll with the transcript rather than fencing it. */}
|
||||
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context && context.type !== 'page'
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
|
||||
).map((suggestion) => (
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PiggyStarters compact={compact} context={context} onAsk={send} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} compact={compact} />)}
|
||||
<div ref={bottomRef} />
|
||||
<div
|
||||
// The column is capped at a reading measure rather than filling the
|
||||
// page: at 1440 the workspace panel is over a thousand pixels wide,
|
||||
// and a markdown answer set across all of it is a wall.
|
||||
// The busy state that holds the announcement back belongs on the
|
||||
// live region root, which is the viewport above, not on this column.
|
||||
className={cn('mx-auto flex w-full max-w-3xl flex-col', compact ? 'gap-5' : 'gap-6')}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
compact={compact}
|
||||
onRetry={isRetryable(message) && !running ? () => retry(message.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PiggyConversationScrollButton />
|
||||
</PiggyConversation>
|
||||
|
||||
<form className={cn('border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); send(); }}>
|
||||
{followUps.length ? (
|
||||
// Wrapped, not scrolled sideways. A row of whole questions is wider
|
||||
// than every surface but the full page, and a chip sliced off by the
|
||||
// panel edge reads as a rendering fault — where a second line reads
|
||||
// as a second suggestion.
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
|
||||
{followUps.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
// Dead rather than absent while a turn runs: `send` refuses
|
||||
// anything mid-stream, and a row that vanishes and returns
|
||||
// moves the composer under the user's thumb.
|
||||
disabled={running}
|
||||
// Each chip is one line whatever the width, so the row can only
|
||||
// ever be as tall as the number of suggestions.
|
||||
title={suggestion}
|
||||
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
|
||||
onClick={() => send(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
|
||||
className="min-h-11 max-h-36 resize-none"
|
||||
placeholder="Ask about capacity, margin, paper or next actions…"
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={() => abortRef.current?.abort()}><CircleStop aria-hidden /></Button>
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><CircleStop aria-hidden /></Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] leading-4 text-muted">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
<div className="mt-2 flex items-baseline gap-2 text-[11px] leading-4 text-muted">
|
||||
<p className="flex-1 text-center">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
{/* No live region: this changes on every keystroke, and the cap is
|
||||
already announced from the textarea's own `maxLength`. */}
|
||||
{draft.length >= COUNTER_VISIBLE_FROM ? (
|
||||
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
|
||||
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The blank transcript.
|
||||
*
|
||||
* The openers come from `piggySuggestions`, which chooses them by the one read
|
||||
* tool this context resolves to rather than by what the page is called — so
|
||||
* every line offered here is one Piggy can actually ground. The dock takes
|
||||
* three of them: at 22rem each opener wraps to two lines, and a fourth turns a
|
||||
* quick way in into a page of text to read before typing.
|
||||
*/
|
||||
function PiggyStarters({
|
||||
context,
|
||||
compact,
|
||||
onAsk,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
compact: boolean;
|
||||
onAsk: (text: string) => void;
|
||||
}) {
|
||||
const suggestions = piggySuggestions(context);
|
||||
return (
|
||||
// `flex-1`, not `h-full`: the conversation's content element is sized by its
|
||||
// children, so a percentage height here resolves to nothing.
|
||||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center text-center">
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
|
||||
// Sends rather than fills the composer. Filling it looked like
|
||||
// nothing had happened, so the chip read as a dead control.
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the user has already asked, so a follow-up chip cannot offer back a
|
||||
* question that is sitting in the transcript above it. */
|
||||
function userQuestions(messages: TranscriptMessage[]): string[] {
|
||||
return messages.filter((message) => message.role === 'user').map((message) => message.content);
|
||||
}
|
||||
|
||||
/** A page context has no id and its `type` is the literal 'page', which reads
|
||||
* as nothing useful in a badge — show the route the dock is following. */
|
||||
function contextLabel(context: PiggyChatContext): string {
|
||||
@@ -290,54 +377,76 @@ function contextLabel(context: PiggyChatContext): string {
|
||||
return context.type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function ChatMessage({ message, compact = false }: { message: TranscriptMessage; compact?: boolean }) {
|
||||
function ChatMessage({
|
||||
message,
|
||||
compact = false,
|
||||
onRetry,
|
||||
}: {
|
||||
message: TranscriptMessage;
|
||||
compact?: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
if (message.role === 'user') {
|
||||
return <div className={cn('ml-auto rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'max-w-[94%] px-3' : 'max-w-[88%] px-4')}><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
return (
|
||||
<div className={cn('ml-auto flex flex-col items-end', compact ? 'max-w-[94%]' : 'max-w-[88%]')}>
|
||||
<div className={cn('rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'px-3' : 'px-4')}>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
{/* The question is still on screen after a failed send, so the user's
|
||||
words are never lost — but the bubble alone reads as sent. */}
|
||||
{message.failed ? <p className="mt-1 text-[11px] leading-4 text-muted">Not sent</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
|
||||
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.reasoning ? (
|
||||
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2 font-medium"><Brain aria-hidden /> Reasoning</summary>
|
||||
<p className="whitespace-pre-wrap px-3 pb-3">{message.reasoning}</p>
|
||||
</details>
|
||||
{/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
|
||||
on, and it is repeated here on purpose: the footer marks itself, so
|
||||
without this the only way to find Copy is to sweep the pointer across
|
||||
the blank strip the hidden buttons occupy. A named group matches on
|
||||
any hovered ancestor, so hovering the answer reveals them. */}
|
||||
<div className="group/actions min-w-0 flex-1">
|
||||
{/* Working, then evidence, then the answer, then what the answer cost.
|
||||
Everything above the answer is deliberately smaller and quieter than
|
||||
it: this is a chain of custody, and the reader came for the last
|
||||
link in it. `PiggyReasoning` draws nothing when there is nothing to
|
||||
show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
|
||||
<PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
|
||||
{message.tools?.length ? (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{message.tools.map((tool) => (
|
||||
<PiggyToolStep key={tool.id} step={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.tools?.length ? <ToolTimeline tools={message.tools} /> : null}
|
||||
{message.content ? <p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p> : null}
|
||||
{message.pending && !message.content ? <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div> : null}
|
||||
{message.error ? <div className="mt-2 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
{message.content ? <PiggyResponse content={message.content} /> : null}
|
||||
{/* Only while the turn has produced nothing at all. Once a tool chip or
|
||||
the reasoning panel is on screen, the turn is visibly working and a
|
||||
second spinner saying so is noise. */}
|
||||
{message.pending && !message.content && !message.tools?.length && !message.reasoning?.trim() ? (
|
||||
<div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div>
|
||||
) : null}
|
||||
{/* The state chip in the footer below names a stopped or truncated
|
||||
turn. An error is different: it carries a sentence the chip cannot,
|
||||
and it is the one thing here allowed to be loud. */}
|
||||
{message.error ? <div className="mt-3 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
<PiggyMessageActions message={message} onRetry={onRetry} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolTimeline({ tools }: { tools: ToolStep[] }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{tools.map((tool) => (
|
||||
<details key={tool.id} className="rounded-lg border border-border text-xs">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2">
|
||||
{tool.state === 'running' ? <Loader2 className="animate-spin text-muted" aria-hidden /> : tool.state === 'succeeded' ? <CheckCircle2 className="text-positive" aria-hidden /> : <XCircle className="text-danger" aria-hidden />}
|
||||
<span className="font-medium">{toolLabel(tool.name)}</span>
|
||||
<span className="ml-auto text-muted">{tool.state === 'running' ? 'Running' : tool.state === 'succeeded' ? 'Complete' : 'Failed'}</span>
|
||||
</summary>
|
||||
<pre className="overflow-x-auto border-t border-border p-3 text-[11px] text-muted">{tool.error ?? JSON.stringify(tool.arguments, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
|
||||
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
|
||||
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
|
||||
if (event.type === 'tool_call') return { ...message, tools: [...(message.tools ?? []), { id: event.id, name: event.name, arguments: event.arguments, state: 'running' }] };
|
||||
if (event.type === 'tool_result') return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', error: event.error } : tool) };
|
||||
if (event.type === 'done') return { ...message, pending: false };
|
||||
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
||||
return message;
|
||||
/**
|
||||
* Whether the reasoning panel should read as live.
|
||||
*
|
||||
* The stream has no event for "thinking finished", but the model writes its
|
||||
* scratch work before its answer — so the first content token is the end of the
|
||||
* thinking, and waiting for the turn to settle instead would leave the panel
|
||||
* open, uncollapsed and unmeasured underneath the answer it preceded.
|
||||
*/
|
||||
function isThinking(message: TranscriptMessage): boolean {
|
||||
return Boolean(message.pending && message.reasoning?.trim() && !message.content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +458,3 @@ function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): Transcri
|
||||
export function usePiggyStatus() {
|
||||
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@ import {
|
||||
CUSTOMER_SEGMENTS,
|
||||
DEMAND_STAGE_LABELS,
|
||||
DEMAND_STAGES,
|
||||
GPU_SOCKETS,
|
||||
INTERCONNECT_TYPES,
|
||||
PRODUCT_LINES,
|
||||
SECURITY_TIERS,
|
||||
SUPPLIER_TYPES,
|
||||
SUPPLY_STAGE_LABELS,
|
||||
SUPPLY_STAGES,
|
||||
type AccountSide,
|
||||
type ActivityType,
|
||||
type Team,
|
||||
} from '@pig/core';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { LoaderCircle, Lock } from 'lucide-react';
|
||||
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
@@ -47,8 +51,8 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, get, patch, post } from '@/lib/api';
|
||||
import { can, type PermissionIdentity } from '@/lib/permissions';
|
||||
import { ApiError, compactNumber, get, patch, post, unitPrice } from '@/lib/api';
|
||||
import { can, canAny, type PermissionIdentity } from '@/lib/permissions';
|
||||
|
||||
export interface AccountRecord {
|
||||
id: string;
|
||||
@@ -159,10 +163,26 @@ const optionalNonnegativeNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
'Enter zero or a positive number.',
|
||||
);
|
||||
const optionalProbability = z.string().refine(
|
||||
const optionalPercentage = z.string().refine(
|
||||
(value) => value === '' || (Number(value) >= 0 && Number(value) <= 100),
|
||||
'Use a percentage from 0 to 100.',
|
||||
);
|
||||
const requiredPositiveNumber = z.string().refine(
|
||||
(value) => Number.isFinite(Number(value)) && Number(value) > 0,
|
||||
'Enter a number greater than zero.',
|
||||
);
|
||||
const requiredNonnegativeNumber = z.string().refine(
|
||||
(value) => Number.isFinite(Number(value)) && Number(value) >= 0,
|
||||
'Enter zero or a positive amount.',
|
||||
);
|
||||
const requiredWholeNumber = z.string().refine(
|
||||
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
|
||||
'Enter a whole number greater than zero.',
|
||||
);
|
||||
const optionalWholeNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isInteger(Number(value)) && Number(value) >= 0),
|
||||
'Enter a whole number of days or leave it blank.',
|
||||
);
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required.'),
|
||||
@@ -209,7 +229,7 @@ const demandFormSchema = z.object({
|
||||
tcv: optionalNonnegativeNumber,
|
||||
currency: z.string().trim().length(3, 'Use a three-letter currency code.'),
|
||||
termMonths: optionalPositiveNumber,
|
||||
probability: optionalProbability,
|
||||
probability: optionalPercentage,
|
||||
expectedCloseDate: z.string(),
|
||||
closedReason: z.string(),
|
||||
msaExecuted: z.boolean(),
|
||||
@@ -237,6 +257,112 @@ const supplyFormSchema = z.object({
|
||||
});
|
||||
type SupplyForm = z.infer<typeof supplyFormSchema>;
|
||||
|
||||
/**
|
||||
* Hours between two `datetime-local` values, or null while either is unset or
|
||||
* inverted. Derived from the same instants that get POSTed, so the envelope
|
||||
* shown to the buyer cannot disagree with the one the server checks — including
|
||||
* across a daylight-saving boundary, where a 90-day block is not 2,160 hours.
|
||||
*/
|
||||
function windowHours(startsAt: string, endsAt: string): number | null {
|
||||
const start = new Date(startsAt).getTime();
|
||||
const end = new Date(endsAt).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
|
||||
return (end - start) / 3_600_000;
|
||||
}
|
||||
|
||||
/** The most GPU-hours a flat (unshaped) block of this size can hold. */
|
||||
function flatEnvelopeGpuHours(startsAt: string, endsAt: string, gpuCount: string): number | null {
|
||||
const hours = windowHours(startsAt, endsAt);
|
||||
const count = Number(gpuCount);
|
||||
if (hours === null || !Number.isFinite(count) || count <= 0) return null;
|
||||
return hours * count;
|
||||
}
|
||||
|
||||
const commitmentFormSchema = z
|
||||
.object({
|
||||
accountId: z.string().uuid('Select a supplier account.'),
|
||||
supplyDealId: z.string(),
|
||||
name: z.string().trim().min(1, 'Name the commitment.').max(200, 'Keep the name under 200 characters.'),
|
||||
gpuType: z.string().trim().min(1, 'GPU type is required.'),
|
||||
socket: z.string(),
|
||||
gpuCount: requiredWholeNumber,
|
||||
interconnectType: z.enum(INTERCONNECT_TYPES),
|
||||
securityTier: z.enum(SECURITY_TIERS),
|
||||
startsAt: z.string().min(1, 'Start is required.'),
|
||||
endsAt: z.string().min(1, 'End is required.'),
|
||||
totalGpuHours: requiredPositiveNumber,
|
||||
costPerGpuHour: requiredNonnegativeNumber,
|
||||
currency: z.string().trim().length(3, 'Use a three-letter currency code.'),
|
||||
isContiguous: z.boolean(),
|
||||
oversubscriptionPct: z.string().refine(
|
||||
(value) => value === '' || (Number(value) >= 0 && Number(value) <= 1000),
|
||||
'Use a percentage from 0 to 1000.',
|
||||
),
|
||||
minimumSpend: optionalNonnegativeNumber,
|
||||
takeOrPayFloorPct: optionalPercentage,
|
||||
prepaidPct: optionalPercentage,
|
||||
prepaidAmount: optionalNonnegativeNumber,
|
||||
noticeDays: optionalWholeNumber,
|
||||
isAutoRenew: z.boolean(),
|
||||
notes: z.string().max(10_000, 'Keep notes under 10,000 characters.'),
|
||||
})
|
||||
.superRefine((values, context) => {
|
||||
if (values.startsAt && values.endsAt && windowHours(values.startsAt, values.endsAt) === null) {
|
||||
context.addIssue({ code: 'custom', path: ['endsAt'], message: 'End must be after start.' });
|
||||
}
|
||||
const envelope = flatEnvelopeGpuHours(values.startsAt, values.endsAt, values.gpuCount);
|
||||
const total = Number(values.totalGpuHours);
|
||||
// The server refuses this outright, and it is the easy mistake to make:
|
||||
// contracted GPU-hours are a total for the term, not a per-day figure.
|
||||
if (envelope !== null && total - envelope > 1e-7) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalGpuHours'],
|
||||
message: `${values.gpuCount} GPUs over this window hold at most ${Math.floor(envelope).toLocaleString()} GPU-hours.`,
|
||||
});
|
||||
}
|
||||
if (Math.abs(total * 100 - Math.round(total * 100)) > 1e-7) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalGpuHours'],
|
||||
message: 'GPU-hours may have at most two decimal places.',
|
||||
});
|
||||
}
|
||||
});
|
||||
type CommitmentForm = z.infer<typeof commitmentFormSchema>;
|
||||
|
||||
/**
|
||||
* The types a person logs by hand.
|
||||
*
|
||||
* The rest of `ACTIVITY_TYPES` are written by the system — a stage change, a
|
||||
* contract event, an agent's action — and offering them here would let a typed
|
||||
* note claim machine provenance in a timeline that is read as an audit trail.
|
||||
*/
|
||||
const LOGGABLE_ACTIVITY_TYPES = [
|
||||
'call',
|
||||
'meeting',
|
||||
'email',
|
||||
'note',
|
||||
] as const satisfies readonly ActivityType[];
|
||||
|
||||
const ACTIVITY_TYPE_HINTS: Record<(typeof LOGGABLE_ACTIVITY_TYPES)[number], string> = {
|
||||
call: 'What was said, and what was agreed.',
|
||||
meeting: 'Who attended, and what changed as a result.',
|
||||
email: 'Paste the substance, not the thread.',
|
||||
note: 'Something learned that belongs on the record.',
|
||||
};
|
||||
|
||||
const activityFormSchema = z.object({
|
||||
type: z.enum(LOGGABLE_ACTIVITY_TYPES),
|
||||
accountId: z.string().uuid('Select the account this belongs to.'),
|
||||
relatedDeal: z.string(),
|
||||
contactId: z.string(),
|
||||
subject: z.string().trim().min(1, 'Give it a subject.').max(200, 'Keep the subject under 200 characters.'),
|
||||
body: z.string().max(8_000, 'Keep the detail under 8,000 characters.'),
|
||||
occurredAt: z.string().min(1, 'Record when it happened.'),
|
||||
});
|
||||
type ActivityForm = z.infer<typeof activityFormSchema>;
|
||||
|
||||
const label = (value: string) => value.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
|
||||
const blankToNull = (value: string) => value.trim() || null;
|
||||
const optionalNumber = (value: string) => value === '' ? null : Number(value);
|
||||
@@ -251,6 +377,39 @@ function canWriteSide(identity: PermissionIdentity | undefined, side: AccountSid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `requireSidePermission` on the server: an activity is a supply-side
|
||||
* or demand-side event, and a `both` account admits either. Asking the same
|
||||
* question here means the control is absent rather than answering 403.
|
||||
*
|
||||
* Exported so a page gating a "Log activity" affordance on a specific account
|
||||
* asks exactly the question the server will ask about that account.
|
||||
*/
|
||||
export function canLogAgainstSide(identity: PermissionIdentity | undefined, side: AccountSide): boolean {
|
||||
const teams: Team[] = side === 'both' ? ['supply', 'demand'] : [side];
|
||||
return teams.some((team) => can(identity, 'activity:write', team));
|
||||
}
|
||||
|
||||
/**
|
||||
* `datetime-local` carries no time zone: it wants wall-clock time. Subtracting
|
||||
* the offset before slicing is what stops the field opening hours adrift of
|
||||
* the clock on the wall, which is the one thing a logged call must match.
|
||||
*/
|
||||
function localDateTimeValue(date: Date): string {
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single "related deal" control writes one of two columns. Encoding the side
|
||||
* into the option value keeps that a choice rather than two selects the user
|
||||
* could fill in contradictory ways.
|
||||
*/
|
||||
function dealReference(value: string): { demandDealId?: string; supplyDealId?: string } {
|
||||
const [side, id] = value.split(':');
|
||||
if (!id) return {};
|
||||
return side === 'supply' ? { supplyDealId: id } : { demandDealId: id };
|
||||
}
|
||||
|
||||
function accountDefaults(record?: AccountRecord | null): AccountForm {
|
||||
return {
|
||||
name: record?.name ?? '',
|
||||
@@ -582,6 +741,249 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
|
||||
);
|
||||
}
|
||||
|
||||
export interface CapacityCommitmentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
totalGpuHours: string | number;
|
||||
costPerGpuHourCents: number;
|
||||
}
|
||||
|
||||
interface CreateSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
identity?: PermissionIdentity;
|
||||
defaultAccountId?: string;
|
||||
}
|
||||
|
||||
function commitmentDefaults(accountId?: string): CommitmentForm {
|
||||
return {
|
||||
accountId: accountId ?? '', supplyDealId: '', name: '', gpuType: '', socket: '', gpuCount: '',
|
||||
interconnectType: 'Unknown', securityTier: 'secure_cloud', startsAt: '', endsAt: '', totalGpuHours: '',
|
||||
costPerGpuHour: '', currency: 'USD', isContiguous: true, oversubscriptionPct: '', minimumSpend: '',
|
||||
takeOrPayFloorPct: '', prepaidPct: '', prepaidAmount: '', noticeDays: '', isAutoRenew: false, notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording capacity we have committed to buy.
|
||||
*
|
||||
* This is the root record of the supply side: availability, the matcher, every
|
||||
* margin figure and the idle-capacity alerts are all derived from these blocks,
|
||||
* so until one exists the product has nothing to show. `commitment:write` is
|
||||
* supply-team and lead-and-above by policy, which is why the team is named here
|
||||
* rather than inferred — see TEAM_CAPABILITY_RULES.
|
||||
*/
|
||||
export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId }: CreateSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const writable = can(identity, 'commitment:write', 'supply');
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'commitment-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts?side=supply'), enabled: open });
|
||||
const { data: supplyBoard } = useQuery({ queryKey: ['/api/deals/supply'], queryFn: () => get<{ deals: { deal: SupplyDealRecord }[] }>('/api/deals/supply'), enabled: open });
|
||||
const form = useForm<CommitmentForm>({ resolver: zodResolver(commitmentFormSchema), defaultValues: commitmentDefaults(defaultAccountId) });
|
||||
useEffect(() => { if (open) form.reset(commitmentDefaults(defaultAccountId)); }, [defaultAccountId, form, open]);
|
||||
const accountId = form.watch('accountId');
|
||||
const dealOptions = (supplyBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId);
|
||||
const envelope = flatEnvelopeGpuHours(form.watch('startsAt'), form.watch('endsAt'), form.watch('gpuCount'));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: CommitmentForm) =>
|
||||
post<CapacityCommitmentRecord>('/api/commitments', {
|
||||
accountId: values.accountId, supplyDealId: blankToNull(values.supplyDealId), name: values.name.trim(),
|
||||
gpuType: values.gpuType.trim(), socket: blankToNull(values.socket), gpuCount: Number(values.gpuCount),
|
||||
interconnectType: values.interconnectType, securityTier: values.securityTier,
|
||||
startsAt: new Date(values.startsAt).toISOString(), endsAt: new Date(values.endsAt).toISOString(),
|
||||
totalGpuHours: Number(values.totalGpuHours), costPerGpuHourCents: Math.round(Number(values.costPerGpuHour) * 100),
|
||||
currency: values.currency.toUpperCase(), isContiguous: values.isContiguous,
|
||||
oversubscriptionPct: values.oversubscriptionPct === '' ? 0 : Number(values.oversubscriptionPct),
|
||||
minimumSpendCents: cents(values.minimumSpend), takeOrPayFloorPct: optionalNumber(values.takeOrPayFloorPct),
|
||||
prepaidPct: optionalNumber(values.prepaidPct), prepaidAmountCents: cents(values.prepaidAmount),
|
||||
noticeDays: optionalNumber(values.noticeDays), isAutoRenew: values.isAutoRenew, notes: blankToNull(values.notes),
|
||||
}),
|
||||
onSuccess: async (commitment) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['availability'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['commitments'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['margin'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] }),
|
||||
]);
|
||||
// The block is only worth recording because it can now be sold, so say
|
||||
// that rather than "saved" — the seller's next move is the matcher.
|
||||
toast.success('Capacity commitment recorded', {
|
||||
description: `${compactNumber(Number(commitment.totalGpuHours))} GPU-hrs at ${unitPrice(commitment.costPerGpuHourCents)}/GPU-hr are now sellable and carried in margin.`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Supply · committed capacity" title="Record capacity commitment" description="Capacity we have contracted to buy. Availability, the matcher and every margin figure are derived from these blocks.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
{writable ? null : <PermissionNotice>Recording committed capacity needs supply-team lead access. A platform administrator can grant it.</PermissionNotice>}
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="accountId" label="Supplier account" className="sm:col-span-2" options={(accountData ?? []).map((account) => ({ value: account.id, label: account.name }))} />
|
||||
<SelectField control={form.control} name="supplyDealId" label="Originating supply deal" optional className="sm:col-span-2" options={dealOptions.map((row) => ({ value: row.deal.id, label: row.deal.name }))} />
|
||||
<TextField control={form.control} name="name" label="Commitment name" placeholder="CoreWeave H100 · Q4 block" className="sm:col-span-2" />
|
||||
{/* Hardware identifiers are case-sensitive upstream; correcting
|
||||
them for the user would produce silent mismatches. */}
|
||||
<TextField control={form.control} name="gpuType" label="GPU type" placeholder="H100_80GB" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
|
||||
<TextField control={form.control} name="gpuCount" label="GPU count" inputMode="numeric" />
|
||||
<SelectField control={form.control} name="socket" label="Socket" optional options={GPU_SOCKETS.map((value) => ({ value, label: value }))} />
|
||||
<SelectField control={form.control} name="interconnectType" label="Interconnect" options={INTERCONNECT_TYPES.map((value) => ({ value, label: value }))} />
|
||||
<SelectField control={form.control} name="securityTier" label="Security tier" options={SECURITY_TIERS.map((value) => ({ value, label: label(value) }))} />
|
||||
<SwitchField control={form.control} name="isContiguous" label="Contiguous block" description="Not one GPU count split across halls." />
|
||||
</FieldGrid>
|
||||
<Section title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
|
||||
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
|
||||
<TextField control={form.control} name="totalGpuHours" label="Contracted GPU-hours" inputMode="decimal" description={envelope === null ? 'Set the window and GPU count to see the flat envelope.' : `A flat block this size holds ${compactNumber(envelope)} GPU-hrs at most.`} />
|
||||
<TextField control={form.control} name="costPerGpuHour" label="Cost $ / GPU-hr" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="currency" label="Currency" maxLength={3} />
|
||||
<TextField control={form.control} name="oversubscriptionPct" label="Oversubscription allowance (%)" inputMode="decimal" description="Leave blank unless the contract permits selling above the envelope." />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<Section title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="minimumSpend" label="Minimum spend" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="takeOrPayFloorPct" label="Take-or-pay floor (%)" inputMode="decimal" />
|
||||
<TextField control={form.control} name="prepaidPct" label="Prepaid share (%)" inputMode="decimal" />
|
||||
<TextField control={form.control} name="prepaidAmount" label="Prepaid amount" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="noticeDays" label="Notice to exit (days)" inputMode="numeric" />
|
||||
<SwitchField control={form.control} name="isAutoRenew" label="Auto-renews" description="Renewal alerting depends on this being honest." />
|
||||
<TextAreaField control={form.control} name="notes" label="Commercial notes" className="sm:col-span-2" description="Caveats a seller would need before promising this capacity." />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Record commitment" />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function activityDefaults(accountId?: string): ActivityForm {
|
||||
return {
|
||||
type: 'call', accountId: accountId ?? '', relatedDeal: '', contactId: '', subject: '', body: '',
|
||||
occurredAt: localDateTimeValue(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope `POST /api/activities` answers with. A synced event that was
|
||||
* already logged is a success with nothing inserted, so the row can be null —
|
||||
* and the field is optional here because the client must not break if that
|
||||
* response shape is ever tightened.
|
||||
*/
|
||||
interface LoggedActivity {
|
||||
activity: { id: string } | null;
|
||||
deduplicated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging what a person did.
|
||||
*
|
||||
* Every UI mutation already writes a derived note, so the timeline is never
|
||||
* frozen — but calls, meetings and emails are the entries a human recognises,
|
||||
* and until this existed they could only be written over MCP. `activity:write`
|
||||
* is held per team and checked twice on the server: once for the principal, and
|
||||
* once against the side of the account named here, which is why the account is
|
||||
* required rather than optional.
|
||||
*/
|
||||
export function LogActivitySheet({ open, onOpenChange, identity, defaultAccountId, defaultContactId }: CreateSheetProps & { defaultContactId?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'activity-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts'), enabled: open });
|
||||
const { data: contactRows } = useQuery({ queryKey: ['contacts', 'activity-record-options'], queryFn: () => get<ContactRow[]>('/api/contacts'), enabled: open });
|
||||
const { data: demandBoard } = useQuery({ queryKey: ['/api/deals/demand'], queryFn: () => get<{ deals: { deal: DemandDealRecord }[] }>('/api/deals/demand'), enabled: open });
|
||||
const { data: supplyBoard } = useQuery({ queryKey: ['/api/deals/supply'], queryFn: () => get<{ deals: { deal: SupplyDealRecord }[] }>('/api/deals/supply'), enabled: open });
|
||||
const form = useForm<ActivityForm>({ resolver: zodResolver(activityFormSchema), defaultValues: activityDefaults(defaultAccountId) });
|
||||
useEffect(() => {
|
||||
// Reset on open rather than on mount so the timestamp is the moment the
|
||||
// sheet was opened, not the moment the page was first rendered.
|
||||
if (open) form.reset({ ...activityDefaults(defaultAccountId), contactId: defaultContactId ?? '' });
|
||||
}, [defaultAccountId, defaultContactId, form, open]);
|
||||
|
||||
const loggableAccounts = useMemo(() => (accountData ?? []).filter((account) => canLogAgainstSide(identity, account.side)), [accountData, identity]);
|
||||
const accountId = form.watch('accountId');
|
||||
const type = form.watch('type');
|
||||
// Two questions, and both have to be asked. `canAny` covers the person who
|
||||
// holds the capability nowhere, whose account list is empty and who would
|
||||
// otherwise see an enabled button before choosing anything; the membership
|
||||
// test covers the account whose side they cannot write.
|
||||
const permitted = canAny(identity, 'activity:write');
|
||||
const writable =
|
||||
permitted && (accountId === '' || loggableAccounts.some((account) => account.id === accountId));
|
||||
const contactOptions = (contactRows ?? []).filter((row) => row.contact.accountId === accountId);
|
||||
const dealOptions = [
|
||||
...(demandBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId).map((row) => ({ value: `demand:${row.deal.id}`, label: `${row.deal.name} · demand` })),
|
||||
...(supplyBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId).map((row) => ({ value: `supply:${row.deal.id}`, label: `${row.deal.name} · supply` })),
|
||||
];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: ActivityForm) =>
|
||||
post<LoggedActivity>('/api/activities', {
|
||||
type: values.type,
|
||||
accountId: values.accountId,
|
||||
// Omitted rather than null: the endpoint accepts these keys only as
|
||||
// UUIDs, and a null would be rejected as an invalid activity.
|
||||
contactId: values.contactId || undefined,
|
||||
subject: values.subject.trim(),
|
||||
body: values.body.trim() || undefined,
|
||||
occurredAt: new Date(values.occurredAt).toISOString(),
|
||||
...dealReference(values.relatedDeal),
|
||||
}),
|
||||
onSuccess: async (result, values) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['growth'] }),
|
||||
]);
|
||||
if (result?.deduplicated) {
|
||||
toast.success('Already on the timeline', { description: 'An identical event had already been synced, so nothing was added.' });
|
||||
} else {
|
||||
toast.success(`${label(values.type)} logged`, { description: 'It is on the account timeline and has moved the last-activity date.' });
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Timeline" title="Log activity" description="What actually happened with a counterparty. Record changes write their own notes; this is for the conversation behind them.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
{writable ? null : (
|
||||
<PermissionNotice>
|
||||
{permitted
|
||||
? 'Logging activity against this account needs write access to its side of the book. Choose another account, or ask a platform administrator.'
|
||||
: 'Logging activity needs write access to one side of the book. A platform administrator can grant it.'}
|
||||
</PermissionNotice>
|
||||
)}
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="type" label="Type" options={LOGGABLE_ACTIVITY_TYPES.map((value) => ({ value, label: label(value) }))} />
|
||||
<TextField control={form.control} name="occurredAt" label="Happened at" type="datetime-local" />
|
||||
<SelectField control={form.control} name="accountId" label="Account" className="sm:col-span-2" options={loggableAccounts.map((account) => ({ value: account.id, label: `${account.name} · ${label(account.side)}` }))} />
|
||||
<TextField control={form.control} name="subject" label="Subject" placeholder="Pricing call on the Q4 renewal" className="sm:col-span-2" />
|
||||
<TextAreaField control={form.control} name="body" label="Detail" description={ACTIVITY_TYPE_HINTS[type]} className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
<Section title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="relatedDeal" label="Related deal" optional options={dealOptions} />
|
||||
<SelectField control={form.control} name="contactId" label="Contact involved" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Log activity" />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordSheet({ open, onOpenChange, category, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; category: string; title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -602,13 +1004,13 @@ function SheetBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">{children}</div>;
|
||||
}
|
||||
|
||||
function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) {
|
||||
function SheetActions({ pending, onCancel, label: actionLabel, disabled = false }: { pending: boolean; onCancel(): void; label: string; disabled?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" className="h-11" disabled={pending}>
|
||||
<Button type="submit" className="h-11" disabled={pending || disabled}>
|
||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : null}
|
||||
{pending ? 'Saving…' : actionLabel}
|
||||
</Button>
|
||||
@@ -617,6 +1019,21 @@ function SheetActions({ pending, onCancel, label: actionLabel }: { pending: bool
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown inside a sheet the caller should not have been able to open. The page
|
||||
* gates the affordance; this exists so that a deep link, or a permission
|
||||
* revoked while the tab was idle, explains itself instead of answering 403 on
|
||||
* submit.
|
||||
*/
|
||||
function PermissionNotice({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div role="status" className="flex gap-3 rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactElement, type ReactNode } from 'react';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
import { useStickToBottom } from 'use-stick-to-bottom';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { Button, cn } from '@/components/ui';
|
||||
|
||||
/** Breathing room left between a revealed disclosure and the edge it is pulled from. */
|
||||
const REVEAL_MARGIN_PX = 8;
|
||||
|
||||
/**
|
||||
* What the viewport exposes to the controls inside it. Deliberately these three
|
||||
* members rather than the library's whole instance: the scroll button has no
|
||||
* business holding the refs, and the animation choice is made once, here, where
|
||||
* the motion preference is read.
|
||||
*/
|
||||
interface ConversationScroll {
|
||||
/** False only while the reader has scrolled away from the newest message. */
|
||||
isAtBottom: boolean;
|
||||
scrollToLatest: () => void;
|
||||
/** See `usePiggyConversationReveal`. */
|
||||
revealOnExpand: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const noReveal = () => {};
|
||||
|
||||
const ConversationScrollContext = createContext<ConversationScroll | null>(null);
|
||||
|
||||
/**
|
||||
* The transcript viewport.
|
||||
*
|
||||
* It replaces an effect that called `bottomRef.current?.scrollIntoView()` on
|
||||
* every change to the message array — which, during a stream, means once per
|
||||
* token. Two failures fell out of that, and both are the reason this component
|
||||
* exists rather than a tidier version of the same effect:
|
||||
*
|
||||
* - There was no near-bottom check and no scroll listener anywhere, so
|
||||
* scrolling up to re-read an earlier answer while a new one streamed was
|
||||
* impossible: the next delta yanked the viewport back down, milliseconds
|
||||
* later, for as long as the answer took to write.
|
||||
* - `scrollIntoView` scrolls *every* scrollable ancestor. The dock is a sticky
|
||||
* 22rem column inside the page, so following Piggy also dragged the record
|
||||
* the user was reading it against.
|
||||
*
|
||||
* `use-stick-to-bottom` fixes both. It follows new content only while the
|
||||
* reader is already at the bottom, lets go the instant they scroll or wheel
|
||||
* up, re-attaches when they come back down, and does it by writing one
|
||||
* element's `scrollTop` — so nothing outside this component moves.
|
||||
*
|
||||
* DOM shape, because it is load-bearing rather than incidental:
|
||||
*
|
||||
* div — positioned; the scroll button's containing block
|
||||
* div — the scrollport, the only thing that scrolls; takes `className`
|
||||
* div — the measured content, whose growth drives the follow
|
||||
*
|
||||
* The button is absolutely positioned against the outermost element, which is
|
||||
* an ancestor of the scrollport rather than inside it, so it is neither
|
||||
* clipped by the overflow nor carried away by the scrolling.
|
||||
*
|
||||
* `className` lands on the scrollport rather than the outer element so that a
|
||||
* caller's gutters — the dock's `compact` px-3, the sheet's px-4 sm:px-5 —
|
||||
* scroll with the transcript exactly as they did before, instead of leaving a
|
||||
* dead band the text is sliced against. The outer element carries its own
|
||||
* `min-h-0 flex-1`, so a panel does not have to pass any layout at all.
|
||||
*
|
||||
* A child that should fill an otherwise-empty transcript — the suggestion
|
||||
* card — must use `flex-1`, not `h-full`: the content element is sized by its
|
||||
* children, so a percentage height there resolves to nothing.
|
||||
*/
|
||||
export function PiggyConversation({
|
||||
children,
|
||||
className,
|
||||
busy = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** A turn is still writing, so the live region should hold its announcement. */
|
||||
busy?: boolean;
|
||||
}) {
|
||||
// The library animates with JavaScript, so the global `scroll-behavior:
|
||||
// auto !important` under reduced motion does not reach it. Asked here and
|
||||
// passed down rather than read in two places.
|
||||
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
// A panel mounted against an existing transcript — the dock reopening, a
|
||||
// drawer coming back up — should already be at the newest message. An
|
||||
// animated first scroll would look like the answer arriving twice.
|
||||
initial: 'instant',
|
||||
resize: reducedMotion ? 'instant' : 'smooth',
|
||||
});
|
||||
|
||||
/**
|
||||
* Keep a disclosure the reader has just opened on screen.
|
||||
*
|
||||
* `use-stick-to-bottom` follows *any* positive resize of the content while
|
||||
* the reader is at the bottom, and cannot tell content Piggy streamed in at
|
||||
* the tail from content the reader themselves unfolded halfway up. Opening a
|
||||
* tool step therefore scrolled the step away: its bottom edge measured 133px
|
||||
* above the scrollport in the dock, and on a phone the heading that says which
|
||||
* tool it is landed 273px above the top edge. That is the exact opposite of
|
||||
* what pressing it asked for, on the page whose whole claim is that the
|
||||
* records behind an answer can be inspected.
|
||||
*
|
||||
* Two moves, in this order:
|
||||
*
|
||||
* - `stopScroll()` first, synchronously, while the click that will open the
|
||||
* disclosure is still being dispatched. It clears the lock before the
|
||||
* browser lays the expansion out, so the ResizeObserver's follow finds
|
||||
* `isAtBottom` already false and abandons the scroll rather than racing it.
|
||||
* Reading an unfolded step is scrolling away from the tail, and it releases
|
||||
* the follow for the same reason wheeling up does.
|
||||
* - Then, one frame later with the content in place, nudge the scrollport so
|
||||
* the disclosure is actually visible — and only the scrollport. Never
|
||||
* `scrollIntoView`, which walks every scrollable ancestor and would drag
|
||||
* the record behind the dock along with it.
|
||||
*
|
||||
* v1.1.6 has no opt-out of the resize follow: its options are read live
|
||||
* through a ref, but the only ones there are the animation and a
|
||||
* `targetScrollTop` override, and lying about the target corrupts
|
||||
* `isNearBottom` — and with it the jump-to-latest button — for as long as the
|
||||
* lie is held.
|
||||
*/
|
||||
const revealOnExpand = useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
const scrollport = scrollRef.current;
|
||||
if (!element || !scrollport) return;
|
||||
stopScroll();
|
||||
requestAnimationFrame(() => scrollIntoScrollport(scrollport, element));
|
||||
},
|
||||
[scrollRef, stopScroll],
|
||||
);
|
||||
|
||||
const scroll = useMemo<ConversationScroll>(
|
||||
() => ({
|
||||
isAtBottom,
|
||||
scrollToLatest: () => {
|
||||
void scrollToBottom({ animation: reducedMotion ? 'instant' : 'smooth' });
|
||||
},
|
||||
revealOnExpand,
|
||||
}),
|
||||
[isAtBottom, scrollToBottom, reducedMotion, revealOnExpand],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationScrollContext.Provider value={scroll}>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}
|
||||
role="log"
|
||||
aria-label="Piggy conversation"
|
||||
// Announce the finished answer rather than each token: a live region
|
||||
// fed deltas reads as an unbroken stutter.
|
||||
aria-live="polite"
|
||||
// On the live region ROOT, which is the element whose busy state a
|
||||
// screen reader consults before it decides to speak. It used to sit on
|
||||
// the message column inside this element instead, where an
|
||||
// implementation that only reads the root — the common case — went on
|
||||
// announcing every delta as it landed.
|
||||
aria-busy={busy}
|
||||
// The transcript is the one part of this panel a keyboard user
|
||||
// cannot otherwise reach: without a tab stop there is no way to
|
||||
// scroll back to an earlier answer without a pointer.
|
||||
tabIndex={0}
|
||||
>
|
||||
<div ref={contentRef} className="flex min-h-full flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConversationScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back down.
|
||||
*
|
||||
* Rendered anywhere inside `PiggyConversation` — its position is fixed by the
|
||||
* absolute placement below, not by where it sits in the children — and absent
|
||||
* entirely while the reader is at the bottom, because a control that jumps you
|
||||
* where you already are is noise floating over the answer.
|
||||
*
|
||||
* Rendered outside a `PiggyConversation` it is nothing at all. That is a
|
||||
* wiring mistake rather than a state, but a thrown error inside a streaming
|
||||
* transcript would take the whole panel down with it.
|
||||
*/
|
||||
export function PiggyConversationScrollButton(): ReactElement | null {
|
||||
const scroll = useContext(ConversationScrollContext);
|
||||
if (!scroll || scroll.isAtBottom) return null;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={scroll.scrollToLatest}
|
||||
aria-label="Jump to the latest message"
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-3 z-10 mx-auto rounded-full border border-border',
|
||||
// The `secondary` fill, left opaque. A translucent disc ghosted the
|
||||
// sentence it covered in light mode and disappeared into the panel
|
||||
// altogether in dark; `surface-2` reads against `surface` in both.
|
||||
'text-muted shadow-lg hover:text-fg',
|
||||
// `mx-auto` between `inset-x-0` centres it without a transform, which
|
||||
// the entrance animation below needs for itself.
|
||||
'animate-in fade-in zoom-in-95',
|
||||
)}
|
||||
>
|
||||
<ArrowDown className="size-4" aria-hidden />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback a `<details>` inside the transcript must fire from its
|
||||
* summary's `onClick` when it is about to open, passing the element that will
|
||||
* grow.
|
||||
*
|
||||
* `onClick` rather than the `toggle` event because `toggle` is queued and fires
|
||||
* after the browser has already laid the expansion out and the follow has
|
||||
* already run; a click handler is dispatched before the default action opens
|
||||
* anything, which is the only moment early enough to get in front of it.
|
||||
*
|
||||
* Outside a `PiggyConversation` this does nothing, matching the scroll button:
|
||||
* a mis-wired transcript should render a slightly worse tool step, not throw
|
||||
* inside a stream and take the panel with it.
|
||||
*/
|
||||
export function usePiggyConversationReveal(): (element: HTMLElement | null) => void {
|
||||
const scroll = useContext(ConversationScrollContext);
|
||||
return scroll?.revealOnExpand ?? noReveal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `element` inside `scrollport`, moving nothing else on the page.
|
||||
*
|
||||
* The downward correction is capped at the element's own top gap: chasing the
|
||||
* bottom of a disclosure taller than the viewport would scroll its heading —
|
||||
* the only part that says which tool this is — off the top of the scrollport.
|
||||
*/
|
||||
function scrollIntoScrollport(scrollport: HTMLElement, element: HTMLElement): void {
|
||||
const view = scrollport.getBoundingClientRect();
|
||||
const box = element.getBoundingClientRect();
|
||||
const topGap = box.top - (view.top + REVEAL_MARGIN_PX);
|
||||
if (topGap < 0) {
|
||||
scrollport.scrollTop += topGap;
|
||||
return;
|
||||
}
|
||||
const bottomOverflow = box.bottom - (view.bottom - REVEAL_MARGIN_PX);
|
||||
if (bottomOverflow > 0) scrollport.scrollTop += Math.min(bottomOverflow, topGap);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The footer under a finished Piggy turn: what you can do with the answer, and
|
||||
* what the answer cost.
|
||||
*
|
||||
* The run line is not telemetry for its own sake. PIG's whole argument is that
|
||||
* an agent-native CRM can run on Prime Intellect's inference and their model,
|
||||
* and until now the transcript gave no sign of either — the one fact the
|
||||
* product most needs to state was the one fact it kept to itself. It is
|
||||
* therefore always present, and always quiet: a caption, never a banner.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { TranscriptMessage } from '@/lib/piggy-chat';
|
||||
import { Badge, Button, cn } from '@/components/ui';
|
||||
|
||||
/** How long the copy button admits it worked before returning to its label. */
|
||||
const COPIED_RESET_MS = 2_000;
|
||||
|
||||
export function PiggyMessageActions({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: TranscriptMessage;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// The transcript is a long-lived list and a turn can be dropped from it while
|
||||
// the confirmation is still counting down — `retry` removes the exchange it
|
||||
// replaces — so the timer has to die with the component.
|
||||
useEffect(() => () => window.clearTimeout(resetRef.current), []);
|
||||
|
||||
const state = stateLabel(message);
|
||||
const usage = formatUsage(message);
|
||||
// Only Piggy's words are worth a copy button. A user turn reaches this
|
||||
// footer too — a question the relay refused carries the `failed` chip — and
|
||||
// offering to copy back what they typed a second ago is noise.
|
||||
const copyable = message.role === 'assistant' && Boolean(message.content.trim());
|
||||
// Retry is offered for anything the caller passed a handler for; deciding
|
||||
// *which* turns deserve one is the transcript's job, not this footer's.
|
||||
const retryable = Boolean(onRetry) && Boolean(message.error || message.stopped || message.truncated);
|
||||
|
||||
// Nothing to press and nothing to report is a row of whitespace under every
|
||||
// message. There is nothing to say, so say nothing.
|
||||
if (message.pending) return null;
|
||||
if (!copyable && !retryable && !state && !usage && !message.model) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
// `navigator.clipboard` is absent outside a secure context, which is not a
|
||||
// hypothetical here: PIG is routinely opened from a phone on the LAN over
|
||||
// plain http, and reading `.writeText` off undefined would throw before any
|
||||
// toast could explain itself.
|
||||
if (!navigator.clipboard) {
|
||||
toast.error('Copying needs a secure connection. Select the text instead.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
} catch {
|
||||
// Denied permission, or a document that was not focused when the write
|
||||
// landed. Either way the clipboard still holds whatever it held before,
|
||||
// so the user must be told rather than left to paste stale text.
|
||||
toast.error('The browser refused clipboard access.');
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
toast.success('Answer copied');
|
||||
window.clearTimeout(resetRef.current);
|
||||
resetRef.current = window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/actions mt-1.5 flex flex-col gap-0.5">
|
||||
{/* The run line keeps its own row rather than sharing one with the
|
||||
buttons, and comes first so that it stays against the answer it
|
||||
describes: at 22rem the buttons' reserved width truncated the model id
|
||||
to "nvidia/nemotron-3-nan…", which defeats the point of showing it. */}
|
||||
{state || message.model || usage ? (
|
||||
<p className="flex min-w-0 items-baseline gap-1.5 text-[11px] leading-4 text-muted">
|
||||
{state ? <Badge className="shrink-0 px-2 text-[11px] font-normal">{state}</Badge> : null}
|
||||
{message.model ? (
|
||||
// `truncate` only shrinks a flex child that is allowed to: without
|
||||
// `min-w-0` the model id sets the row's minimum width and pushes
|
||||
// the counts off the side of the dock.
|
||||
<span className="min-w-0 truncate font-mono" title={message.model}>
|
||||
{message.model}
|
||||
</span>
|
||||
) : null}
|
||||
{message.model && usage ? <span aria-hidden>·</span> : null}
|
||||
{usage ? (
|
||||
<span className="shrink-0 tabular-nums" title={exactUsage(message)}>
|
||||
{usage}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
* Quiet where there is a pointer, permanent where there is not.
|
||||
* `@media (hover: hover)` is the only honest test for "can this user
|
||||
* reveal something by hovering"; a touch device never can, so hiding
|
||||
* these behind hover there would hide them for good. Opacity rather than
|
||||
* `hidden`, because the transcript is a scroll container and a row that
|
||||
* only claims its space once hovered would shove the message out from
|
||||
* under the pointer as it arrived.
|
||||
*/}
|
||||
{copyable || retryable ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1 transition-opacity',
|
||||
// Copy is a convenience and can wait to be hovered for. Retry is
|
||||
// the way out of a turn that failed, and a recovery affordance
|
||||
// nobody can see until they happen to sweep the pointer over the
|
||||
// error is not one — so a row containing it stays put.
|
||||
!retryable && '[@media(hover:hover)]:opacity-0',
|
||||
!retryable && '[@media(hover:hover)]:group-hover/actions:opacity-100',
|
||||
// Beats the rule above on specificity, so tabbing to a button
|
||||
// reveals the row it sits in whatever the pointer is doing.
|
||||
'focus-within:opacity-100',
|
||||
)}
|
||||
>
|
||||
{copyable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Copy answer"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{copied ? <Check className="size-4 text-positive" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Both labels open with the word printed on the button. An
|
||||
accessible name that does not contain its own visible text is a
|
||||
voice-control dead end: "click Retry" would find nothing. */}
|
||||
{retryable ? (
|
||||
<Button type="button" variant="ghost" size="sm" aria-label="Retry this answer" onClick={onRetry}>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-word account of a turn that did not simply finish.
|
||||
*
|
||||
* Ordered by what the user needs to know first: an outright failure outranks
|
||||
* having pressed stop, which outranks the line dropping. `failed` is last
|
||||
* because it belongs to the question rather than the answer.
|
||||
*/
|
||||
function stateLabel(message: TranscriptMessage): string | null {
|
||||
if (message.error) return 'Failed';
|
||||
if (message.stopped) return 'Stopped';
|
||||
if (message.truncated) return 'Ended early';
|
||||
if (message.failed) return 'Not sent';
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatUsage(message: TranscriptMessage): string | null {
|
||||
const parts: string[] = [];
|
||||
if (typeof message.inputTokens === 'number') parts.push(`${formatTokens(message.inputTokens)} in`);
|
||||
if (typeof message.outputTokens === 'number') parts.push(`${formatTokens(message.outputTokens)} out`);
|
||||
return parts.length ? parts.join(' / ') : null;
|
||||
}
|
||||
|
||||
/** The unabbreviated figures, for the caption's `title`. Nothing is rounded away. */
|
||||
function exactUsage(message: TranscriptMessage): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (typeof message.inputTokens === 'number') parts.push(`${message.inputTokens.toLocaleString()} input tokens`);
|
||||
if (typeof message.outputTokens === 'number') parts.push(`${message.outputTokens.toLocaleString()} output tokens`);
|
||||
return parts.length ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thousands are abbreviated because this is a caption, not an invoice: at a
|
||||
* glance "4.1k" answers the question the exact figure does not, and a
|
||||
* five-digit number next to a model id is what breaks the row in the dock.
|
||||
* One decimal below ten thousand, where the difference between 4.1k and 4.9k
|
||||
* is still a real difference.
|
||||
*/
|
||||
function formatTokens(count: number): string {
|
||||
if (count < 1_000) return String(count);
|
||||
if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
|
||||
return `${Math.round(count / 1_000)}k`;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Brain, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* How long after the last reasoning token the panel folds itself away.
|
||||
*
|
||||
* Long enough that the collapse reads as a consequence of the thinking ending
|
||||
* rather than as a flicker, short enough that the answer is not still fighting
|
||||
* a wall of scratch text for the eye by the time it starts streaming.
|
||||
*/
|
||||
const COLLAPSE_DELAY_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Piggy's scratch work, shown while it happens and folded away afterwards.
|
||||
*
|
||||
* Reachable only when `PIGGY_REASONING_EFFORT` is turned up from its default of
|
||||
* `none`; with the default, `reasoning_delta` never fires and the integrator
|
||||
* never renders this. That is deliberate — see the note on the setting in
|
||||
* apps/piggy/src/config.ts — so treat this panel as the operator's debugging
|
||||
* surface first and a chat flourish second.
|
||||
*
|
||||
* Three behaviours, in the order they matter:
|
||||
* - it opens itself while the thinking streams, because unexplained latency is
|
||||
* the thing a reasoning model is worst at;
|
||||
* - it closes itself a beat after the thinking stops, because the answer is
|
||||
* what the user came for and scratch work left open buries it;
|
||||
* - it stops doing either the moment the user touches the disclosure, because
|
||||
* a panel that re-closes itself under someone who opened it to read is worse
|
||||
* than one that never opened at all.
|
||||
*/
|
||||
export function PiggyReasoning({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
const [open, setOpen] = useState(streaming);
|
||||
const [durationMs, setDurationMs] = useState<number | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
/**
|
||||
* `performance.now()` at the first reasoning token, not at mount: the
|
||||
* integrator may render this panel from the moment the turn starts, and the
|
||||
* wait for the first byte belongs to the request, not to the thinking.
|
||||
*/
|
||||
const startedAt = useRef<number | null>(null);
|
||||
/**
|
||||
* Set by the only gesture that can toggle a `<details>` — a click or an
|
||||
* Enter/Space on the summary, which the browser reports as a click too. Once
|
||||
* it is set, neither automatic rule fires again for this turn.
|
||||
*/
|
||||
const touched = useRef(false);
|
||||
|
||||
const started = Boolean(text.trim());
|
||||
useEffect(() => {
|
||||
if (streaming && started && startedAt.current === null) startedAt.current = performance.now();
|
||||
if (!streaming && startedAt.current !== null) {
|
||||
setDurationMs(performance.now() - startedAt.current);
|
||||
startedAt.current = null;
|
||||
}
|
||||
}, [streaming, started]);
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) return;
|
||||
if (streaming) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
// Re-checked at fire time as well as at schedule time: the user may have
|
||||
// opened the panel during the delay, and this closure would otherwise
|
||||
// shut it under them a second later.
|
||||
if (!touched.current) setOpen(false);
|
||||
}, COLLAPSE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [streaming]);
|
||||
|
||||
useEffect(() => {
|
||||
// Follow the tail while it writes. Without this the capped box shows the
|
||||
// opening sentence for the whole turn, which looks like a stalled stream.
|
||||
if (streaming && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [text, streaming]);
|
||||
|
||||
// Nothing was thought and nothing is being thought: render no chrome at all,
|
||||
// rather than an empty box the user can open to find nothing in.
|
||||
if (!started && !streaming) return null;
|
||||
|
||||
return (
|
||||
<details
|
||||
open={open}
|
||||
onToggle={(event) => setOpen(event.currentTarget.open)}
|
||||
className="mb-2 text-xs text-muted"
|
||||
/*
|
||||
* The transcript around this is `role="log" aria-live="polite"`, and a
|
||||
* live region announces its whole subtree. Auto-opening the panel
|
||||
* therefore put the model's scratch work into a screen reader's ear,
|
||||
* token by token, ahead of the answer it was scratch work for. `off`
|
||||
* overrides the inherited politeness for this subtree only.
|
||||
*/
|
||||
aria-live="off"
|
||||
>
|
||||
<summary
|
||||
// `list-none` and the WebKit rule between them remove the native
|
||||
// triangle, which a flex summary drops in Chrome but keeps in Firefox —
|
||||
// so without both the disclosure marker exists in one browser only.
|
||||
className="flex min-h-11 cursor-pointer list-none items-center gap-2 py-2 pr-2 font-medium transition-colors hover:text-fg [&::-webkit-details-marker]:hidden"
|
||||
onClick={() => {
|
||||
touched.current = true;
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 transition-transform', open && 'rotate-90')}
|
||||
aria-hidden
|
||||
/>
|
||||
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
|
||||
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
|
||||
</summary>
|
||||
{/* Withheld until the first token so the gap between "Thinking" and
|
||||
anything to read is empty space rather than an empty rail. */}
|
||||
{started ? (
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={cn(
|
||||
'ml-1 animate-in fade-in border-l border-border py-1 pl-3',
|
||||
// Capped only while it writes. An auto-opened panel is one the user
|
||||
// did not ask for, so it must not push the answer off screen; a
|
||||
// panel they opened themselves is one they mean to read to the end.
|
||||
streaming && 'max-h-40 overflow-y-auto',
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap leading-5">{text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately silent about duration when there is none to report.
|
||||
*
|
||||
* A panel mounted against an already-finished turn — a restored transcript, a
|
||||
* remount behind a closed sheet — never saw the clock start, and "Thought for 0
|
||||
* seconds" would be a measurement we did not take.
|
||||
*/
|
||||
function reasoningLabel(durationMs: number | null): string {
|
||||
if (durationMs === null) return 'Reasoning';
|
||||
const seconds = Math.max(1, Math.round(durationMs / 1_000));
|
||||
return `Thought for ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Piggy's answer, rendered as markdown.
|
||||
*
|
||||
* The model writes GFM — renewal tables, bolded figures, numbered next steps
|
||||
* and the occasional SQL block. The transcript used to print that verbatim, so
|
||||
* everyone read `**Renewal:**` and pipe-delimited soup.
|
||||
*
|
||||
* Every element is styled from the map below rather than left to Streamdown's
|
||||
* own look. Streamdown ships Tailwind class names inside its compiled output,
|
||||
* and PIG's Tailwind only scans `src/**`, so those class names are never
|
||||
* emitted into the stylesheet — anything not overridden here would render with
|
||||
* bare browser defaults. There is no `@tailwindcss/typography` in this repo
|
||||
* either, so there is no `prose` to fall back on.
|
||||
*
|
||||
* `rehypePlugins` is deliberately not passed. Streamdown's default chain is
|
||||
* rehype-raw → rehype-sanitize → rehype-harden, and supplying our own would
|
||||
* silently replace it — dropping the sanitiser that keeps a model-authored
|
||||
* `javascript:` href or a stray `<script>` out of the DOM.
|
||||
*/
|
||||
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
|
||||
import { isValidElement } from 'react';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { Streamdown, type Components, type ExtraProps } from 'streamdown';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
/** Fenced blocks carry their language as `language-sql` on the `code` element. */
|
||||
const LANGUAGE_CLASS = /language-([\w-]+)/;
|
||||
|
||||
export function PiggyResponse({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<Streamdown
|
||||
// Half a table or an unclosed `**` arrives on nearly every frame while
|
||||
// the answer streams. Without this the transcript flashes raw pipes and
|
||||
// asterisks between tokens.
|
||||
parseIncompleteMarkdown
|
||||
// Streamdown's own copy/download overlays for tables and code blocks are
|
||||
// styled with class names this build never emits, so they would land as
|
||||
// unstyled buttons floating over the answer.
|
||||
controls={false}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
className={cn(
|
||||
// Block rhythm lives here rather than on each element: Streamdown's
|
||||
// root already sets `space-y-*`, whose `> * + *` rule outranks any
|
||||
// margin utility a child could carry.
|
||||
'space-y-3 break-words text-sm leading-6 [&>*:first-child]:pt-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
p: ({ children }) => <p className="leading-6">{children}</p>,
|
||||
|
||||
/*
|
||||
* Headings buy their extra air with padding, not margin — see the note on
|
||||
* `space-y-3` above. The scale is compressed against the ordinary chat type:
|
||||
* this renders in a 22rem dock as often as on a full page, and a document
|
||||
* h1 at that width reads as a shout.
|
||||
*/
|
||||
h1: ({ children }) => <h1 className="pt-2 text-lg font-semibold tracking-tight">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="pt-2 text-[0.9375rem] font-semibold tracking-tight">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="pt-1 text-sm font-semibold">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="pt-1 text-sm font-medium">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="pt-1 text-sm font-medium text-muted">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="pt-1 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>,
|
||||
|
||||
ul: ({ children }) => <ul className="list-disc space-y-1 pl-5 marker:text-muted">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal space-y-1 pl-5 marker:text-muted">{children}</ol>,
|
||||
// A nested list is the first *element* child of its item even when prose
|
||||
// precedes it, so `space-y` on the parent never reaches it.
|
||||
li: ({ children }) => <li className="leading-6 [&>ol]:mt-1 [&>ul]:mt-1">{children}</li>,
|
||||
|
||||
strong: ({ children }) => <strong className="font-semibold text-fg">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
a: MarkdownLink,
|
||||
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-border" />,
|
||||
|
||||
img: ({ src, alt }) => (
|
||||
// `referrerPolicy` so a model-authored image URL cannot use the referer to
|
||||
// learn which PIG record the reader had open when it loaded.
|
||||
<img
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={alt ?? ''}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="max-w-full rounded-lg border border-border"
|
||||
/>
|
||||
),
|
||||
|
||||
/*
|
||||
* There is no `pre` entry, and that is deliberate: Streamdown's `pre` is not
|
||||
* a wrapper but a marker that tags its `code` child with `data-block`, which
|
||||
* is how the pair below is told apart. Replacing it would break that
|
||||
* contract, so the whole fenced-block chrome — the `pre` included — is built
|
||||
* by `CodeFence`, and `inlineCode` takes the inline case.
|
||||
*/
|
||||
code: CodeFence,
|
||||
inlineCode: ({ children }) => (
|
||||
<code className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
|
||||
table: ({ children }) => (
|
||||
// The dock is 22rem wide and a renewals table is not. `scroll-x` keeps the
|
||||
// overflow inside this box — with momentum and overscroll containment, so
|
||||
// swiping a table on a phone does not drag the transcript with it.
|
||||
<div className="scroll-x rounded-lg border border-border">
|
||||
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
|
||||
the scroller rather than squash the columns when it is not. */}
|
||||
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
|
||||
// A row highlight is what lets you keep your place across a table that is
|
||||
// wider than the pane and has been scrolled sideways.
|
||||
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
|
||||
th: ({ children, style, align }) => (
|
||||
<th className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted" style={alignStyle(style, align)}>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children, style, align }) => (
|
||||
<td className="nums px-3 py-2 align-top" style={alignStyle(style, align)}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Every link here was written by the model, not by PIG, so it is treated as
|
||||
* outbound and untrusted: a new tab (nothing in a chat should navigate the
|
||||
* workspace away), no `opener` handle back to us, no referer leaking the
|
||||
* record the reader was on, and a marker glyph so a plausible-looking phrase
|
||||
* cannot pass itself off as internal navigation.
|
||||
*/
|
||||
function MarkdownLink({ href, children }: ComponentProps<'a'> & ExtraProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
title={href}
|
||||
className="font-medium text-info underline decoration-border underline-offset-2 hover:decoration-info"
|
||||
>
|
||||
{children}
|
||||
<ArrowUpRight className="ml-0.5 inline size-3 align-[-0.1em]" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fenced code block. Only ever reached for fenced blocks: supplying
|
||||
* `inlineCode` alongside `code` is what makes Streamdown route the two cases
|
||||
* apart, so there is no inline branch to guard here.
|
||||
*/
|
||||
function CodeFence({ className, children }: ComponentProps<'code'> & ExtraProps) {
|
||||
const language = LANGUAGE_CLASS.exec(className ?? '')?.[1];
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{language ? (
|
||||
<div className="border-b border-border px-3 py-1.5 font-mono text-[11px] lowercase text-muted">{language}</div>
|
||||
) : null}
|
||||
<pre className="scroll-x p-3 text-xs leading-5">
|
||||
<code className="font-mono">{codeText(children)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GFM column alignment — the `---:` in a delimiter row — is the one piece of
|
||||
* element styling the markdown itself owns, and a `$1.86/GPU-hr` column that
|
||||
* silently reverts to the left is the difference between a readable table and
|
||||
* a wall. Markdown carries it as the legacy `align` attribute, which the hast
|
||||
* to JSX conversion may hand over already translated into `style.textAlign` —
|
||||
* so both are read, and the winner becomes a real inline style. Left as a bare
|
||||
* attribute it is only a user-agent presentational hint, which the cell's own
|
||||
* `text-left` class outranks.
|
||||
*
|
||||
* Only the alignment is taken, and these two cells are the only components
|
||||
* here that forward a style at all. The sanitiser upstream already strips
|
||||
* author `style`, and this keeps that true even if it ever stops.
|
||||
*/
|
||||
function alignStyle(style: CSSProperties | undefined, align: string | undefined): CSSProperties | undefined {
|
||||
const value = style?.textAlign ?? align;
|
||||
return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined;
|
||||
}
|
||||
|
||||
/** The fence body reaches us as React children, one text node deep on a
|
||||
* complete block but occasionally nested while the block is still arriving. */
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === 'string') return children;
|
||||
if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join('');
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children);
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* One tool round trip, rendered as evidence rather than as a spinner.
|
||||
*
|
||||
* The /piggy page promises that the user can inspect the PIG records behind an
|
||||
* answer. The server has always streamed the whole tool payload, but the
|
||||
* timeline expanded onto `JSON.stringify(arguments)` — and since almost every
|
||||
* Piggy tool declares `z.object({}).strict()`, that was the literal string
|
||||
* `{}`. A chip that proves nothing is worse than no chip: it looks like
|
||||
* provenance and carries none.
|
||||
*
|
||||
* So the default reading is a sentence — "Northwind Robotics · 4 contacts, 2
|
||||
* contracts, 0 demand deals" — with the records themselves linked, and the raw
|
||||
* payload one further click down for anyone who wants to check the sentence
|
||||
* against it.
|
||||
*/
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CheckCircle2, ChevronRight, Loader2, XCircle } from 'lucide-react';
|
||||
import { money, unitPrice } from '@/lib/api';
|
||||
import type { ToolStep } from '@/lib/piggy-chat';
|
||||
import { cn } from '@/components/ui';
|
||||
import { usePiggyConversationReveal } from './conversation';
|
||||
|
||||
/**
|
||||
* How many records the evidence row will link before it stops.
|
||||
*
|
||||
* `readFocusedRecord` reads up to a hundred rows per relation, and a chip per
|
||||
* contact would bury the answer under its own footnotes. The count in the
|
||||
* headline stays exact; only the links are capped.
|
||||
*/
|
||||
const LINKED_RECORDS_MAX = 8;
|
||||
|
||||
/** Past this the raw payload is a scroll container nobody reads to the end of. */
|
||||
const RAW_PAYLOAD_MAX_CHARS = 20_000;
|
||||
|
||||
// ------------------------------------------------------------------ routing
|
||||
|
||||
/**
|
||||
* Where a record of each kind can be opened.
|
||||
*
|
||||
* Contacts point at /accounts because PIG has no contacts route — the accounts
|
||||
* page carries both views — and everything else points at its list.
|
||||
*/
|
||||
const RECORD_ROUTES = {
|
||||
account: '/accounts',
|
||||
contact: '/accounts',
|
||||
demand_deal: '/demand',
|
||||
supply_deal: '/supply',
|
||||
contract: '/contracts',
|
||||
commitment: '/capacity',
|
||||
allocation: '/capacity',
|
||||
} as const;
|
||||
|
||||
type RecordKind = keyof typeof RECORD_ROUTES;
|
||||
|
||||
const RECORD_LABELS: Record<RecordKind, string> = {
|
||||
account: 'account',
|
||||
contact: 'contact',
|
||||
demand_deal: 'demand deal',
|
||||
supply_deal: 'supply deal',
|
||||
contract: 'contract',
|
||||
commitment: 'capacity commitment',
|
||||
allocation: 'allocation',
|
||||
};
|
||||
|
||||
interface RecordLink {
|
||||
kind: RecordKind;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place a record id becomes a URL.
|
||||
*
|
||||
* `/accounts/:id` now exists, so an account chip opens the record itself —
|
||||
* which is the whole promise of the evidence row, and why the id has been
|
||||
* carried this far rather than dropped at the summariser. Nothing else has a
|
||||
* per-record route yet, so those chips still land on the list, which at least
|
||||
* puts the reader in front of the row. A contact is the case worth stating: it
|
||||
* would want `/accounts/:accountId`, and the summariser reads contacts out of
|
||||
* collections that carry the contact's own id and not its account's, so
|
||||
* appending it here would build a URL to an account that does not exist.
|
||||
*/
|
||||
function recordHref(link: RecordLink): string {
|
||||
return link.kind === 'account'
|
||||
? `${RECORD_ROUTES.account}/${link.id}`
|
||||
: RECORD_ROUTES[link.kind];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ evidence
|
||||
|
||||
interface Evidence {
|
||||
/** One line a human reads instead of the payload. */
|
||||
headline: string | null;
|
||||
/** The records the answer rests on, each openable. */
|
||||
links: RecordLink[];
|
||||
/** Records read but not linked, so the cap is admitted rather than hidden. */
|
||||
hiddenLinkCount: number;
|
||||
}
|
||||
|
||||
const NO_EVIDENCE: Evidence = { headline: null, links: [], hiddenLinkCount: 0 };
|
||||
|
||||
export function PiggyToolStep({ step }: { step: ToolStep }) {
|
||||
const evidence = describeStep(step);
|
||||
const input = formatArguments(step.arguments);
|
||||
const payload = step.state === 'succeeded' ? formatPayload(step.result) : null;
|
||||
const stepRef = useRef<HTMLDetailsElement>(null);
|
||||
const rawRef = useRef<HTMLDetailsElement>(null);
|
||||
const reveal = usePiggyConversationReveal();
|
||||
|
||||
/**
|
||||
* A transcript pinned to its newest message treats an unfolded step as new
|
||||
* content and scrolls past it, so the evidence the user asked to see leaves
|
||||
* the screen. `open` still holds its pre-click value inside a click handler,
|
||||
* which is both the only moment we can tell an expansion from a collapse and
|
||||
* the last moment before the growth is laid out. A collapse is left alone: it
|
||||
* shrinks the transcript, which the follow handles correctly already.
|
||||
*/
|
||||
const revealOnExpand = (details: HTMLDetailsElement | null) => {
|
||||
if (!details || details.open) return;
|
||||
reveal(details);
|
||||
};
|
||||
|
||||
return (
|
||||
<details ref={stepRef} className="group rounded-lg border border-border text-xs">
|
||||
<summary
|
||||
onClick={() => revealOnExpand(stepRef.current)}
|
||||
className={cn(
|
||||
'flex min-h-11 cursor-pointer list-none items-start gap-2 px-3 py-2',
|
||||
// Safari draws its own disclosure triangle from a pseudo-element that
|
||||
// `list-style: none` does not reach, which left two markers on the row.
|
||||
'[&::-webkit-details-marker]:hidden',
|
||||
)}
|
||||
>
|
||||
<StepIcon state={step.state} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{toolLabel(step.name)}</span>
|
||||
{step.durationMs === undefined ? null : (
|
||||
<span className="shrink-0 tabular-nums text-muted">{formatDuration(step.durationMs)}</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-muted transition-transform group-open:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
{evidence.headline ? (
|
||||
// Clamped shut, whole when open: a calendar headline runs to several
|
||||
// sentences, and a chip that tall stops being a chip.
|
||||
<p
|
||||
className={cn(
|
||||
'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
|
||||
step.state === 'failed' ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{evidence.headline}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border p-3">
|
||||
{input ? (
|
||||
<Section title="Input">
|
||||
<RawBlock text={input} />
|
||||
</Section>
|
||||
) : null}
|
||||
<Section title="Output">
|
||||
{step.state === 'running' ? (
|
||||
<p className="text-muted">Waiting for PIG…</p>
|
||||
) : step.state === 'failed' ? (
|
||||
// The reason is already in the header, unclamped once open, so
|
||||
// repeating it here would print the same sentence twice in a row.
|
||||
<p className="text-muted">Nothing was returned; the call did not complete.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{evidence.links.length ? (
|
||||
<RecordLinks links={evidence.links} hidden={evidence.hiddenLinkCount} />
|
||||
) : null}
|
||||
{payload ? (
|
||||
<details ref={rawRef} className="group/raw">
|
||||
<summary
|
||||
onClick={() => revealOnExpand(rawRef.current)}
|
||||
className="inline-flex min-h-11 cursor-pointer list-none items-center gap-1 text-muted hover:text-fg [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-3.5 transition-transform group-open/raw:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
Raw payload
|
||||
</summary>
|
||||
<RawBlock text={payload} />
|
||||
</details>
|
||||
) : (
|
||||
<p className="text-muted">The tool returned no payload.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ state }: { state: ToolStep['state'] }) {
|
||||
const label = state === 'running' ? 'Running' : state === 'succeeded' ? 'Succeeded' : 'Failed';
|
||||
return (
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{state === 'running' ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted" aria-hidden />
|
||||
) : state === 'succeeded' ? (
|
||||
<CheckCircle2 className="size-4 text-positive" aria-hidden />
|
||||
) : (
|
||||
<XCircle className="size-4 text-danger" aria-hidden />
|
||||
)}
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Labelled without a heading: a transcript full of `h4`s wrecks heading navigation. */
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-label={title}>
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted">{title}</p>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RawBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-[11px] leading-4 text-muted">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordLinks({ links, hidden }: { links: RecordLink[]; hidden: number }) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label="Records read">
|
||||
{links.map((link) => (
|
||||
<li key={`${link.kind}:${link.id}`} className="min-w-0 max-w-full">
|
||||
<Link
|
||||
to={recordHref(link)}
|
||||
// The title has to follow the href: promising a list and opening a
|
||||
// record is the sort of small lie that stops a chip being trusted.
|
||||
title={
|
||||
link.kind === 'account'
|
||||
? `Open the account ${link.label}`
|
||||
: `Open the ${RECORD_LABELS[link.kind]} list`
|
||||
}
|
||||
className="flex min-h-11 max-w-full items-center rounded-md border border-border px-2 text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<span className="truncate">{link.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{hidden > 0 ? (
|
||||
<li className="flex min-h-11 items-center text-muted">and {hidden} more</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- summarising
|
||||
|
||||
function describeStep(step: ToolStep): Evidence {
|
||||
// A failure with no message still needs a line, or the chip reads as a
|
||||
// success whose summary happened not to render.
|
||||
if (step.state === 'failed') {
|
||||
return { ...NO_EVIDENCE, headline: step.error ?? 'The tool failed without saying why.' };
|
||||
}
|
||||
if (step.state === 'running') return NO_EVIDENCE;
|
||||
return summariseResult(step.result);
|
||||
}
|
||||
|
||||
function summariseResult(result: unknown): Evidence {
|
||||
const payload = asRecord(result);
|
||||
if (!payload) return NO_EVIDENCE;
|
||||
|
||||
// The page tools compose the sentence they want quoted and the system prompt
|
||||
// tells the model to quote it, so deriving a second summary here would put a
|
||||
// subtly different reading of the same numbers next to the model's. They drop
|
||||
// record ids on purpose, so those chips carry a sentence and nothing else —
|
||||
// but the lookup layer keeps its ids, and those rows are linked.
|
||||
const headline = asString(payload.headline);
|
||||
if (headline) {
|
||||
const found = readHeadlineLinks(payload);
|
||||
return {
|
||||
headline,
|
||||
links: found.slice(0, LINKED_RECORDS_MAX),
|
||||
hiddenLinkCount: Math.max(0, found.length - LINKED_RECORDS_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
const subject = describeSubject(payload);
|
||||
const collections = readCollections(payload);
|
||||
const parts = [
|
||||
...(subject?.figures ?? []),
|
||||
...lifecycleFigures(payload),
|
||||
...collections.counts,
|
||||
];
|
||||
|
||||
const found = [
|
||||
...(subject && subject.id ? [{ kind: subject.kind, id: subject.id, label: subject.name }] : []),
|
||||
...collections.links,
|
||||
];
|
||||
|
||||
return {
|
||||
headline: composeHeadline(subject?.name ?? null, parts),
|
||||
links: found.slice(0, LINKED_RECORDS_MAX),
|
||||
hiddenLinkCount: Math.max(0, found.length - LINKED_RECORDS_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows behind a headline, where the tool kept their ids.
|
||||
*
|
||||
* The lookup tools are the reason this exists. `pig_search_records` answers
|
||||
* "which Meridian?" with typed ids, and `pig_list_renewals` with contract ids —
|
||||
* the very records the answer rests on — and until they were read here a search
|
||||
* chip proved nothing but its own sentence, on the page whose whole claim is
|
||||
* that the records behind an answer can be opened. The page tools are untouched:
|
||||
* they carry no `results` or `renewals`, so they still summarise to a sentence.
|
||||
*/
|
||||
function readHeadlineLinks(payload: Record<string, unknown>): RecordLink[] {
|
||||
const links: RecordLink[] = [];
|
||||
// A search hit names its own type, because a search spans five tables.
|
||||
for (const row of asArray(payload.results)) {
|
||||
const record = asRecord(row);
|
||||
const kind = record && asRecordKind(record.type);
|
||||
const id = record && asString(record.id);
|
||||
const label = record && recordName(record);
|
||||
if (kind && id && label) links.push({ kind, id, label });
|
||||
}
|
||||
// A renewal is always a contract, and says so by carrying no type at all.
|
||||
for (const row of asArray(payload.renewals)) {
|
||||
const record = asRecord(row);
|
||||
const id = record && asString(record.id);
|
||||
const label = record && recordName(record);
|
||||
if (id && label) links.push({ kind: 'contract', id, label });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* A middot separates the name from the figures, not an em dash. PIG's own
|
||||
* record names are full of em dashes — "DEMO — MSA — coreweave.com" is a real
|
||||
* one — and a second em dash makes the name and the evidence read as one
|
||||
* run-on title.
|
||||
*/
|
||||
function composeHeadline(name: string | null, parts: string[]): string | null {
|
||||
if (name && parts.length) return `${name} · ${parts.join(', ')}`;
|
||||
if (name) return name;
|
||||
return parts.length ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
interface Subject {
|
||||
kind: RecordKind;
|
||||
id: string | null;
|
||||
name: string;
|
||||
/** The one or two facts worth putting beside the name. */
|
||||
figures: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the payload is *about*.
|
||||
*
|
||||
* Order matters: a contact result also carries its account, and a deal result
|
||||
* carries both, so the most specific key wins. `readFocusedRecord` is the only
|
||||
* producer of these shapes and each one names its subject differently, which
|
||||
* is why this is a lookup rather than a discriminant.
|
||||
*/
|
||||
function describeSubject(payload: Record<string, unknown>): Subject | null {
|
||||
const contact = asRecord(payload.contact);
|
||||
if (contact) return subjectOf('contact', contact, [asString(contact.title)]);
|
||||
|
||||
const deal = asRecord(payload.deal);
|
||||
if (deal) {
|
||||
// The two deal shapes are told apart by the sibling array rather than by
|
||||
// sniffing columns: `readFocusedRecord` returns `commitments` beside a
|
||||
// supply deal and `allocations` beside a demand one.
|
||||
return Array.isArray(payload.commitments)
|
||||
? subjectOf('supply_deal', deal, [hardwareFigure(deal), costFigure(deal.targetCostPerGpuHourCents)])
|
||||
: subjectOf('demand_deal', deal, [dealValueFigure(deal)]);
|
||||
}
|
||||
|
||||
const commitment = asRecord(payload.commitment);
|
||||
if (commitment) {
|
||||
return subjectOf('commitment', commitment, [
|
||||
hardwareFigure(commitment),
|
||||
costFigure(commitment.costPerGpuHourCents),
|
||||
]);
|
||||
}
|
||||
|
||||
const contract = asRecord(payload.contract);
|
||||
if (contract) return subjectOf('contract', contract, [asString(contract.status)]);
|
||||
|
||||
const account = asRecord(payload.account);
|
||||
if (account) return subjectOf('account', account, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function subjectOf(
|
||||
kind: RecordKind,
|
||||
record: Record<string, unknown>,
|
||||
figures: (string | null)[],
|
||||
): Subject {
|
||||
return {
|
||||
kind,
|
||||
id: asString(record.id),
|
||||
name: recordName(record) ?? `Unnamed ${RECORD_LABELS[kind]}`,
|
||||
figures: figures.filter((figure): figure is string => figure !== null),
|
||||
};
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
key: string;
|
||||
/** Null where the rows have nowhere to link to — no route lists them. */
|
||||
kind: RecordKind | null;
|
||||
one: string;
|
||||
many: string;
|
||||
}
|
||||
|
||||
/** Every named array `readFocusedRecord` can return, in the order it reads them. */
|
||||
const COLLECTIONS: readonly Collection[] = [
|
||||
{ key: 'contacts', kind: 'contact', one: 'contact', many: 'contacts' },
|
||||
{ key: 'demandDeals', kind: 'demand_deal', one: 'demand deal', many: 'demand deals' },
|
||||
{ key: 'supplyDeals', kind: 'supply_deal', one: 'supply deal', many: 'supply deals' },
|
||||
{ key: 'contracts', kind: 'contract', one: 'contract', many: 'contracts' },
|
||||
{ key: 'commitments', kind: 'commitment', one: 'commitment', many: 'commitments' },
|
||||
{ key: 'allocations', kind: 'allocation', one: 'allocation', many: 'allocations' },
|
||||
{ key: 'slaTerms', kind: null, one: 'SLA term', many: 'SLA terms' },
|
||||
{ key: 'slaMetricTargets', kind: null, one: 'SLA target', many: 'SLA targets' },
|
||||
{ key: 'obligations', kind: null, one: 'obligation', many: 'obligations' },
|
||||
];
|
||||
|
||||
function readCollections(payload: Record<string, unknown>): {
|
||||
counts: string[];
|
||||
links: RecordLink[];
|
||||
} {
|
||||
const counts: string[] = [];
|
||||
const links: RecordLink[] = [];
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const rows = payload[collection.key];
|
||||
if (!Array.isArray(rows)) continue;
|
||||
// Zero is reported rather than skipped. "0 contracts" is the difference
|
||||
// between Piggy having looked and found nothing and Piggy never having
|
||||
// looked, and that distinction is the whole point of showing the working.
|
||||
counts.push(`${rows.length} ${rows.length === 1 ? collection.one : collection.many}`);
|
||||
|
||||
const kind = collection.kind;
|
||||
if (!kind) continue;
|
||||
for (const row of rows) {
|
||||
const record = asRecord(row);
|
||||
const id = record && asString(record.id);
|
||||
if (!record || !id) continue;
|
||||
// An allocation has no name of its own, so it is labelled by kind and a
|
||||
// short id rather than by a bare hex string nobody can place.
|
||||
const label = recordName(record) ?? `${collection.one} ${id.slice(0, 8)}`;
|
||||
links.push({ kind, id, label });
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, links };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lifecycle tool returns a score rather than rows, and the score is what
|
||||
* the answer will have quoted — so it belongs in the summary beside the name.
|
||||
*/
|
||||
function lifecycleFigures(payload: Record<string, unknown>): string[] {
|
||||
const lifecycle = asRecord(payload.lifecycle);
|
||||
if (!lifecycle) return [];
|
||||
const figures: string[] = [];
|
||||
const score = asNumber(lifecycle.score);
|
||||
if (score !== null) figures.push(`lifecycle score ${Math.round(score)}`);
|
||||
const state = asString(lifecycle.relationshipState);
|
||||
if (state) figures.push(state.replaceAll('_', ' '));
|
||||
const blockers = Array.isArray(lifecycle.blockers) ? lifecycle.blockers.length : 0;
|
||||
if (blockers > 0) figures.push(`${blockers} blocker${blockers === 1 ? '' : 's'}`);
|
||||
return figures;
|
||||
}
|
||||
|
||||
/** Accounts and deals carry `name`, contacts `fullName`, contracts `title`. */
|
||||
function recordName(record: Record<string, unknown>): string | null {
|
||||
return asString(record.name) ?? asString(record.fullName) ?? asString(record.title);
|
||||
}
|
||||
|
||||
function hardwareFigure(record: Record<string, unknown>): string | null {
|
||||
const count = asNumber(record.gpuCount);
|
||||
const type = asString(record.gpuType);
|
||||
if (count !== null && type) return `${count}× ${type}`;
|
||||
if (type) return type;
|
||||
return count === null ? null : `${count} GPUs`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `…Cents` column is an integer of US cents, so it goes through the app's
|
||||
* own formatters rather than being divided by a hundred here for the second
|
||||
* time in the codebase. `unitPrice` and not `money`, because this is a price
|
||||
* per GPU-hour: `money` drops the cents when they happen to be round, and $1.89
|
||||
* against $2 is the difference between a quotable figure and a rounded one.
|
||||
*/
|
||||
function costFigure(value: unknown): string | null {
|
||||
const cents = asNumber(value);
|
||||
return cents === null ? null : `${unitPrice(cents)}/GPU-hour`;
|
||||
}
|
||||
|
||||
/** Total contract value where it is known, annual value otherwise — the same
|
||||
* precedence `readPipeline` uses, so the two never disagree about a deal. */
|
||||
function dealValueFigure(deal: Record<string, unknown>): string | null {
|
||||
const cents = asNumber(deal.tcvCents) ?? asNumber(deal.acvCents);
|
||||
return cents === null ? null : money(cents);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ payloads
|
||||
|
||||
/**
|
||||
* The Input section, or nothing at all.
|
||||
*
|
||||
* Most Piggy tools declare `z.object({}).strict()`, so an unconditional input
|
||||
* panel prints `{}` under nearly every chip. A malformed tool call arrives as
|
||||
* the unparsed string the model emitted — that is what makes it malformed — so
|
||||
* it is shown verbatim instead of being stringified into a quoted one-liner.
|
||||
*/
|
||||
function formatArguments(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') return value.trim() || null;
|
||||
const record = asRecord(value);
|
||||
if (record && Object.keys(record).length === 0) return null;
|
||||
return formatPayload(value);
|
||||
}
|
||||
|
||||
function formatPayload(value: unknown): string | null {
|
||||
if (value === undefined) return null;
|
||||
let text: string;
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2) ?? String(value);
|
||||
} catch {
|
||||
// A payload that cannot be serialised must not take the transcript down
|
||||
// with it: the answer above it is still worth reading.
|
||||
return null;
|
||||
}
|
||||
return text.length > RAW_PAYLOAD_MAX_CHARS
|
||||
? `${text.slice(0, RAW_PAYLOAD_MAX_CHARS)}\n\n… shortened for display. Piggy read the whole payload.`
|
||||
: text;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- naming
|
||||
|
||||
/**
|
||||
* Named for the reader, not for the model.
|
||||
*
|
||||
* The generic fallback turns `pig_get_calendar_ahead` into "Get Calendar
|
||||
* Ahead", which is the tool's identifier with the underscores taken out. The
|
||||
* eleven tools interactive chat can actually be given get a name instead —
|
||||
* `createInteractivePigTools` is the list this must keep up with, and the four
|
||||
* lookup tools were the ones reading as "Get Record By Id" until they landed
|
||||
* here.
|
||||
*/
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
pig_get_record: 'Record in focus',
|
||||
pig_get_account_lifecycle: 'Account lifecycle',
|
||||
pig_get_workspace_summary: 'Workspace summary',
|
||||
pig_get_margin_summary: 'Margin book',
|
||||
pig_get_idle_capacity: 'Idle capacity',
|
||||
pig_get_pipeline: 'Open pipeline',
|
||||
pig_get_calendar_ahead: 'Calendar ahead',
|
||||
pig_search_records: 'Record search',
|
||||
pig_get_record_by_id: 'Record lookup',
|
||||
pig_list_renewals: 'Renewal deadlines',
|
||||
pig_list_inventory: 'Provider inventory',
|
||||
};
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return (
|
||||
TOOL_LABELS[name] ??
|
||||
name
|
||||
.replace(/^pig_/, '')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this the transcript stops quoting a figure and admits a floor instead.
|
||||
*
|
||||
* The clock is the gap between the `tool_call` and `tool_result` lines arriving
|
||||
* on the stream, which carries the event loop and the NDJSON parse along with
|
||||
* the query, so a millisecond reading would claim a precision this timing does
|
||||
* not have. A tenth of a second is the finest thing it can honestly say.
|
||||
*/
|
||||
const DURATION_FLOOR_MS = 100;
|
||||
|
||||
/**
|
||||
* One decimal below ten seconds: most calls land under a second, where "0.4s"
|
||||
* carries more than "0s".
|
||||
*
|
||||
* Under the floor it reads "<0.1s" rather than rounding to "0.0s". Against a
|
||||
* database on the same host nearly every real Piggy tool call lands there, and
|
||||
* "0.0s" turned the one number that exists to show the call took time into
|
||||
* something that reads as a failed measurement.
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < DURATION_FLOOR_MS) return '<0.1s';
|
||||
return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- reading
|
||||
//
|
||||
// The payload is `unknown` and must stay that way. It crossed a network from a
|
||||
// process that is free to change its tool return shapes without telling the
|
||||
// browser, so every field is read through a guard and a shape that has drifted
|
||||
// costs a missing line rather than a blank transcript.
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? (value as unknown[]) : [];
|
||||
}
|
||||
|
||||
/** A record type the transcript knows how to open, or nothing. */
|
||||
function asRecordKind(value: unknown): RecordKind | null {
|
||||
const key = asString(value);
|
||||
return key !== null && key in RECORD_ROUTES ? (key as RecordKind) : null;
|
||||
}
|
||||
+114
-24
@@ -41,25 +41,50 @@ export class ApiError extends Error {
|
||||
|
||||
let supabase: SupabaseClient | null = null;
|
||||
let publicConfig: PublicConfig | null = null;
|
||||
/*
|
||||
* The in-flight load, not just its result.
|
||||
*
|
||||
* Guarding on the resolved config alone is not enough: StrictMode invokes the
|
||||
* effect that calls this twice, both calls observe a null config, and both go
|
||||
* on to build an auth client. Two GoTrueClients then share one storage key and
|
||||
* refresh the same session against each other — which Supabase warns about and
|
||||
* which only bites where auth is actually configured, so it never shows up in
|
||||
* a development run with auth disabled. Caching the promise makes the second
|
||||
* caller await the first rather than race it.
|
||||
*/
|
||||
let publicConfigLoad: Promise<PublicConfig> | null = null;
|
||||
|
||||
export async function loadPublicConfig(): Promise<PublicConfig> {
|
||||
if (publicConfig) return publicConfig;
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Could not load configuration from the server.');
|
||||
publicConfig = (await response.json()) as PublicConfig;
|
||||
export function loadPublicConfig(): Promise<PublicConfig> {
|
||||
if (publicConfig) return Promise.resolve(publicConfig);
|
||||
if (publicConfigLoad) return publicConfigLoad;
|
||||
|
||||
if (publicConfig.supabaseUrl && publicConfig.supabaseAnonKey) {
|
||||
supabase = createClient(publicConfig.supabaseUrl, publicConfig.supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
// The session lands in a URL fragment after an email link; picking it
|
||||
// up automatically is what makes magic-link sign-in work.
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
return publicConfig;
|
||||
publicConfigLoad = (async () => {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Could not load configuration from the server.');
|
||||
const loaded = (await response.json()) as PublicConfig;
|
||||
|
||||
if (loaded.supabaseUrl && loaded.supabaseAnonKey) {
|
||||
supabase = createClient(loaded.supabaseUrl, loaded.supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
// The session lands in a URL fragment after an email link; picking it
|
||||
// up automatically is what makes magic-link sign-in work.
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
publicConfig = loaded;
|
||||
return loaded;
|
||||
})();
|
||||
|
||||
// A failed load must not be cached, or a transient network error becomes a
|
||||
// permanent one that only a reload can clear.
|
||||
publicConfigLoad.catch(() => {
|
||||
publicConfigLoad = null;
|
||||
});
|
||||
|
||||
return publicConfigLoad;
|
||||
}
|
||||
|
||||
export function getSupabase(): SupabaseClient | null {
|
||||
@@ -122,6 +147,8 @@ export const patch = <T,>(path: string, body: unknown) =>
|
||||
* Compact above a million because a pipeline view showing "$12,400,000.00" in
|
||||
* a phone-width column is unreadable, and the exact cent is never the point at
|
||||
* that magnitude.
|
||||
*
|
||||
* Not for prices quoted per GPU-hour — use `unitPrice`, which explains why.
|
||||
*/
|
||||
export function money(cents: number | null | undefined, currency = 'USD'): string {
|
||||
if (cents == null) return '—';
|
||||
@@ -146,6 +173,21 @@ export function moneyExact(cents: number | null | undefined, currency = 'USD'):
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* A price quoted per GPU-hour. Always exact, and this is not negotiable.
|
||||
*
|
||||
* `money` hides the cents when they happen to be round, which is right for a
|
||||
* $2.4M deal and wrong here: at this scale the cents *are* the number. A
|
||||
* break-even of 200 cents rendered as "$2" on Overview and "$2.00" on Margin
|
||||
* is the same figure looking like two different figures, and the reader who
|
||||
* spots it stops trusting both screens. Every $/GPU-hr — cost, break-even,
|
||||
* quote, the delta between them — goes through this function, so the decision
|
||||
* is made once here rather than re-argued at each call site.
|
||||
*/
|
||||
export function unitPrice(cents: number | null | undefined, currency = 'USD'): string {
|
||||
return moneyExact(cents, currency);
|
||||
}
|
||||
|
||||
export function percent(value: number | null | undefined, digits = 0): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return `${(value * 100).toFixed(digits)}%`;
|
||||
@@ -159,17 +201,65 @@ export function compactNumber(value: number | null | undefined): string {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function shortDate(value: string | Date | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const DAY = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
|
||||
const DAY_AND_YEAR = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function toDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDay(date: Date, withYear: boolean): string {
|
||||
return (withYear ? DAY_AND_YEAR : DAY).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* A day, carrying its year only when that year is not the current one.
|
||||
*
|
||||
* The year used to be omitted unconditionally, which is fine for "renewal
|
||||
* notice due Nov 3" and disastrous for a commitment window: a term is very
|
||||
* often exactly 365 days, so both ends land in the same month and the window
|
||||
* rendered as "Aug 13 – Aug 12" — a range that reads as running backwards.
|
||||
* Suppressing the year only when it is the one the reader is already in keeps
|
||||
* the common case short without ever printing a date that misleads.
|
||||
*/
|
||||
export function shortDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
if (!date) return '—';
|
||||
return formatDay(date, date.getFullYear() !== new Date().getFullYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Both ends of a window, in one string.
|
||||
*
|
||||
* Ranges need a rule `shortDate` cannot apply alone: when the two ends fall in
|
||||
* different years, *both* need labelling, because "Aug 13 – Aug 12, 2027"
|
||||
* leaves the reader to guess at the start. A range that sits wholly inside a
|
||||
* single past or future year states that year once, at the end, rather than
|
||||
* twice.
|
||||
*/
|
||||
export function dateRange(
|
||||
start: string | Date | null | undefined,
|
||||
end: string | Date | null | undefined,
|
||||
): string {
|
||||
const from = toDate(start);
|
||||
const to = toDate(end);
|
||||
if (!from || !to) return shortDate(from ?? to);
|
||||
|
||||
const spansYears = from.getFullYear() !== to.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const thisYear = !spansYears && from.getFullYear() === currentYear;
|
||||
return `${formatDay(from, spansYears)} – ${formatDay(to, !thisYear)}`;
|
||||
}
|
||||
|
||||
export function relativeTime(value: string | Date | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const date = toDate(value);
|
||||
if (!date) return '—';
|
||||
|
||||
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* it is a page that answers 403, and offering it is worse than omitting it.
|
||||
*/
|
||||
import {
|
||||
BookUser,
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
@@ -63,7 +64,11 @@ export const NAV: NavItem[] = [
|
||||
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
|
||||
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
|
||||
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
|
||||
{ to: '/accounts', label: 'Accounts', icon: Building2, group: 'Records' },
|
||||
// BookUser rather than a second Building2: the sidebar collapses to icons
|
||||
// only, and Demand already owns the office block. Two rows sharing a glyph
|
||||
// are two rows you have to expand the sidebar to tell apart. It is also the
|
||||
// truer icon — this page is the directory of accounts *and* their people.
|
||||
{ to: '/accounts', label: 'Accounts', icon: BookUser, group: 'Records' },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileText, group: 'Records' },
|
||||
{
|
||||
to: '/imports',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { PiggyChatContext } from '@pig/core';
|
||||
import { ApiError, getSupabase } from './api';
|
||||
|
||||
@@ -34,6 +35,402 @@ export interface PiggyStatus {
|
||||
canUse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's own cap (`message: z.string().trim().min(1).max(4_000)`).
|
||||
* Named here so the composer stops the user at the same number rather than
|
||||
* letting them write a long question and collecting a 400 for it.
|
||||
*/
|
||||
export const PIGGY_MESSAGE_MAX_LENGTH = 4_000;
|
||||
|
||||
/** The relay's per-turn history cap. A longer turn is a 400 for the whole send. */
|
||||
const HISTORY_CONTENT_MAX_LENGTH = 8_000;
|
||||
|
||||
/** The relay accepts at most twenty prior turns. */
|
||||
const HISTORY_MAX_TURNS = 20;
|
||||
|
||||
// ------------------------------------------------------------- transcript
|
||||
|
||||
/**
|
||||
* One tool round trip, as the transcript remembers it.
|
||||
*
|
||||
* `result` and `durationMs` are not decoration: the server already streams the
|
||||
* tool's payload and the timeline used to throw it away, so "where did that
|
||||
* number come from?" had no answer inside the UI.
|
||||
*/
|
||||
export interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
/** The tool's own payload, verbatim, so the answer can be checked against it. */
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
/** Wall-clock time the call took, filled in when its result arrives. */
|
||||
durationMs?: number;
|
||||
/**
|
||||
* `performance.now()` at the `tool_call`. No event on the wire carries a
|
||||
* timestamp, so the only clock available to us is this one.
|
||||
*/
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
tools?: ToolStep[];
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
/** The user pressed stop. The answer is as complete as it will ever be. */
|
||||
stopped?: boolean;
|
||||
/** The response body closed without a `done` or an `error`. */
|
||||
truncated?: boolean;
|
||||
/** A user turn the relay never accepted. It is in the transcript but not in the model's. */
|
||||
failed?: boolean;
|
||||
/**
|
||||
* Epoch milliseconds before which re-sending this turn would be refused
|
||||
* again. Set only by a refusal that told us when it stops refusing — the
|
||||
* hourly rate limit — so that the transcript offers a wait rather than a
|
||||
* button whose one job is to collect the same 429.
|
||||
*/
|
||||
retryableAt?: number;
|
||||
/** From the `meta` event: which model actually answered. */
|
||||
model?: string;
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one streamed event into the assistant turn.
|
||||
*
|
||||
* Exported because the transcript state model is shared with the components
|
||||
* that render it, and a second copy of this reducer would drift from the event
|
||||
* union it consumes.
|
||||
*/
|
||||
export function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
|
||||
if (event.type === 'meta') return { ...message, model: event.model };
|
||||
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
|
||||
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
|
||||
if (event.type === 'tool_call') {
|
||||
const step: ToolStep = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
arguments: event.arguments,
|
||||
state: 'running',
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
return { ...message, tools: [...(message.tools ?? []), step] };
|
||||
}
|
||||
if (event.type === 'tool_result') {
|
||||
return {
|
||||
...message,
|
||||
tools: (message.tools ?? []).map((tool) =>
|
||||
tool.id === event.id
|
||||
? {
|
||||
...tool,
|
||||
state: event.ok ? 'succeeded' : 'failed',
|
||||
result: event.result,
|
||||
error: event.error,
|
||||
durationMs: Math.round(performance.now() - tool.startedAt),
|
||||
}
|
||||
: tool,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (event.type === 'done') {
|
||||
return { ...message, pending: false, inputTokens: event.inputTokens, outputTokens: event.outputTokens };
|
||||
}
|
||||
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn worth offering a re-send for: one that ended without an answer
|
||||
* through no choice of the user's. A stopped turn is excluded deliberately —
|
||||
* the user asked for it to end.
|
||||
*
|
||||
* So is a turn the hourly limit refused, until the hour it named has passed.
|
||||
* Retry sends the identical request to the identical limiter, so before then
|
||||
* the button cannot do the one thing it offers; the wait is in the turn's error
|
||||
* sentence instead, and `usePiggyConversation` re-renders when it elapses so
|
||||
* the button comes back the moment it means something.
|
||||
*/
|
||||
export function isRetryable(message: TranscriptMessage): boolean {
|
||||
if (message.role !== 'assistant') return false;
|
||||
if (!message.error && !message.truncated) return false;
|
||||
// The clock is read here rather than taken as a defaulted second parameter:
|
||||
// `messages.filter(isRetryable)` would then hand it the array index, which
|
||||
// typechecks and quietly answers the wrong question.
|
||||
return message.retryableAt === undefined || Date.now() >= message.retryableAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turns worth replaying to the model.
|
||||
*
|
||||
* Two exclusions, both load-bearing rather than tidiness. A `failed` user turn
|
||||
* is one the relay refused, so the model has never seen it; replaying it asks
|
||||
* for an answer to a question the user has since retried, and after the retry
|
||||
* it would be in there twice. An `error`ed assistant turn is dropped because
|
||||
* anything it holds is a fragment the model never finished, and its visible
|
||||
* text is our own error copy — which it would read back as its own words.
|
||||
*/
|
||||
export function toChatHistory(messages: TranscriptMessage[]): PiggyChatTurn[] {
|
||||
return messages
|
||||
.filter((entry) => !entry.failed && !entry.error && entry.content.trim())
|
||||
.slice(-HISTORY_MAX_TURNS)
|
||||
.map((entry) => ({ role: entry.role, content: entry.content.slice(0, HISTORY_CONTENT_MAX_LENGTH) }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refusals
|
||||
|
||||
/** The relay's code for a spent hourly quota, as `apps/api` writes it. */
|
||||
const PIGGY_RATE_LIMITED = 'piggy_rate_limited';
|
||||
|
||||
/**
|
||||
* How long to sit out a 429 that arrived without a retry-after.
|
||||
*
|
||||
* Only an intermediary that dropped both the header and the body can produce
|
||||
* one, so this is a guess — kept short, because a wait invented here that is
|
||||
* longer than the real one strands a user who could have asked again.
|
||||
*/
|
||||
const UNKNOWN_WAIT_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* A refusal that carries when it stops being a refusal.
|
||||
*
|
||||
* `ApiError` is shared with the whole REST client and has nowhere to put the
|
||||
* relay's `retryAfterSeconds`, so the transcript used to see a rate limit as an
|
||||
* ordinary failed turn — indistinguishable from a dropped connection, and
|
||||
* offered the same Retry button, which spent the user's next request on the
|
||||
* identical refusal.
|
||||
*/
|
||||
export class PiggyRateLimitError extends ApiError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryAfterSeconds: number | null,
|
||||
) {
|
||||
super(message, 429, PIGGY_RATE_LIMITED);
|
||||
this.name = 'PiggyRateLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
interface Refusal {
|
||||
message: string;
|
||||
/** Epoch ms, when the failure named a time before which a retry is pointless. */
|
||||
retryableAt?: number;
|
||||
}
|
||||
|
||||
function describeFailure(error: unknown): Refusal {
|
||||
if (error instanceof PiggyRateLimitError) {
|
||||
const clearsAt = rateLimitClearsAt(error.retryAfterSeconds);
|
||||
return { message: rateLimitMessage(clearsAt), retryableAt: clearsAt.getTime() };
|
||||
}
|
||||
return { message: error instanceof Error ? error.message : 'Piggy chat failed.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* One moment, used for both the sentence and the return of the Retry button, so
|
||||
* that the two cannot disagree.
|
||||
*
|
||||
* Rounded up to the whole minute because the real window almost always ends
|
||||
* part-way through one: naming the minute it ends in would invite a retry a few
|
||||
* seconds early, and the limiter would refuse that too.
|
||||
*/
|
||||
function rateLimitClearsAt(retryAfterSeconds: number | null): Date {
|
||||
const seconds = retryAfterSeconds ?? UNKNOWN_WAIT_SECONDS;
|
||||
return new Date(Date.now() + Math.ceil(seconds / 60) * 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wait still worth reading ten minutes later.
|
||||
*
|
||||
* "Try again in twelve minutes" is written once and then goes quietly wrong as
|
||||
* it sits in the transcript, which is the same defect as a duration that reads
|
||||
* "0.0s": a number that stopped being a measurement. A clock time does not
|
||||
* drift, and the user's question is left on screen above it, so the sentence
|
||||
* says what will happen to it rather than only what went wrong.
|
||||
*/
|
||||
function rateLimitMessage(clearsAt: Date): string {
|
||||
const time = clearsAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
return `You have used this hour's Piggy questions. The limit clears at ${time}, when Retry will work again.`;
|
||||
}
|
||||
|
||||
/** Both the body field and the header are integers of seconds, and both may be absent. */
|
||||
function readRetryAfter(value: unknown): number | null {
|
||||
// `Number('')` is zero, which would print a limit that clears immediately.
|
||||
if (typeof value === 'string' && !value.trim()) return null;
|
||||
const seconds = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ conversation
|
||||
|
||||
export interface PiggyConversation {
|
||||
messages: TranscriptMessage[];
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
running: boolean;
|
||||
/**
|
||||
* Send `text`, or the composer draft when it is omitted — a suggestion chip
|
||||
* and the retry button both have something to say and no reason to make the
|
||||
* user press send afterwards.
|
||||
*
|
||||
* `from` is the transcript the history is built out of. Only `retry` passes
|
||||
* it, with the exchange being replaced already removed, because that
|
||||
* exchange is superseded rather than continued.
|
||||
*/
|
||||
send: (text?: string, from?: TranscriptMessage[]) => void;
|
||||
stop: () => void;
|
||||
/** Re-ask the question that produced this failed or truncated answer. */
|
||||
retry: (assistantId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole client side of a Piggy conversation, deliberately separable from
|
||||
* the panel that renders it.
|
||||
*
|
||||
* It lives outside the panel because the panel is destroyed and rebuilt more
|
||||
* often than the conversation should be: the sheet and the drawer unmount
|
||||
* their children on close, and a thread that evaporates because the user
|
||||
* dismissed an overlay to look at the record behind it is the single most
|
||||
* expensive thing this UI can do. Whoever stays mounted owns the hook and
|
||||
* passes the result down.
|
||||
*/
|
||||
export function usePiggyConversation({
|
||||
context,
|
||||
initialPrompt = '',
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
} = {}): PiggyConversation {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const [running, setRunning] = useState(false);
|
||||
/**
|
||||
* The gate `send` actually reads, because `running` cannot close in time.
|
||||
*
|
||||
* A state flag only takes effect once React has re-rendered, so two Send
|
||||
* presses inside one frame — a double click, a held Enter key, a chip pressed
|
||||
* twice — both saw `running: false` and both opened a stream. Measured: three
|
||||
* clicks dispatched together produced three relay calls, three questions in
|
||||
* the transcript and three answers interleaving into it. That is three of the
|
||||
* user's thirty hourly messages spent at once, and only the last stream is
|
||||
* still reachable by Stop, since each one overwrites `abortRef`. A ref is
|
||||
* written synchronously, so the second press is refused by the first.
|
||||
*/
|
||||
const runningRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Bumped only to re-read the clock. `isRetryable` withholds the Retry button
|
||||
// while a rate limit holds, and nothing else in a transcript nobody is typing
|
||||
// into would ever re-render to bring it back.
|
||||
const [retryClock, setRetryClock] = useState(0);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const waits = messages
|
||||
.map((entry) => entry.retryableAt)
|
||||
.filter((at): at is number => at !== undefined && at > now);
|
||||
if (!waits.length) return;
|
||||
// One timer for the soonest wait; the effect re-runs when it fires and arms
|
||||
// the next, so a transcript with several refusals still costs one timeout.
|
||||
const timer = setTimeout(() => setRetryClock(Date.now()), Math.min(...waits) - now);
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, retryClock]);
|
||||
|
||||
const updateTurn = (id: string, change: (turn: TranscriptMessage) => TranscriptMessage) =>
|
||||
setMessages((current) => current.map((entry) => (entry.id === id ? change(entry) : entry)));
|
||||
|
||||
const send = async (text?: string, from?: TranscriptMessage[]) => {
|
||||
const message = (text ?? draft).trim();
|
||||
if (!message || runningRef.current) return;
|
||||
// Claimed before the first await, so nothing else can enter this turn.
|
||||
runningRef.current = true;
|
||||
const userId = crypto.randomUUID();
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history = toChatHistory(from ?? messages);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ id: userId, role: 'user', content: message },
|
||||
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
|
||||
]);
|
||||
// Only the composer's own text is cleared. A suggestion or a retry has not
|
||||
// touched what the user was typing and must not throw it away.
|
||||
if (text === undefined) setDraft('');
|
||||
setRunning(true);
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
// Tracked here rather than read back out of state: `messages` is a stale
|
||||
// closure by the time the stream finishes, and the question we need to
|
||||
// answer — did anything terminate this turn? — is about the events, not
|
||||
// about what React has committed.
|
||||
let settled = false;
|
||||
try {
|
||||
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
|
||||
if (event.type === 'done' || event.type === 'error') settled = true;
|
||||
updateTurn(assistantId, (turn) => applyEvent(turn, event));
|
||||
}
|
||||
if (!settled) {
|
||||
// The body closed mid-answer. `readNdjson` returns normally when that
|
||||
// happens, so without this the turn stays `pending` forever and a dead
|
||||
// connection is indistinguishable from Piggy still thinking.
|
||||
updateTurn(assistantId, (turn) => ({ ...turn, pending: false, truncated: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) {
|
||||
// Aborting rejects the read, so neither `done` nor `error` ever
|
||||
// arrives and nothing else will clear `pending` — which left the
|
||||
// docked panel spinning across every subsequent navigation.
|
||||
updateTurn(assistantId, (turn) => ({ ...turn, pending: false, stopped: true }));
|
||||
} else {
|
||||
const failure = describeFailure(error);
|
||||
setMessages((current) =>
|
||||
current.map((entry) => {
|
||||
if (entry.id === assistantId) {
|
||||
return { ...entry, pending: false, error: failure.message, retryableAt: failure.retryableAt };
|
||||
}
|
||||
// The question is marked, not deleted: the user's words stay on
|
||||
// screen to be re-sent, and `toChatHistory` knows to keep a turn
|
||||
// the relay refused out of the model's history.
|
||||
if (entry.id === userId) return { ...entry, failed: true };
|
||||
return entry;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
runningRef.current = false;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retry = (assistantId: string) => {
|
||||
if (runningRef.current) return;
|
||||
const index = messages.findIndex((entry) => entry.id === assistantId);
|
||||
const question = index > 0 ? messages[index - 1] : undefined;
|
||||
if (!question || question.role !== 'user') return;
|
||||
// Drop the failed exchange rather than leaving it above the new one: the
|
||||
// same question twice in the transcript reads as Piggy having been asked
|
||||
// twice, and a partial answer left in place would be replayed as history
|
||||
// for the very question it failed to answer.
|
||||
setMessages((current) => current.filter((entry) => entry.id !== question.id && entry.id !== assistantId));
|
||||
void send(question.content, messages.slice(0, index - 1));
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
draft,
|
||||
setDraft,
|
||||
running,
|
||||
send: (text, fromTranscript) => void send(text, fromTranscript),
|
||||
stop: () => abortRef.current?.abort(),
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamPiggyChat(
|
||||
request: {
|
||||
message: string;
|
||||
@@ -57,13 +454,29 @@ export async function* streamPiggyChat(
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
let code: string | undefined;
|
||||
let retryAfterSeconds: number | null = null;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string; code?: string };
|
||||
const body = (await response.json()) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
retryAfterSeconds?: unknown;
|
||||
};
|
||||
message = body.error ?? message;
|
||||
code = body.code;
|
||||
retryAfterSeconds = readRetryAfter(body.retryAfterSeconds);
|
||||
} catch {
|
||||
// The authenticated proxy normally returns JSON, but an upstream proxy may not.
|
||||
}
|
||||
if (response.status === 429) {
|
||||
// The header is read as the fallback rather than the body's field alone:
|
||||
// an intermediary of its own may rate-limit us with a bare `Retry-After`
|
||||
// and no JSON at all, and a wait we cannot name is one the user is told
|
||||
// to guess at.
|
||||
throw new PiggyRateLimitError(
|
||||
message,
|
||||
retryAfterSeconds ?? readRetryAfter(response.headers.get('retry-after')),
|
||||
);
|
||||
}
|
||||
throw new ApiError(message, response.status, code);
|
||||
}
|
||||
if (!response.body) throw new Error('Piggy returned no response stream.');
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* The questions Piggy offers before anyone has typed.
|
||||
*
|
||||
* These are chosen by what Piggy can actually answer where it is standing, not
|
||||
* by what the page is called. Interactive chat is given exactly one read tool —
|
||||
* `createInteractivePigTools` picks it from the record type, or from
|
||||
* `piggyPageGuide` for a route — so a starter the page's one tool cannot
|
||||
* ground is not merely unhelpful: it burns one of four turns and comes back
|
||||
* hedged. That is worse than offering nothing, so every line below was written
|
||||
* against the payload its tool returns and checked against the seeded book.
|
||||
*
|
||||
* Two consequences worth stating, because they look like omissions:
|
||||
*
|
||||
* - Pages are grouped by tool, not by subject. /accounts and /facts get the
|
||||
* same book questions as the dashboard because all three resolve to
|
||||
* `pig_get_workspace_summary`, which knows nothing about accounts or facts.
|
||||
* Asking "which account is at risk?" from /accounts reads beautifully and
|
||||
* cannot be answered.
|
||||
* - No starter names a horizon in days. `pig_get_calendar_ahead` takes
|
||||
* `withinDays` and defaults to 30, and a question phrased around a quarter
|
||||
* is only answered if the model chooses to pass the argument. Everything
|
||||
* here returns something inside the default window.
|
||||
*
|
||||
* The voice is the desk's, not a chatbot's: a question with a decision behind
|
||||
* it beats a request for a summary, because the summary is already on screen.
|
||||
*/
|
||||
import { isPageContext, type PiggyPageRoute, type PiggyRecordType } from '@pig/core';
|
||||
import type { PiggyChatContext } from '@/lib/piggy-chat';
|
||||
|
||||
// --------------------------------------------------------------- by tool
|
||||
|
||||
/** `pig_get_workspace_summary`: book margin, utilisation, open deal counts, worst idle blocks. */
|
||||
const BOOK = [
|
||||
'Which block is losing us the most on hours nobody has bought?',
|
||||
'Is the book covering its cost once the idle hours are charged in?',
|
||||
'How much of the capacity we have bought is still unsold?',
|
||||
'Do we have enough open demand to cover the hours we have already bought?',
|
||||
];
|
||||
|
||||
/** `pig_get_margin_summary`: totals plus the largest blocks by cost. */
|
||||
const MARGIN = [
|
||||
'Which of the big blocks is dragging the book down?',
|
||||
'What is gross margin once the full cost of every commitment is counted?',
|
||||
'What are we clearing per allocated GPU-hour?',
|
||||
'How many hours have we bought this term and not sold?',
|
||||
];
|
||||
|
||||
/** `pig_get_idle_capacity`: unsold blocks with their idle cost and break-even price. */
|
||||
const IDLE = [
|
||||
'Which commitment is furthest from break-even?',
|
||||
'What would the remaining hours have to fetch to cover each block?',
|
||||
'What are the unsold hours costing us across the book?',
|
||||
'Which unsold block runs out of term first?',
|
||||
];
|
||||
|
||||
/**
|
||||
* `pig_get_pipeline`, asked as a seller would ask it.
|
||||
*
|
||||
* There is no both-sides set any more: /demand and /supply are the only routes
|
||||
* this tool answers, and each one has a desk behind it. A neutral set existed
|
||||
* for /growth, which now resolves to the idle tool instead.
|
||||
*/
|
||||
const DEMAND_PIPELINE = [
|
||||
'Which open deal is worth the most, and when is it meant to land?',
|
||||
'How much of the pipeline has slipped past its expected close date?',
|
||||
'Which stage is holding the most value?',
|
||||
'What should we be chasing to close this month?',
|
||||
];
|
||||
|
||||
/** The same tool, asked as a buyer would. */
|
||||
const SUPPLY_PIPELINE = [
|
||||
'What capacity are we still negotiating, and at what target cost?',
|
||||
'Which supply deal would put the most GPUs on the book?',
|
||||
'Are we lining up more capacity than the demand side can absorb?',
|
||||
'What is stuck in diligence on the supply side?',
|
||||
];
|
||||
|
||||
/** `pig_get_calendar_ahead`: the thirteen-kind projection, plus what is overdue. */
|
||||
const DATES = [
|
||||
'What has already slipped and still needs chasing?',
|
||||
'What has to happen in the next month?',
|
||||
'Which holds expire before the deal behind them closes?',
|
||||
'How much pipeline is dated to close inside the window?',
|
||||
];
|
||||
|
||||
/**
|
||||
* The calendar again, from /contracts. Narrower on purpose: the tool projects
|
||||
* dates, so a question about a term or a party has nothing to read.
|
||||
*/
|
||||
const CONTRACT_DATES = [
|
||||
'Which renewal or notice date lands next?',
|
||||
'What obligations fall due in the next month?',
|
||||
'What is overdue that we should have dealt with by now?',
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------- by record
|
||||
|
||||
/**
|
||||
* `pig_get_record` for each type, and for an account the lifecycle read as
|
||||
* well. Scoped to what that read returns and no further: a contact carries its
|
||||
* account row but none of the account's deals, and a supply deal's commitments
|
||||
* are only attached once the deal is live.
|
||||
*/
|
||||
const RECORD: Record<PiggyRecordType, string[]> = {
|
||||
account: [
|
||||
'Is this account getting less attention than it deserves?',
|
||||
'What is open with them, and what stage is it stuck at?',
|
||||
'What is blocking the next step here?',
|
||||
'How many hours have we actually sold this account?',
|
||||
],
|
||||
contact: [
|
||||
'Who is this, and can they sign?',
|
||||
'What do we know about them, and how well sourced is it?',
|
||||
'What should I know before I contact them?',
|
||||
],
|
||||
demand_deal: [
|
||||
'Are the hours behind this deal actually booked?',
|
||||
'How many GPU-hours does this take out of the book, and at what price?',
|
||||
'Is this going to close when it says it will?',
|
||||
'What paperwork is still missing before this can sign?',
|
||||
],
|
||||
supply_deal: [
|
||||
'What is still outstanding before we can sign this block?',
|
||||
'How many GPU-hours would this add, and at what cost per hour?',
|
||||
'How long are we tied in for if we take it?',
|
||||
'What did technical and financial diligence conclude?',
|
||||
],
|
||||
contract: [
|
||||
'What should we do about this renewal?',
|
||||
'When must we serve notice to stop this renewing itself?',
|
||||
'What obligations on this are still outstanding?',
|
||||
'What are we on the hook for if we do not use the capacity?',
|
||||
],
|
||||
commitment: [
|
||||
'How much of this block is still unsold?',
|
||||
'What would the remaining hours have to fetch to cover it?',
|
||||
'How much term is left to sell the rest into?',
|
||||
'Who is holding hours on this block, and at what price?',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Partial, and deliberately so — it mirrors the GUIDES table in
|
||||
* apps/piggy/src/page-routes.ts. A route added there without a guide falls back
|
||||
* to the workspace summary, and a route added here without an entry must fall
|
||||
* back to the questions that tool can answer. /learn is the live example: it
|
||||
* has no guide, so it is answered from the book like everything else.
|
||||
*/
|
||||
const PAGE: Partial<Record<PiggyPageRoute, string[]>> = {
|
||||
'/': BOOK,
|
||||
/*
|
||||
* Idle, not pipeline. /growth's guide names `pig_get_idle_capacity` — the
|
||||
* page's own figures are the idle ones — so a pipeline starter offered here
|
||||
* asks about stage counts and deal values that the one tool the model is
|
||||
* given cannot see. It would burn a turn and come back hedged.
|
||||
*/
|
||||
'/growth': IDLE,
|
||||
'/margin': MARGIN,
|
||||
'/calendar': DATES,
|
||||
'/capacity': IDLE,
|
||||
'/demand': DEMAND_PIPELINE,
|
||||
'/supply': SUPPLY_PIPELINE,
|
||||
'/contracts': CONTRACT_DATES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Three or four openers for the context Piggy is in, most useful first.
|
||||
*
|
||||
* No context is the dashboard case by another name — `createInteractivePigTools`
|
||||
* resolves it to the same workspace summary — so it gets the same book
|
||||
* questions rather than a vaguer set of its own.
|
||||
*/
|
||||
export function piggySuggestions(context?: PiggyChatContext): string[] {
|
||||
const chosen = !context
|
||||
? BOOK
|
||||
: isPageContext(context)
|
||||
? (PAGE[context.route] ?? BOOK)
|
||||
: RECORD[context.type];
|
||||
// Copied, because these are module-level tables every caller shares and a
|
||||
// consumer that sorts or splices what it was handed would rewrite them.
|
||||
return [...chosen];
|
||||
}
|
||||
|
||||
/** How many starters stay on offer once the conversation has begun. */
|
||||
export const PIGGY_FOLLOW_UP_COUNT = 2;
|
||||
|
||||
/**
|
||||
* The starters worth keeping above the composer after the first turn.
|
||||
*
|
||||
* The full grid is a blank-state device and cannot survive the transcript — it
|
||||
* would push the answer off screen on a phone. A quiet pair can, and a second
|
||||
* question is the common case: today the openers vanish on the first send and
|
||||
* the user is left with an empty composer and no idea what else Piggy reads.
|
||||
*
|
||||
* `asked` is whatever the user has already sent, so a chip cannot offer back a
|
||||
* question that is already in the transcript above it. Compared loosely
|
||||
* because `send` trims before dispatching, so the stored turn rarely matches
|
||||
* the chip byte for byte.
|
||||
*/
|
||||
export function piggyFollowUps(
|
||||
context: PiggyChatContext | undefined,
|
||||
asked: readonly string[] = [],
|
||||
): string[] {
|
||||
const spent = new Set(asked.map(normalise));
|
||||
return piggySuggestions(context)
|
||||
.filter((suggestion) => !spent.has(normalise(suggestion)))
|
||||
.slice(0, PIGGY_FOLLOW_UP_COUNT);
|
||||
}
|
||||
|
||||
function normalise(text: string): string {
|
||||
return text.trim().toLowerCase();
|
||||
}
|
||||
@@ -4,3 +4,28 @@ import { twMerge } from 'tailwind-merge';
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's name with its demonstration marker taken off.
|
||||
*
|
||||
* Every seeded row is titled "DEMO — Halcyon Research" so that nobody mistakes
|
||||
* the sample book for a real one. That prefix is a label on the record, not
|
||||
* part of the name, and anything that reads the name positionally — initials,
|
||||
* a first name, a sort key — reads the label instead: seven Growth cards all
|
||||
* initialled "DE", and a landing page that greets the reader as "DEMO".
|
||||
*/
|
||||
export function withoutDemoPrefix(name: string): string {
|
||||
return name.replace(/^(DEMO|EXAMPLE)\s+—\s+/, '');
|
||||
}
|
||||
|
||||
/** Up to two initials, for an avatar tile. Falls back rather than rendering empty. */
|
||||
export function initials(name: string): string {
|
||||
return (
|
||||
withoutDemoPrefix(name)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('') || 'PIG'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
// Streamdown's markdown renderer (Piggy's answers) emits its own markup with
|
||||
// prebuilt styles. Its package `exports` map hides the package root, so there
|
||||
// is no path Tailwind's content globs could scan; this stylesheet is the
|
||||
// supported way to have that markup styled. Ahead of index.css so PIG's own
|
||||
// layers stay last.
|
||||
import 'streamdown/styles.css';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@ import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Building2, Mail, Pencil, Plus, RefreshCw, Search, UserPlus } from 'lucide-react';
|
||||
import { Building2, ChevronRight, Mail, Pencil, Plus, RefreshCw, Search, UserPlus } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets';
|
||||
import { DataTable, DataTableColumnHeader } from '@/components/DataTable';
|
||||
import { Badge, Button, Card, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
@@ -34,17 +35,17 @@ export function Accounts() {
|
||||
const visibleContacts = useMemo(() => !deferredSearch ? contactsQuery.data ?? [] : (contactsQuery.data ?? []).filter((row) => [row.contact.fullName, row.contact.email, row.contact.title, row.accountName].some((value) => value?.toLocaleLowerCase().includes(deferredSearch))), [contactsQuery.data, deferredSearch]);
|
||||
|
||||
const accountColumns: ColumnDef<AccountRecord>[] = [
|
||||
{ id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.name}</p>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> },
|
||||
{ id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><Link to={`/accounts/${row.original.id}`} className="truncate block font-medium underline-offset-4 hover:text-accent-fg hover:underline">{row.original.name}</Link>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> },
|
||||
{ accessorKey: 'side', header: ({ column }) => <DataTableColumnHeader column={column} title="Side" />, cell: ({ row }) => <SideBadge side={row.original.side} /> },
|
||||
{ id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = accountType(row.original); return type ? <span className="capitalize">{type.replace(/_/g, ' ')}</span> : '—'; } },
|
||||
{ accessorKey: 'country', header: ({ column }) => <DataTableColumnHeader column={column} title="Country" />, cell: ({ row }) => row.original.country ?? '—' },
|
||||
{ accessorKey: 'confidence', header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.confidence} /> },
|
||||
{ accessorKey: 'lastActivityAt', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' },
|
||||
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end gap-1"><Button size="icon" variant="ghost" title="Add contact" disabled={!canAccount(row.original)} onClick={() => setContactSheet({ open: true, accountId: row.original.id })}><UserPlus aria-hidden /><span className="sr-only">Add contact to {row.original.name}</span></Button><Button size="icon" variant="ghost" title="Edit account" disabled={!canAccount(row.original)} onClick={() => setAccountSheet({ open: true, record: row.original })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.name}</span></Button></div> },
|
||||
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end gap-1">{/* The name is a link, but a link that only reveals itself on hover is a drill-down nobody finds; this is the affordance. */}<Button size="icon" variant="ghost" title="Open account" asChild><Link to={`/accounts/${row.original.id}`}><ChevronRight aria-hidden /><span className="sr-only">Open {row.original.name}</span></Link></Button><Button size="icon" variant="ghost" title="Add contact" disabled={!canAccount(row.original)} onClick={() => setContactSheet({ open: true, accountId: row.original.id })}><UserPlus aria-hidden /><span className="sr-only">Add contact to {row.original.name}</span></Button><Button size="icon" variant="ghost" title="Edit account" disabled={!canAccount(row.original)} onClick={() => setAccountSheet({ open: true, record: row.original })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.name}</span></Button></div> },
|
||||
];
|
||||
const contactColumns: ColumnDef<ContactRow>[] = [
|
||||
{ id: 'contact', accessorFn: (row) => `${row.contact.fullName} ${row.contact.email ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Contact" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.contact.fullName}</p>{row.original.contact.email ? <p className="truncate text-xs text-muted">{row.original.contact.email}</p> : <p className="text-xs text-muted">No email recorded</p>}</div> },
|
||||
{ accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.accountName ?? 'Unassigned' },
|
||||
{ accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.contact.accountId && row.original.accountName ? <Link to={`/accounts/${row.original.contact.accountId}`} className="underline-offset-4 hover:text-accent-fg hover:underline">{row.original.accountName}</Link> : 'Unassigned' },
|
||||
{ id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, cell: ({ row }) => row.original.contact.title ?? '—' },
|
||||
{ id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => <span className="capitalize">{row.original.contact.affiliation.replace(/_/g, ' ')}</span> },
|
||||
{ id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.contact.confidence} /> },
|
||||
@@ -66,8 +67,8 @@ export function Accounts() {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{account.name}</p><p className="mt-0.5 truncate text-sm text-muted">{account.domain ?? 'No domain recorded'}</p></div><SideBadge side={account.side} /></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Relationship" value={type ? type.replace(/_/g, ' ') : 'Not classified'} /><RecordValue label="Geography" value={account.country ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={account.confidence} />} /><RecordValue label="Last activity" value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : 'No activity'} /></div><div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3"><Button className="min-h-11" variant="outline" disabled={!writable} onClick={onAddContact}><UserPlus aria-hidden />Add contact</Button><Button className="min-h-11" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit account</Button></div></article>; }
|
||||
function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{row.contact.fullName}</p><p className="mt-0.5 truncate text-sm text-muted">{row.contact.title ?? 'No title recorded'}</p></div><Badge tone="neutral">{row.contact.affiliation.replace(/_/g, ' ')}</Badge></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Account" value={row.accountName ?? 'Unassigned'} /><RecordValue label="Email" value={row.contact.email ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={row.contact.confidence} />} /><RecordValue label="Last activity" value={row.contact.lastActivityAt ? relativeTime(row.contact.lastActivityAt) : 'No activity'} /></div><Button className="mt-3 min-h-11 w-full border-t border-border" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit contact</Button></article>; }
|
||||
function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0">{/* The whole title is the tap target: on a phone a link the width of the text is the difference between opening the record and selecting it. */}<Link to={`/accounts/${account.id}`} className="tap flex items-center gap-1 truncate font-semibold underline-offset-4 hover:underline">{account.name}<ChevronRight className="size-4 shrink-0 text-muted" aria-hidden /></Link><p className="mt-0.5 truncate text-sm text-muted">{account.domain ?? 'No domain recorded'}</p></div><SideBadge side={account.side} /></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Relationship" value={type ? type.replace(/_/g, ' ') : 'Not classified'} /><RecordValue label="Geography" value={account.country ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={account.confidence} />} /><RecordValue label="Last activity" value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : 'No activity'} /></div><div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3"><Button className="min-h-11" variant="outline" disabled={!writable} onClick={onAddContact}><UserPlus aria-hidden />Add contact</Button><Button className="min-h-11" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit account</Button></div></article>; }
|
||||
function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{row.contact.fullName}</p><p className="mt-0.5 truncate text-sm text-muted">{row.contact.title ?? 'No title recorded'}</p></div><Badge tone="neutral">{row.contact.affiliation.replace(/_/g, ' ')}</Badge></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Account" value={row.contact.accountId && row.accountName ? <Link className="underline-offset-4 hover:underline" to={`/accounts/${row.contact.accountId}`}>{row.accountName}</Link> : 'Unassigned'} /><RecordValue label="Email" value={row.contact.email ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={row.contact.confidence} />} /><RecordValue label="Last activity" value={row.contact.lastActivityAt ? relativeTime(row.contact.lastActivityAt) : 'No activity'} /></div><Button className="mt-3 min-h-11 w-full border-t border-border" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit contact</Button></article>; }
|
||||
function RecordValue({ label, value }: { label: string; value: React.ReactNode }) { return <div className="min-w-0 rounded-lg bg-surface-2 p-2.5"><p className="text-[11px] uppercase tracking-wide text-muted">{label}</p><div className="mt-1 truncate capitalize text-xs font-medium">{value}</div></div>; }
|
||||
function SideBadge({ side }: { side: AccountRecord['side'] }) { return <Badge tone={side === 'supply' ? 'info' : side === 'both' ? 'accent' : 'neutral'}>{side === 'supply' ? 'Buy-side' : side === 'demand' ? 'Sell-side' : 'Both sides'}</Badge>; }
|
||||
function Confidence({ confidence }: { confidence: string }) { return confidence === 'confirmed' ? <span className="text-muted">Confirmed</span> : <ConfidenceBadge confidence={confidence} />; }
|
||||
|
||||
+853
-36
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ import {
|
||||
type ContractType,
|
||||
type SlaKind,
|
||||
} from '@pig/core';
|
||||
import { get, money, patch, post, shortDate } from '@/lib/api';
|
||||
import { dateRange, get, money, patch, post, shortDate } from '@/lib/api';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { can, canAny } from '@/lib/permissions';
|
||||
@@ -526,7 +526,7 @@ export function Contracts() {
|
||||
</TableCell>
|
||||
<TableCell><QualityCell contract={row.contract} /></TableCell>
|
||||
<TableCell className="nums text-sm">
|
||||
{shortDate(row.contract.effectiveAt)} <ArrowRight className="mx-1 inline size-3" aria-hidden /> {shortDate(row.contract.expiresAt)}
|
||||
<Term contract={row.contract} />
|
||||
</TableCell>
|
||||
<TableCell><RenewalBadge row={row} /></TableCell>
|
||||
<TableCell><ChevronRight aria-hidden /></TableCell>
|
||||
@@ -557,7 +557,7 @@ export function Contracts() {
|
||||
<ChevronRight className="shrink-0 text-muted" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] items-end gap-3 border-t border-border pt-3 text-xs text-muted">
|
||||
<div><p className="capitalize">{row.contract.side} · {terminationLabel(row.contract.terminationTier)}</p><p className="nums mt-1">{shortDate(row.contract.effectiveAt)} <ArrowRight className="mx-1 inline size-3" aria-hidden /> {shortDate(row.contract.expiresAt)}</p></div>
|
||||
<div><p className="capitalize">{row.contract.side} · {terminationLabel(row.contract.terminationTier)}</p><p className="nums mt-1"><Term contract={row.contract} /></p></div>
|
||||
<RenewalBadge row={row} />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1135,6 +1135,30 @@ function RenewalBadge({ row }: { row: Pick<ContractListRow, 'renewalState' | 're
|
||||
return <span className="text-xs text-muted">Notice {shortDate(row.renewalNoticeAt)}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both ends of the paper's term, in one string.
|
||||
*
|
||||
* `dateRange` rather than two `shortDate`s with an arrow between them. A
|
||||
* twelve-month contract expires on the same day of the same month it started,
|
||||
* and the year was being suppressed on whichever end happened to fall in the
|
||||
* current one — so an MSA running Oct 2025 to Oct 2026 printed "Oct 5, 2025 →
|
||||
* Oct 5", which reads as a term that ends before it begins. Paper with no
|
||||
* expiry keeps the arrow: "starts here and does not stop" is the fact this
|
||||
* column carries for an SLA, and a lone start date would hide it.
|
||||
*/
|
||||
function Term({ contract }: { contract: Pick<ContractRecord, 'effectiveAt' | 'expiresAt'> }) {
|
||||
if (!contract.effectiveAt || !contract.expiresAt) {
|
||||
return (
|
||||
<>
|
||||
{shortDate(contract.effectiveAt)}{' '}
|
||||
<ArrowRight className="mx-1 inline size-3" aria-hidden />{' '}
|
||||
{shortDate(contract.expiresAt)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{dateRange(contract.effectiveAt, contract.expiresAt)}</>;
|
||||
}
|
||||
|
||||
function QualityCell({ contract }: { contract: ContractRecord }) {
|
||||
return <div><StatusBadge status={contract.status} /><p className="mt-1 text-xs text-muted">{terminationLabel(contract.terminationTier)}</p></div>;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api';
|
||||
import { compactNumber, dateRange, get, money, unitPrice } from '@/lib/api';
|
||||
import { initials } from '@/lib/utils';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface GrowthCustomer {
|
||||
@@ -107,7 +108,14 @@ export function Growth() {
|
||||
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
|
||||
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
|
||||
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
|
||||
<Stat label="Idle supply cost" value={money(idleCost)} hint="Paid capacity still unsold" tone={idleCost ? 'danger' : 'default'} />
|
||||
{/*
|
||||
Scoped in the hint, because this is the cost of the blocks listed
|
||||
under "Idle supply" and not the book's whole idle spend. Read as a
|
||||
total it contradicts the Overview, which draws its idle exposure at a
|
||||
lower threshold and therefore always shows a larger number for the
|
||||
same book.
|
||||
*/}
|
||||
<Stat label="Idle supply cost" value={money(idleCost)} hint="Near-term blocks over the idle threshold" tone={idleCost ? 'danger' : 'default'} />
|
||||
</section>
|
||||
|
||||
{view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : (
|
||||
@@ -128,8 +136,8 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
|
||||
<div className="h-1 bg-gradient-to-r from-accent via-info to-positive" />
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{account.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1"><CardTitle className="break-words text-lg leading-snug">{account.name}</CardTitle><p className="mt-1 truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{initials(account.name)}</div>
|
||||
<div className="min-w-0 flex-1"><CardTitle className="break-words text-lg leading-snug"><Link className="underline-offset-4 hover:underline" to={`/accounts/${account.id}`}>{account.name}</Link></CardTitle><p className="mt-1 truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
|
||||
<div className="text-right" aria-label={`Attention score ${lifecycle.score}`}><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
|
||||
@@ -152,7 +160,7 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
|
||||
{lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" />
|
||||
<Link className="tap inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2 sm:flex-none" to="/accounts">Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
<Link className="tap inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2 sm:flex-none" to={`/accounts/${account.id}`}>Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -166,7 +174,7 @@ function IdleSupply({ rows }: { rows: GrowthReport['idleSupply'] }) {
|
||||
<CardHeader><div className="flex items-start justify-between gap-3"><div><CardTitle>{row.name}</CardTitle><p className="mt-1 text-sm text-muted">{row.gpuCount}× {row.gpuType}</p></div><Badge tone="warning">{money(row.idleCostCents)} idle cost</Badge></div></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2"><Metric label="Sold" value={compactNumber(row.soldGpuHours)} /><Metric label="Held" value={compactNumber(row.heldGpuHours)} /><Metric label="Sellable" value={compactNumber(row.availableGpuHours)} /></div>
|
||||
<div className="space-y-1 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span>{shortDate(row.startsAt)} – {shortDate(row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span>{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${moneyExact(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
|
||||
<div className="space-y-1 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span>{dateRange(row.startsAt, row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span>{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
|
||||
<Link className="tap inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2" to="/capacity">Match this capacity <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ImportEntity,
|
||||
type PermissionGrant,
|
||||
} from '@pig/core';
|
||||
import { AlertTriangle, CheckCircle2, FileSpreadsheet, LoaderCircle, Upload } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, FileSpreadsheet, LoaderCircle, Lock, Upload } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
@@ -126,7 +126,25 @@ export function Imports() {
|
||||
};
|
||||
|
||||
if (me && !allowed) {
|
||||
return <Card><EmptyState title="Import access required" description="A team administrator with data-import permission must run spreadsheet imports." /></Card>;
|
||||
// Keeps the heading the permitted view has. Returning the bare card left
|
||||
// the page with no h1 and no breadcrumb, so someone sent here by a link
|
||||
// landed on a refusal with nothing naming the page it came from.
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Import data</h1>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Lock className="h-8 w-8" />}
|
||||
title="Import access required"
|
||||
description="A team administrator with data-import permission must run spreadsheet imports."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
* was an explicit product decision: the point of the page for an outsider is
|
||||
* partly to advertise the rest of it.
|
||||
*
|
||||
* **An empty walkthrough list is a state this page is designed for, not an
|
||||
* accident.** A walkthrough row exists only for a video file that is actually
|
||||
* on disk, so a deployment without the rendered media serves a code-holder a
|
||||
* page with nothing to play. That is the first — and possibly only — thing a
|
||||
* stranger ever sees of PIG, so it gets a written panel of its own rather than
|
||||
* the admin empty state, and it is kept distinct from the panel for a read
|
||||
* that failed. See `PreviewPending` and `PreviewUnavailable`.
|
||||
*
|
||||
* Nothing here builds an embed URL. Every source arrives from the API already
|
||||
* resolved through the host allowlist in `@pig/core`; a resource the server
|
||||
* could not resolve is not in the response at all.
|
||||
@@ -133,6 +141,7 @@ export function Learn() {
|
||||
token={token}
|
||||
feed={publicFeed.data ?? null}
|
||||
isLoading={publicFeed.isFetching}
|
||||
failed={publicFeed.isError}
|
||||
onUnlocked={(minted) => {
|
||||
writeToken(minted);
|
||||
setToken(minted);
|
||||
@@ -182,9 +191,12 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
|
||||
Curriculum
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Learn</h1>
|
||||
{/* Precise about what the code opens: an earlier line promised that
|
||||
"anything on the Platform track" could be sent to someone with no
|
||||
account, which is true only of the rows marked by code. */}
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
||||
How this market works, and how PIG works. Anything on the Platform track can be sent
|
||||
to someone without an account.
|
||||
How this market works, and how PIG works. The Platform track is what the share code
|
||||
opens; Concepts stays with members.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
|
||||
@@ -257,6 +269,7 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
|
||||
<ConceptGrid
|
||||
resources={feed.tracks[track] ?? []}
|
||||
managing={managing}
|
||||
canManage={feed.canManage}
|
||||
emptyTitle={`No ${LEARN_TRACK_LABELS[track].toLowerCase()} material yet`}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
@@ -270,10 +283,10 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
|
||||
kicker="Product how-to"
|
||||
icon={<MonitorPlay className="size-3.5 shrink-0" aria-hidden />}
|
||||
title="Platform"
|
||||
description="A short course on PIG itself, in order. Every step here can be shared with someone who has no account."
|
||||
description="A short course on PIG itself, in order. This is the one track the share code opens."
|
||||
>
|
||||
{platform.length === 0 ? (
|
||||
<LearnEmpty title="No walkthroughs yet" />
|
||||
<LearnEmpty title="No walkthroughs yet" description={emptyHint(feed.canManage)} />
|
||||
) : (
|
||||
// Capped: a one-line summary set the full width of a desktop shell
|
||||
// is a line nobody can track back from.
|
||||
@@ -291,15 +304,17 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
|
||||
function ConceptGrid({
|
||||
resources,
|
||||
managing,
|
||||
canManage,
|
||||
emptyTitle,
|
||||
onPlay,
|
||||
}: {
|
||||
resources: LearnResourceView[];
|
||||
managing: boolean;
|
||||
canManage: boolean;
|
||||
emptyTitle: string;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
if (resources.length === 0) return <LearnEmpty title={emptyTitle} />;
|
||||
if (resources.length === 0) return <LearnEmpty title={emptyTitle} description={emptyHint(canManage)} />;
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -321,12 +336,15 @@ function CodeHolderView({
|
||||
token,
|
||||
feed,
|
||||
isLoading,
|
||||
failed,
|
||||
onUnlocked,
|
||||
onPlay,
|
||||
}: {
|
||||
token: string | null;
|
||||
feed: PublicFeed | null;
|
||||
isLoading: boolean;
|
||||
/** The read was refused or never answered — distinct from an empty library. */
|
||||
failed: boolean;
|
||||
onUnlocked: (token: string) => void;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
@@ -349,6 +367,7 @@ function CodeHolderView({
|
||||
}
|
||||
|
||||
const expiry = feed ? formatExpiry(feed.expiresAt) : null;
|
||||
const resources = feed?.resources ?? [];
|
||||
|
||||
return (
|
||||
<AnonFrame>
|
||||
@@ -364,9 +383,13 @@ function CodeHolderView({
|
||||
>
|
||||
Platform walkthroughs
|
||||
</h1>
|
||||
{/* The running-order sentence is a claim about videos that are on
|
||||
the page. With none published it describes nothing, so it goes. */}
|
||||
<p className="min-w-0 max-w-2xl text-sm leading-6 text-muted">
|
||||
{LEARN_TRACK_DESCRIPTIONS.platform} Work through them in order — the first one assumes
|
||||
nothing.
|
||||
{LEARN_TRACK_DESCRIPTIONS.platform}
|
||||
{resources.length > 0
|
||||
? ' Work through them in order — the first one assumes nothing.'
|
||||
: ''}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge tone="accent">
|
||||
@@ -379,14 +402,12 @@ function CodeHolderView({
|
||||
|
||||
{isLoading && !feed ? (
|
||||
<ListSkeleton />
|
||||
) : (feed?.resources.length ?? 0) === 0 ? (
|
||||
<LearnEmpty title="Nothing published yet" />
|
||||
) : failed && !feed ? (
|
||||
<PreviewUnavailable />
|
||||
) : resources.length === 0 ? (
|
||||
<PreviewPending />
|
||||
) : (
|
||||
<LearnWalkthroughList
|
||||
resources={feed?.resources ?? []}
|
||||
managing={false}
|
||||
onPlay={onPlay}
|
||||
/>
|
||||
<LearnWalkthroughList resources={resources} managing={false} onPlay={onPlay} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -458,13 +479,67 @@ function TrackSection({
|
||||
);
|
||||
}
|
||||
|
||||
function LearnEmpty({ title }: { title: string }) {
|
||||
function LearnEmpty({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<GraduationCap className="size-6" aria-hidden />}
|
||||
title={title}
|
||||
description="Paste a video link to start the collection."
|
||||
description={description}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of an empty track, which is an instruction only for the one
|
||||
* person who can act on it. "Paste a video link to start the collection" was
|
||||
* shown to every member, none of whom the API would let write, and it is
|
||||
* exactly the wrong sentence to put in front of an outsider.
|
||||
*/
|
||||
function emptyHint(canManage: boolean): string {
|
||||
return canManage
|
||||
? 'Paste a video link to start the collection.'
|
||||
: 'Nothing has been published to this track yet.';
|
||||
}
|
||||
|
||||
/**
|
||||
* What a code-holder sees when the walkthroughs have not been published.
|
||||
*
|
||||
* This is the whole page for someone whose only view of PIG is a share code —
|
||||
* it happens whenever the rendered media is not on the box, since a
|
||||
* walkthrough row is written only for a file that exists — so it has to read
|
||||
* as a finished page rather than a failed one.
|
||||
*
|
||||
* No ghost cards, and no "coming soon" tiles. A card carries a play button,
|
||||
* and a play button that does nothing is worse than an honest absence; it is
|
||||
* the same reason the access hero draws redacted bars rather than invented
|
||||
* thumbnails. What the visitor gets instead is the truth and a human to ask.
|
||||
*/
|
||||
function PreviewPending() {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<MonitorPlay className="size-6" aria-hidden />}
|
||||
title="No walkthroughs published yet"
|
||||
description="Your code worked — nothing has been published to this preview so far. Whoever shared the code will know when the first one lands."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct from `PreviewPending` on purpose: a library that did not answer is
|
||||
* not a library that is empty, and telling a visitor "nothing published yet"
|
||||
* when the request failed is a page inventing a fact about itself.
|
||||
*/
|
||||
function PreviewUnavailable() {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<MonitorPlay className="size-6" aria-hidden />}
|
||||
title="Could not load the walkthroughs"
|
||||
description="The library did not answer. Reload the page, or try again in a few minutes."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+225
-41
@@ -4,13 +4,24 @@
|
||||
* The table scrolls inside its own pane on narrow screens rather than making
|
||||
* the page scroll sideways; a card list would lose the column comparison that
|
||||
* is the entire value of this view.
|
||||
*
|
||||
* The page also has to say the quiet part out loud. A blended margin in the low
|
||||
* single digits reads as a thin but healthy book, while one commitment sits
|
||||
* barely half sold and has not paid for itself — the totals average that away
|
||||
* by construction. So the blocks whose cost is still uncovered are named above
|
||||
* the table with the price their remaining hours have to fetch, rather than
|
||||
* left to be reconstructed by reading a percentage column against a price
|
||||
* column two columns away.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { compactNumber, get, money, moneyExact, percent } from '@/lib/api';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
|
||||
import { compactNumber, get, money, percent, unitPrice } from '@/lib/api';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat, cn } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
|
||||
interface MarginReport {
|
||||
totals: {
|
||||
@@ -24,18 +35,34 @@ interface MarginReport {
|
||||
grossMarginPct: number | null;
|
||||
marginPerAllocatedGpuHourCents: number | null;
|
||||
};
|
||||
blocks: {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
totalGpuHours: number;
|
||||
soldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
utilisation: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}[];
|
||||
blocks: MarginBlock[];
|
||||
}
|
||||
|
||||
interface MarginBlock {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
totalGpuHours: number;
|
||||
soldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
costPerGpuHourCents: number;
|
||||
utilisation: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block has covered its cost when there is nothing left to break even on.
|
||||
*
|
||||
* The colour on the sold-ratio column used to key off `utilisation < 0.5`,
|
||||
* which is an arbitrary line: the block this page exists to flag is 55% sold
|
||||
* and would have rendered as unremarkable, while a block 40% sold on cheap
|
||||
* hours it has already earned back would have rendered as a problem. Break-even
|
||||
* is the honest test — it is zero exactly when revenue has already covered the
|
||||
* whole commitment, and null when there is nothing left to sell.
|
||||
*/
|
||||
function isUncovered(block: MarginBlock): boolean {
|
||||
return block.breakEvenPriceCents != null && block.breakEvenPriceCents > 0;
|
||||
}
|
||||
|
||||
export function Margin() {
|
||||
@@ -44,6 +71,25 @@ export function Margin() {
|
||||
queryKey: ['margin'],
|
||||
queryFn: () => get<MarginReport>('/api/capacity/margin'),
|
||||
});
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const blocks = data?.blocks ?? [];
|
||||
// Resolved from the current data rather than held in state, so a block that
|
||||
// disappears on a refetch quietly returns Piggy to the page instead of
|
||||
// leaving it pointed at a commitment nobody can see any more.
|
||||
const focused = blocks.find((block) => block.commitmentId === focusedId);
|
||||
|
||||
// Ambient context for the dock: the block the user selected, else this page.
|
||||
// Published before the early returns below, because a hook that runs only on
|
||||
// the happy path is a hook that changes order the first time the query fails.
|
||||
usePiggyContext(
|
||||
focused
|
||||
? { type: 'commitment', id: focused.commitmentId, label: focused.name }
|
||||
: { type: 'page', route: '/margin', label: 'Margin' },
|
||||
);
|
||||
|
||||
const uncovered = blocks.filter(isUncovered);
|
||||
const toggleFocus = (commitmentId: string) =>
|
||||
setFocusedId((current) => (current === commitmentId ? null : commitmentId));
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex flex-col gap-4"><Skeleton className="h-16" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
|
||||
@@ -62,7 +108,26 @@ export function Margin() {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No capacity to report on"
|
||||
description="Margin is computed from capacity commitments and the allocations against them."
|
||||
description="Margin is computed from capacity commitments and the allocations against them. Both are recorded on the capacity book."
|
||||
/*
|
||||
* A link rather than the commitment sheet itself, which is the opposite
|
||||
* of the choice Overview makes and for a reason. Margin is a derived
|
||||
* ledger with no other write path on it, and recording a block is only
|
||||
* step one — the seller's next move is to match and allocate it, which
|
||||
* is on /capacity too. Sending the reader there puts them in front of
|
||||
* the whole job rather than dropping a sheet onto a report and
|
||||
* returning them to a page that still says nothing sold. It also keeps
|
||||
* the capability question in one place: the button on /capacity states
|
||||
* whose authority this is, so it is not restated here.
|
||||
*/
|
||||
action={
|
||||
<Button variant="primary" asChild>
|
||||
<Link to="/capacity">
|
||||
Go to the capacity book
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -71,34 +136,108 @@ export function Margin() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
Revenue from what we sold, against the full cost of what we bought.
|
||||
</p>
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
Revenue from what we sold, against the full cost of what we bought. Every
|
||||
commitment is charged in full, so a block keeps paying for the hours nobody
|
||||
has bought yet.
|
||||
</p>
|
||||
</div>
|
||||
{/*
|
||||
No `context` prop, deliberately. This button asks about whatever the
|
||||
page has published — the selected block, else /margin — and pinning it
|
||||
to the page here would make selecting a block change the dock and not
|
||||
this button, which is the one the user just pressed.
|
||||
*/}
|
||||
<PiggyAskButton
|
||||
label={focused ? 'Ask about this block' : 'Ask Piggy'}
|
||||
prompt={
|
||||
focused
|
||||
? 'How much of this block is still unsold, what must the rest fetch to cover it, and how much term is left to sell into?'
|
||||
: 'Which commitment is furthest from covering its cost, and what would the remaining hours have to fetch?'
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
||||
<Stat label="Revenue" value={money(t.revenueCents)} />
|
||||
<Stat label="Cost" value={money(t.costCents)} hint="Full commitment" />
|
||||
<Stat label="Revenue" value={money(t.revenueCents)} hint={`${compactNumber(t.allocatedGpuHours)} GPU-hrs sold`} />
|
||||
<Stat label="Cost" value={money(t.costCents)} hint={`${compactNumber(t.committedGpuHours)} GPU-hrs committed`} />
|
||||
<Stat
|
||||
label="Gross margin"
|
||||
value={money(t.grossMarginCents)}
|
||||
hint={percent(t.grossMarginPct, 1)}
|
||||
hint={`${percent(t.grossMarginPct, 1)} of revenue, after the idle hours`}
|
||||
tone={t.grossMarginCents >= 0 ? 'positive' : 'danger'}
|
||||
/>
|
||||
<Stat
|
||||
label="Per sold GPU-hour"
|
||||
value={moneyExact(t.marginPerAllocatedGpuHourCents)}
|
||||
value={unitPrice(t.marginPerAllocatedGpuHourCents)}
|
||||
hint={`${percent(t.utilisation, 1)} of committed hours sold`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{uncovered.length > 0 ? (
|
||||
<Card className="border-warning/30">
|
||||
<CardHeader className="space-y-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base">
|
||||
{uncovered.length} of {data.blocks.length} commitments have not covered their cost
|
||||
</CardTitle>
|
||||
{/*
|
||||
Not "an average of the blocks": the blended figure is a ratio
|
||||
of sums, and describing it as an average invites exactly the
|
||||
per-block averaging `aggregateMargin` refuses to do.
|
||||
*/}
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{t.grossMarginPct == null
|
||||
? 'Nothing has sold yet, so every commitment below is still owed its whole cost.'
|
||||
: `The book clears ${percent(t.grossMarginPct, 1)} blended because the blocks that have earned their money back carry the ones that have not.`}{' '}
|
||||
These are the blocks still owed something, and the price the rest of each has to
|
||||
fetch to get there.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{uncovered.map((block) => (
|
||||
<div key={block.commitmentId} 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="break-words font-medium leading-snug">{block.name}</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{block.gpuCount}× {block.gpuType} · <span className="nums">{percent(block.utilisation)}</span> sold ·{' '}
|
||||
<span className="nums">{compactNumber(block.availableGpuHours)}</span> hrs still sellable
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-sm sm:text-right">
|
||||
<span className="nums font-semibold text-warning">{unitPrice(block.breakEvenPriceCents)}</span>
|
||||
<span className="text-muted">/GPU-hr to break even</span>
|
||||
{/*
|
||||
Break-even sits below cost once part of the block has sold —
|
||||
the hours already invoiced have paid down some of it. Said
|
||||
here because the two prices are otherwise read as a
|
||||
contradiction rather than as progress.
|
||||
*/}
|
||||
<span className="mt-0.5 block text-xs text-muted">
|
||||
against <span className="nums">{unitPrice(block.costPerGpuHourCents)}</span>/GPU-hr paid
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">By commitment</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted">Sold ratio describes contracted capacity sold, not workload utilization.</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Sold ratio describes contracted capacity sold, not workload utilization. Select a
|
||||
commitment to point Piggy at it.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/capacity" className="tap inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface-2">
|
||||
Capacity <ArrowRight className="size-4" aria-hidden />
|
||||
@@ -119,12 +258,20 @@ export function Margin() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.blocks.map((block) => (
|
||||
<tr key={block.commitmentId} className="border-b border-border/60 last:border-0">
|
||||
<tr
|
||||
key={block.commitmentId}
|
||||
aria-selected={block.commitmentId === focusedId}
|
||||
className={cn(
|
||||
'border-b border-border/60 last:border-0',
|
||||
block.commitmentId === focusedId && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 sm:px-5">
|
||||
<div className="font-medium">{block.name}</div>
|
||||
<div className="text-xs text-muted">
|
||||
{block.gpuCount}× {block.gpuType}
|
||||
</div>
|
||||
<FocusButton
|
||||
block={block}
|
||||
focused={block.commitmentId === focusedId}
|
||||
onToggle={() => toggleFocus(block.commitmentId)}
|
||||
/>
|
||||
</td>
|
||||
<td className="nums px-4 py-3 text-right">
|
||||
{compactNumber(block.soldGpuHours)}
|
||||
@@ -135,13 +282,13 @@ export function Margin() {
|
||||
<td
|
||||
className={[
|
||||
'nums px-4 py-3 text-right font-medium',
|
||||
block.utilisation < 0.5 ? 'text-warning' : '',
|
||||
isUncovered(block) ? 'text-warning' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{percent(block.utilisation)}
|
||||
</td>
|
||||
<td className="nums px-4 py-3 text-right">
|
||||
{moneyExact(block.costPerGpuHourCents)}
|
||||
{unitPrice(block.costPerGpuHourCents)}
|
||||
</td>
|
||||
{/*
|
||||
A zero break-even means the block's cost is already
|
||||
@@ -157,18 +304,27 @@ export function Margin() {
|
||||
</div>
|
||||
<div className="grid gap-3 px-4 pb-4 md:hidden">
|
||||
{data.blocks.map((block) => (
|
||||
<article key={block.commitmentId} className="rounded-xl border border-border p-4">
|
||||
<article
|
||||
key={block.commitmentId}
|
||||
className={cn(
|
||||
'rounded-xl border border-border p-4',
|
||||
// `border-brand`, not `border-accent`: `accent` is shadcn's
|
||||
// subtle surface in this config, so a border in it disappears.
|
||||
block.commitmentId === focusedId && 'border-brand bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words font-medium leading-snug">{block.name}</h3>
|
||||
<p className="mt-1 text-xs text-muted">{block.gpuCount}× {block.gpuType}</p>
|
||||
</div>
|
||||
<span className={['nums shrink-0 text-sm font-semibold', block.utilisation < 0.5 ? 'text-warning' : ''].join(' ')}>{percent(block.utilisation)} sold</span>
|
||||
<FocusButton
|
||||
block={block}
|
||||
focused={block.commitmentId === focusedId}
|
||||
onToggle={() => toggleFocus(block.commitmentId)}
|
||||
/>
|
||||
<span className={['nums shrink-0 text-sm font-semibold', isUncovered(block) ? 'text-warning' : ''].join(' ')}>{percent(block.utilisation)} sold</span>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted">Sold capacity</dt><dd className="nums text-right">{compactNumber(block.soldGpuHours)} hrs</dd>
|
||||
<dt className="text-muted">Sellable capacity</dt><dd className="nums text-right">{compactNumber(block.availableGpuHours)} hrs</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{moneyExact(block.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(block.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Break even</dt><dd className="text-right"><BreakEven value={block.breakEvenPriceCents} /></dd>
|
||||
</dl>
|
||||
</article>
|
||||
@@ -180,8 +336,36 @@ export function Margin() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The commitment name, as the control that points Piggy at that commitment.
|
||||
*
|
||||
* A row is the thing a reader is already looking at when they want to ask about
|
||||
* it, so the name carries the selection rather than a separate button in a
|
||||
* seventh column that would push the table wider than the pane it scrolls in.
|
||||
*/
|
||||
function FocusButton({ block, focused, onToggle }: { block: MarginBlock; focused: boolean; onToggle(): void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={focused}
|
||||
onClick={onToggle}
|
||||
title={focused ? 'Stop pointing Piggy at this commitment' : 'Point Piggy at this commitment'}
|
||||
className="tap -mx-2 block min-w-0 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<span className="block break-words font-medium leading-snug">
|
||||
{block.name}
|
||||
{focused ? <Badge tone="accent" className="ml-2 align-middle">In focus</Badge> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted">
|
||||
{block.gpuCount}× {block.gpuType}
|
||||
</span>
|
||||
<span className="sr-only">{focused ? 'Piggy is looking at this commitment' : 'Point Piggy at this commitment'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakEven({ value }: { value: number | null }) {
|
||||
if (value == null) return <span className="text-muted">Sold out</span>;
|
||||
if (value === 0) return <span className="text-positive">Cost covered</span>;
|
||||
return <span className="nums">{moneyExact(value)}/GPU-hr</span>;
|
||||
return <span className="nums">{unitPrice(value)}/GPU-hr</span>;
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -15,13 +15,12 @@ export function Piggy() {
|
||||
Read-only workspace
|
||||
</div>
|
||||
</header>
|
||||
<div className="rounded-xl border border-border bg-surface-2/60 px-4 py-3 text-sm">
|
||||
<span className="font-medium">Inspection boundary.</span>{' '}
|
||||
<span className="text-muted">
|
||||
Piggy can query scoped PIG records, but this chat cannot create or update CRM data.
|
||||
Verify material terms against the cited records before acting.
|
||||
</span>
|
||||
</div>
|
||||
{/* No standing "inspection boundary" banner here any more. It said what
|
||||
the empty state says on arrival — scoped reads, no writes — and what
|
||||
the composer says under every message once the transcript starts, and
|
||||
a third copy of it cost the transcript 74px it needed more: with the
|
||||
banner in place the page itself scrolled behind a panel that already
|
||||
scrolls, so following an answer moved two things at once. */}
|
||||
<PiggyChatWorkspace />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
*
|
||||
* Stages remain ordered, but wrap into a scanable desktop grid instead of
|
||||
* hiding the back half of the funnel behind a multi-screen horizontal rail.
|
||||
*
|
||||
* Each card can also put itself in front of Piggy. A board of thirteen deals
|
||||
* docked next to an agent that only knows it is "on /demand" answers every
|
||||
* question from stage totals, so the card carries a focus control and the page
|
||||
* publishes that deal as the ambient context while it is held.
|
||||
*/
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import { get, money, relativeTime, unitPrice } from '@/lib/api';
|
||||
import { PiggyAskButton } from '@/components/PiggyChat';
|
||||
import { Badge, Button, Card, EmptyState, Input, Skeleton, cn } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import { can } from '@/lib/permissions';
|
||||
import { DemandDealSheet, SupplyDealSheet, type DemandDealRecord, type SupplyDealRecord } from '@/components/RecordSheets';
|
||||
|
||||
@@ -31,7 +38,7 @@ export function DemandPipeline() {
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.productLine}`}
|
||||
metricLabel="Visible ACV" metricValue={(deals) => money(deals.reduce((total, deal) => total + (deal.acvCents ?? 0), 0))}
|
||||
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}</div></>}
|
||||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.acvCents != null ? <span className="nums text-sm font-semibold">{money(deal.acvCents)}</span> : null}<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>{/* Paper state prevents delivery readiness from being inferred from stage. */}{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}</div>}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -43,11 +50,11 @@ export function SupplyPipeline() {
|
||||
searchText={(deal, accountName) => `${deal.name} ${accountName ?? ''} ${deal.gpuType ?? ''}`}
|
||||
metricLabel="GPU opportunity" metricValue={(deals) => `${deals.reduce((total, deal) => total + (deal.gpuCount ?? 0), 0).toLocaleString()} GPUs`}
|
||||
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => <><p className="truncate font-medium">{deal.name}</p><p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p><div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : null}{deal.targetCostPerGpuHourCents != null ? <span className="nums text-xs text-muted">{money(deal.targetCostPerGpuHourCents)}/hr target</span> : null}</div></>}
|
||||
renderCard={(deal) => <div className="mt-2 flex flex-wrap items-center gap-1.5">{deal.gpuCount != null && deal.gpuType ? <Badge tone="accent">{deal.gpuCount}× {deal.gpuType}</Badge> : null}{/* A per-GPU-hour price goes through `unitPrice`, never `money`: at $1.60 the cents are the number, and `money` drops them when they happen to be round. */}{deal.targetCostPerGpuHourCents != null ? <span className="nums text-xs text-muted">{unitPrice(deal.targetCostPerGpuHourCents)}/GPU-hr target</span> : null}</div>}
|
||||
/>;
|
||||
}
|
||||
|
||||
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
|
||||
function PipelineBoard<T extends { id: string; name: string; stage: string; updatedAt: string }>({ title, orientation, subtitle, endpoint, team, searchText, metricLabel, metricValue, renderCard, renderSheet }: {
|
||||
title: string; orientation: string; subtitle: string; endpoint: string; team: 'supply' | 'demand';
|
||||
searchText: (deal: T, accountName: string | null) => string; metricLabel: string; metricValue: (deals: T[]) => string;
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
@@ -59,6 +66,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
const writable = can(me, 'deal:write', team);
|
||||
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
|
||||
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
|
||||
const filteredDeals = useMemo(() => !deferredQuery ? boardQuery.data?.deals ?? [] : (boardQuery.data?.deals ?? []).filter((row) => searchText(row.deal, row.accountName).toLocaleLowerCase().includes(deferredQuery)), [boardQuery.data?.deals, deferredQuery, searchText]);
|
||||
@@ -74,25 +82,99 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
const activeStageCount = stages.filter((stage) => (byStage.get(stage)?.length ?? 0) > 0).length;
|
||||
const sheetNode = renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record });
|
||||
|
||||
if (boardQuery.isLoading) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Skeleton className="h-96" /></div>;
|
||||
if (boardQuery.isError) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
|
||||
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5"><Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} /><Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
|
||||
// What Piggy is looking at while this board is open: the deal the user put in
|
||||
// focus, else the board itself. Resolved against the current rows rather than
|
||||
// stored alongside the id, so a deal that leaves the board on a refetch
|
||||
// returns the dock to the page instead of holding a card nobody can see.
|
||||
const focusedDeal = (boardQuery.data?.deals ?? []).find((row) => row.deal.id === focusedId)?.deal;
|
||||
usePiggyContext(
|
||||
focusedDeal
|
||||
? { type: team === 'demand' ? 'demand_deal' : 'supply_deal', id: focusedDeal.id, label: focusedDeal.name }
|
||||
: { type: 'page', route: team === 'demand' ? '/demand' : '/supply', label: `${title} pipeline` },
|
||||
);
|
||||
|
||||
// Built once: the four returns below all render it, and a header assembled
|
||||
// separately in each is a header that ends up different in the error state.
|
||||
const header = <Header
|
||||
title={title} orientation={orientation} subtitle={subtitle} writable={writable}
|
||||
onCreate={() => setSheet({ open: true })}
|
||||
askLabel={focusedDeal ? 'Ask about this deal' : 'Ask Piggy'}
|
||||
askPrompt={focusedDeal
|
||||
? (team === 'demand'
|
||||
? 'Are the hours behind this deal actually booked, and is it going to close when it says it will?'
|
||||
: 'How many GPU-hours would this add, at what cost per hour, and what is still outstanding before we can sign it?')
|
||||
: (team === 'demand'
|
||||
? 'Which open deal is worth the most, and when is it meant to land?'
|
||||
: 'Are we lining up more capacity than the demand side can absorb?')}
|
||||
/>;
|
||||
|
||||
if (boardQuery.isLoading) return <div className="space-y-5">{header}<Skeleton className="h-96" /></div>;
|
||||
if (boardQuery.isError) return <div className="space-y-5">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
|
||||
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5">{header}<Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
|
||||
|
||||
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id));
|
||||
|
||||
return <div className="space-y-5">
|
||||
<Header title={title} orientation={orientation} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
|
||||
{header}
|
||||
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
|
||||
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
|
||||
<PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
|
||||
</section>
|
||||
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
|
||||
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
|
||||
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
|
||||
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
|
||||
{sheetNode}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DealCard<T extends { id: string; updatedAt: string }>({ row, renderCard, writable, onEdit }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; onEdit(): void }) {
|
||||
return <article className="card relative min-w-0 p-3 pr-12 shadow-sm"><Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>{renderCard(row.deal, row.accountName)}<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p></article>;
|
||||
/**
|
||||
* One deal on the board.
|
||||
*
|
||||
* The name and account are drawn here rather than by `renderCard` because they
|
||||
* are the control that points Piggy at this deal, and a second icon button
|
||||
* beside the pencil would have cost the title another 44px of a card that is
|
||||
* already a quarter of a column wide — the board would have been asking which
|
||||
* matters more, reading the deal or asking about it.
|
||||
*/
|
||||
function DealCard<T extends { id: string; name: string; updatedAt: string }>({ row, renderCard, writable, focused, onEdit, onFocus }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; focused: boolean; onEdit(): void; onFocus(): void }) {
|
||||
// `ring-brand`, not `ring-accent`: in this Tailwind config `accent` is
|
||||
// shadcn's subtle surface, so a ring drawn in it is invisible against the
|
||||
// card. The brand is the monochrome that inverts with the theme.
|
||||
return <article className={cn('card relative min-w-0 p-3 pr-12 shadow-sm', focused && 'ring-2 ring-brand')}>
|
||||
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={focused}
|
||||
onClick={onFocus}
|
||||
title={focused ? 'Stop pointing Piggy at this deal' : 'Point Piggy at this deal'}
|
||||
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<span className="block truncate font-medium">{row.deal.name}</span>
|
||||
<span className="block truncate text-xs text-muted">{row.accountName ?? 'No account'}</span>
|
||||
<span className="sr-only">{focused ? 'Piggy is looking at this deal' : 'Point Piggy at this deal'}</span>
|
||||
</button>
|
||||
{renderCard(row.deal, row.accountName)}
|
||||
<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
|
||||
</article>;
|
||||
}
|
||||
function PipelineStat({ label, value }: { label: string; value: string }) { return <div className="min-w-[7rem] rounded-lg bg-surface px-3 py-2"><p className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className="nums mt-0.5 truncate text-sm font-semibold">{value}</p></div>; }
|
||||
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return <p className={compact ? 'rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted' : 'py-10 text-center text-sm text-muted'}>{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}</p>; }
|
||||
function Header({ title, orientation, subtitle, writable, onCreate }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void }) { return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div><Button className="min-h-11 sm:shrink-0" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button></header>; }
|
||||
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
|
||||
return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
|
||||
{/*
|
||||
Reversed above `sm` rather than reordered: stacked on a phone the primary
|
||||
action has to come first, and on a wide header the same button belongs at
|
||||
the right edge where it has always been.
|
||||
*/}
|
||||
<div className="flex flex-col gap-2 sm:shrink-0 sm:flex-row-reverse">
|
||||
<Button className="min-h-11" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>
|
||||
{/*
|
||||
No `context` prop on purpose: this asks about whatever the board has
|
||||
published, which is the focused deal when there is one. Passing the page
|
||||
here would pin it to the board and quietly ignore the card the user just
|
||||
put in focus.
|
||||
*/}
|
||||
<PiggyAskButton label={askLabel} prompt={askPrompt} />
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
+427
-15
@@ -1,17 +1,33 @@
|
||||
/**
|
||||
* Settings — appearance, profile, and connecting an agent.
|
||||
* Settings — appearance, profile, agent credentials, and session.
|
||||
*
|
||||
* The appearance section is where the user picks the accent that re-tints the
|
||||
* whole product. It is saved server-side, so the choice follows them between
|
||||
* devices rather than being a per-browser quirk.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, LogOut, Monitor, Moon, Sun, Terminal } from 'lucide-react';
|
||||
import { get, getSupabase, patch } from '@/lib/api';
|
||||
import { Check, Copy, KeyRound, LogOut, Monitor, Moon, Sun, Terminal } from 'lucide-react';
|
||||
import { api, get, getSupabase, patch, post, relativeTime, shortDate } from '@/lib/api';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
import { AdminSettings } from '@/components/AdminSettings';
|
||||
import { toast } from 'sonner';
|
||||
@@ -35,25 +51,56 @@ export function Settings() {
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">
|
||||
Personal preferences, workspace access, and server-managed integration readiness.
|
||||
Personal preferences, agent credentials, and server-managed integration readiness.
|
||||
</p>
|
||||
</div>
|
||||
{me?.isPlatformAdmin ? <Badge tone="warning">Platform admin view</Badge> : null}
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Appearance />
|
||||
<Profile me={me} />
|
||||
</div>
|
||||
<Section title="Your account">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Appearance />
|
||||
{/*
|
||||
Profile and Session share a column so the page keeps two even
|
||||
columns instead of stranding a short card on a row of its own, and
|
||||
because signing out belongs with the identity it ends.
|
||||
*/}
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<Profile me={me} />
|
||||
<SessionCard />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{me?.isPlatformAdmin ? <AdminSettings /> : null}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ConnectAgent />
|
||||
<SessionCard />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The credential card sits beside the snippet that tells you to create a
|
||||
key — on a phone, directly under it. It used to say "create one below"
|
||||
with nothing below, which is the dead end this section closes.
|
||||
*/}
|
||||
<Section title="Agent access">
|
||||
{/* `items-start` because the instruction card is a third of the height
|
||||
of the credential list, and stretching it leaves a card that is
|
||||
mostly empty space. */}
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]">
|
||||
<ConnectAgent />
|
||||
{me ? <ApiKeys me={me} /> : null}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Appearance() {
|
||||
const { mode, accent, resolved, setMode, setAccent, accents } = useTheme();
|
||||
|
||||
@@ -253,7 +300,7 @@ function ConnectAgent() {
|
||||
<div className="scroll-x rounded-lg bg-surface-2 p-3">
|
||||
<pre className="text-xs leading-relaxed">
|
||||
<code>{`export PIG_URL=${origin}
|
||||
export PIG_API_KEY=pig_... # create one below
|
||||
export PIG_API_KEY=pig_... # create one in API keys
|
||||
|
||||
claude mcp add pig -- npx -y @pig/mcp`}</code>
|
||||
</pre>
|
||||
@@ -267,6 +314,371 @@ claude mcp add pig -- npx -y @pig/mcp`}</code>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The creation response is the only time the server ever discloses `key`. */
|
||||
type IssuedApiKey = ApiKey & { key: string };
|
||||
|
||||
function keyStatus(key: ApiKey): { label: string; tone: 'positive' | 'warning' | 'neutral' } {
|
||||
if (key.revokedAt) return { label: 'revoked', tone: 'neutral' };
|
||||
if (key.expiresAt && new Date(key.expiresAt) <= new Date()) {
|
||||
return { label: 'expired', tone: 'warning' };
|
||||
}
|
||||
return { label: 'active', tone: 'positive' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copying, with the failure modes it actually has.
|
||||
*
|
||||
* PIG is routinely opened from a phone on the LAN over plain http, where
|
||||
* `navigator.clipboard` is simply absent — and this is the one screen where a
|
||||
* silently failed copy costs the user a credential they can never see again.
|
||||
* Every path therefore reports itself, and the secret stays selectable on
|
||||
* screen so a refused clipboard is an inconvenience rather than a loss.
|
||||
*/
|
||||
async function copyToClipboard(value: string, success: string): Promise<boolean> {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error('Copying needs a secure connection. Select the key and copy it by hand.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
toast.error('The browser refused clipboard access. Select the key and copy it by hand.');
|
||||
return false;
|
||||
}
|
||||
toast.success(success);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys — the only mint path there is.
|
||||
*
|
||||
* `requireApiKeyManagement` on the server rejects any principal that
|
||||
* authenticated with an API key, so a credential can never mint a successor
|
||||
* that outlives its own revocation. That leaves a browser session as the only
|
||||
* possible caller, and there is no CLI equivalent: without this card, PIG's
|
||||
* MCP story is unreachable.
|
||||
*
|
||||
* Note what the endpoint does NOT require — no capability at all. Gating this
|
||||
* behind `settings:admin` would look prudent and would in fact deny every
|
||||
* ordinary member the keys the server is perfectly willing to give them, so
|
||||
* the gate here mirrors the server's real rule and nothing more.
|
||||
*/
|
||||
function ApiKeys({ me }: { me: Me }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = me.via !== 'api_key';
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [scope, setScope] = useState<'read' | 'write'>('read');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [issued, setIssued] = useState<IssuedApiKey | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
|
||||
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: () => get<ApiKey[]>('/api/api-keys'),
|
||||
enabled: canManage,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
post<IssuedApiKey>('/api/api-keys', {
|
||||
name: name.trim(),
|
||||
scopes: scope === 'write' ? ['read', 'write'] : ['read'],
|
||||
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
|
||||
}),
|
||||
onSuccess: (key) => {
|
||||
setIssued(key);
|
||||
setName('');
|
||||
setExpiresAt('');
|
||||
void queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
},
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (key: ApiKey) => api(`/api/api-keys/${key.id}`, { method: 'DELETE' }),
|
||||
onSuccess: (_result, key) => {
|
||||
setPendingRevoke(null);
|
||||
toast.success(`“${key.name}” can no longer authenticate`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
},
|
||||
onError: () => toast.error('Could not revoke that key'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-accent-fg" aria-hidden />
|
||||
<CardTitle className="text-base">API keys</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
A key lets an agent act as you over MCP and the HTTP API, never reaching further than
|
||||
your own permissions. Keys cannot manage keys, so this card is the only place to mint
|
||||
or revoke one.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{canManage ? (
|
||||
<>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="api-key-name">
|
||||
<span className="text-sm font-medium">Name</span>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Claude Code on my laptop"
|
||||
maxLength={120}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
The name is all you will have to go on when deciding which key to revoke.
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Access</span>
|
||||
<Select value={scope} onValueChange={(value) => setScope(value as 'read' | 'write')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="read">Read only</SelectItem>
|
||||
<SelectItem value="write">Read and write</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="api-key-expiry">
|
||||
<span className="text-sm font-medium">Expires, optional</span>
|
||||
<Input
|
||||
id="api-key-expiry"
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{create.error ? (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{create.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" variant="primary" disabled={create.isPending || !name.trim()}>
|
||||
{create.isPending ? 'Creating…' : 'Create API key'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">Your keys</p>
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted">Loading keys…</p>
|
||||
) : data.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted">
|
||||
No keys yet. Create one above, then paste it into the snippet under “Connect
|
||||
your agent”.
|
||||
</p>
|
||||
) : (
|
||||
data.map((key) => (
|
||||
<ApiKeyRow
|
||||
key={key.id}
|
||||
apiKey={key}
|
||||
ownerName={me.name}
|
||||
onRevoke={() => setPendingRevoke(key)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
You are signed in with an API key, and a key may not create, list or revoke
|
||||
credentials. Open PIG in a browser session to manage keys.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{issued ? <IssuedKeyDialog issued={issued} onDismiss={() => setIssued(null)} /> : null}
|
||||
|
||||
{/*
|
||||
A `window.confirm` here would block the whole tab — and on iOS it is
|
||||
dismissed by the same tap that opens it often enough to revoke a key by
|
||||
accident. The dialog names the credential instead, because "are you
|
||||
sure?" is not a question anyone can answer about a list of six keys.
|
||||
*/}
|
||||
{pendingRevoke ? (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRevoke(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
{/* The close button is absolutely positioned in the same corner,
|
||||
so a long key name would otherwise run underneath it. */}
|
||||
<DialogTitle className="pr-10">Revoke “{pendingRevoke.name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anything still holding {pendingRevoke.keyPrefix}… stops authenticating
|
||||
immediately, including agents running unattended. Revocation cannot be undone; a
|
||||
replacement is a new key.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setPendingRevoke(null)}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => revoke.mutate(pendingRevoke)}
|
||||
>
|
||||
{revoke.isPending ? 'Revoking…' : 'Revoke key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyRow({
|
||||
apiKey,
|
||||
ownerName,
|
||||
onRevoke,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
ownerName: string;
|
||||
onRevoke(): void;
|
||||
}) {
|
||||
const status = keyStatus(apiKey);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{apiKey.name}</p>
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
<Badge tone="accent">{apiKey.scopes.includes('write') ? 'read + write' : 'read'}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted">{apiKey.keyPrefix}…</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
<span title={new Date(apiKey.createdAt).toLocaleString()}>
|
||||
Created {relativeTime(apiKey.createdAt)}
|
||||
</span>{' '}
|
||||
by {ownerName} ·{' '}
|
||||
{apiKey.lastUsedAt ? `last used ${relativeTime(apiKey.lastUsedAt)}` : 'never used'}
|
||||
{apiKey.expiresAt ? ` · expires ${shortDate(apiKey.expiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{apiKey.revokedAt ? null : (
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRevoke}>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The show-once secret.
|
||||
*
|
||||
* PIG stores only a hash, so this dialog holds the single copy of the key that
|
||||
* will ever exist. Escape and click-away are how a dialog gets dismissed by
|
||||
* accident, and here an accident destroys a credential — so both are refused,
|
||||
* and the close button says plainly what it will cost until the key has been
|
||||
* copied.
|
||||
*/
|
||||
function IssuedKeyDialog({ issued, onDismiss }: { issued: IssuedApiKey; onDismiss(): void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
if (await copyToClipboard(issued.key, 'API key copied')) setCopied(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onDismiss();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-10">Copy “{issued.name}” now</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the only time PIG will show this key — the server keeps nothing but a hash of
|
||||
it. Close this without copying and the key is unrecoverable; you would have to create
|
||||
another.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-xl border border-warning bg-warning/10 p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* `select-all` makes one tap select the whole key, which is the
|
||||
fallback that matters when the clipboard API is unavailable. */}
|
||||
<code className="min-w-0 flex-1 select-all break-all font-mono text-xs">
|
||||
{issued.key}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Copy API key"
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
Set it as <code className="font-mono">PIG_API_KEY</code> where your agent runs. Scope:{' '}
|
||||
{issued.scopes.includes('write') ? 'read and write' : 'read only'}.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant={copied ? 'primary' : 'outline'} onClick={onDismiss}>
|
||||
{copied ? 'Done' : 'Close without copying'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={copied ? 'outline' : 'primary'}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
{copied ? 'Copy again' : 'Copy key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing out.
|
||||
*
|
||||
@@ -298,7 +710,7 @@ function SessionCard() {
|
||||
<CardTitle className="text-base">Session</CardTitle>
|
||||
<p className="text-sm text-muted">
|
||||
Signing out clears this browser only. API keys you have issued keep working —
|
||||
revoke those separately.
|
||||
revoke them under Agent access.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -2,13 +2,17 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
const apiTarget = process.env.PIG_API_TARGET ?? 'http://localhost:8920';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// Both are overridable so a second checkout (a worktree, a review branch)
|
||||
// can run its own API and web server without colliding with the first.
|
||||
port: Number(process.env.PIG_WEB_PORT ?? 5173),
|
||||
// Proxy in development so the browser sees one origin, matching how
|
||||
// production serves the API and the app together. Auth sessions are
|
||||
// per-origin, so a split origin in dev but not prod hides real bugs.
|
||||
@@ -20,8 +24,8 @@ export default defineConfig({
|
||||
* 200-text/html failure the API guards against for its own routes.
|
||||
*/
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8920', changeOrigin: true },
|
||||
'/media': { target: 'http://localhost:8920', changeOrigin: true },
|
||||
'/api': { target: apiTarget, changeOrigin: true },
|
||||
'/media': { target: apiTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user