Rebuild Piggy's interface, and give the demo book a business to describe
CI / verify (push) Successful in 4m57s
CI / publish (push) Has been skipped

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:
claude
2026-08-14 00:33:41 -07:00
parent 76e3caa1cb
commit 99d165b5e5
81 changed files with 21780 additions and 2250 deletions
+427 -15
View File
@@ -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>