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
+414 -1
View File
@@ -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.');