Make Piggy part of the product rather than a guest in it
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace around it. The layout was already right — the audit found the approval card to be the best-designed object in the repo, and the account page's empty panels less finished than anything in the workspace. What was wrong was vocabulary: nobody had written the small things down, so both halves kept inventing them. Piggy was drawn with five different marks — a pig in the dock, a sparkle in the sidebar and again on the model picker, a speech bubble on the Ask buttons, and a stock robot glyph on every assistant message, which is the one people look at most. There is now one mark. The composer, which is the first control in the product since sign-in lands on /piggy, was the only un-adapted shadcn field left: 6px radius against a 12px Send button it sat 8px from. A stat tile had been reinvented six times at three numeral scales, and the same uppercase micro-label existed in five variants, two of them one tab apart in the same rail. There were 63 hand-written font sizes: not a scale, sixty-three opinions. Underneath that, the focus ring was invisible. The global rule used ring-accent, which Tailwind deliberately aliases onto the hover tint, so the ring measured 1.01:1 against the light canvas — no visible focus indicator anywhere in the product, for any accent, in either theme. It is ring-brand now and measures 17:1. The warning, positive and info tones were darkened until each clears 4.5:1 on a card, on inset and on its own chip, and the light canvas moved to 98% so a card lifts without leaning on its shadow. The mobile work is the part worth reading. A landscape phone gave the transcript 28% of the viewport and a keyboard-up phone 16%, against a 45% floor — and the fixed tab bar painted over the composer, covering the safety sentence and half the Send button, because two source comments asserted the bar stood down on short viewports and it never had. Both fixed and measured by hit-testing rather than by screenshot. The composer itself was 64px tall for a blank second line nobody typed, because the auto-resize effect sizes to scrollHeight and scrollHeight counts rows — a CSS height could not win against an inline style, so the attribute was the honest lever. Verified across both themes driven through the app's own control: no horizontal overflow on 15 routes at four viewports, 672 stat values that fit, 297 labels at exactly 11px/500, Escape returning focus to its opener rather than the body on every overlay, and a rejected write no longer reporting "Succeeded" with a green check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+100
-13
@@ -335,7 +335,7 @@ interface Refusal {
|
||||
function describeFailure(error: unknown): Refusal {
|
||||
if (error instanceof PiggyRateLimitError) {
|
||||
const clearsAt = rateLimitClearsAt(error.retryAfterSeconds);
|
||||
return { message: rateLimitMessage(clearsAt), retryableAt: clearsAt.getTime() };
|
||||
return { message: rateLimitMessage(clearsAt, error.message), retryableAt: clearsAt.getTime() };
|
||||
}
|
||||
return { message: error instanceof Error ? error.message : 'Piggy chat failed.' };
|
||||
}
|
||||
@@ -361,10 +361,24 @@ function rateLimitClearsAt(retryAfterSeconds: number | null): Date {
|
||||
* "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.
|
||||
*
|
||||
* WHY the wait happened is the server's to say, not this function's. A 429
|
||||
* reaching this client has two quite different causes — PIG's own per-person
|
||||
* hourly quota, and Prime Inference throttling the deployment upstream — and
|
||||
* this hardcoded the first one for both. In a deployment where the upstream is
|
||||
* the common case, that is the product telling a GTM lead they have exhausted
|
||||
* an allowance they have barely touched, and discarding a far better sentence
|
||||
* the relay had already written ("Prime Inference is rate limiting us, so this
|
||||
* question was never answered… none of this was charged to you"). So the
|
||||
* server's reason is quoted and only the deadline is composed here.
|
||||
*/
|
||||
function rateLimitMessage(clearsAt: Date): string {
|
||||
function rateLimitMessage(clearsAt: Date, serverReason: string): 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.`;
|
||||
const reason = serverReason.trim() || 'Piggy is rate limited right now.';
|
||||
// A relayed sentence may or may not be punctuated; two full stops read as a
|
||||
// typo and none reads as a run-on.
|
||||
const stopped = /[.!?]$/.test(reason) ? reason : `${reason}.`;
|
||||
return `${stopped} Retry will work again at ${time}.`;
|
||||
}
|
||||
|
||||
/** Both the body field and the header are integers of seconds, and both may be absent. */
|
||||
@@ -445,12 +459,13 @@ export function usePiggyConversation({
|
||||
* A transcript this conversation is resuming, read back from
|
||||
* `GET /api/piggy/conversations/:id`.
|
||||
*
|
||||
* A seed, like everything else here: it is applied at mount and never again,
|
||||
* so a caller reopening a different thread must remount the hook (the
|
||||
* workspace keys it on the conversation id). Without it the workspace could
|
||||
* list history it had no way of putting back on screen, and the relay builds
|
||||
* a turn's prompt from the `history` the client sends — so an unseeded hook
|
||||
* would also continue a reopened thread having forgotten every word of it.
|
||||
* Adopted until the first send of this session, not only at mount. It is
|
||||
* almost never available at mount: the workspace puts the thread on screen
|
||||
* as soon as the id is in the URL, and `GET /api/piggy/conversations/:id` is
|
||||
* a round trip behind it — so a state initialiser captured `[]` every time
|
||||
* and threw the stored transcript away as it arrived. The relay also builds
|
||||
* a turn's prompt from the `history` the client sends, so an unseeded hook
|
||||
* carried on a reopened thread having forgotten every word of it.
|
||||
*/
|
||||
initialMessages?: TranscriptMessage[];
|
||||
/**
|
||||
@@ -491,6 +506,14 @@ export function usePiggyConversation({
|
||||
* written synchronously, so the second press is refused by the first.
|
||||
*/
|
||||
const runningRef = useRef(false);
|
||||
/**
|
||||
* Raised by the first send of this session, and never lowered.
|
||||
*
|
||||
* It is what makes the late seed below safe. Once this conversation has said
|
||||
* anything, what is on screen is ahead of anything the store can hand back,
|
||||
* and adopting a fetch would delete the turn being read.
|
||||
*/
|
||||
const touched = 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
|
||||
@@ -499,6 +522,24 @@ export function usePiggyConversation({
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
/**
|
||||
* Put the stored transcript on screen when it lands.
|
||||
*
|
||||
* The store keeps every question, tool call, proposed change and answer, and
|
||||
* the client used to discard all of it: ~50 rows in the history rail opened
|
||||
* on a blank pane that said, in writing, that nothing was stored. This is the
|
||||
* half of that loop that was missing.
|
||||
*
|
||||
* Compared by identity rather than adopted outright because callers rebuild
|
||||
* the array on every render — `toTranscript(detail.data.messages)` is a fresh
|
||||
* object each time — and replacing state with an equal value would re-render
|
||||
* the whole transcript for nothing.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (touched.current || !initialMessages) return;
|
||||
setMessages((current) => (sameTurns(current, initialMessages) ? current : initialMessages));
|
||||
}, [initialMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const waits = messages
|
||||
@@ -519,6 +560,7 @@ export function usePiggyConversation({
|
||||
if (!message || runningRef.current) return;
|
||||
// Claimed before the first await, so nothing else can enter this turn.
|
||||
runningRef.current = true;
|
||||
touched.current = true;
|
||||
const userId = crypto.randomUUID();
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history = toChatHistory(from ?? messages);
|
||||
@@ -539,6 +581,16 @@ export function usePiggyConversation({
|
||||
// answer — did anything terminate this turn? — is about the events, not
|
||||
// about what React has committed.
|
||||
let settled = false;
|
||||
/**
|
||||
* Whether the relay ever started answering.
|
||||
*
|
||||
* The question below is marked "Not sent" only when this is false. A
|
||||
* connection that dies half-way through has still spent the turn — the
|
||||
* tokens are gone, the tools have run, and part of the answer is on screen
|
||||
* — so telling the user their question never left is both wrong and the
|
||||
* thing that makes them ask it again.
|
||||
*/
|
||||
let accepted = false;
|
||||
try {
|
||||
const request = {
|
||||
message,
|
||||
@@ -551,6 +603,7 @@ export function usePiggyConversation({
|
||||
conversationId: conversationRef.current,
|
||||
};
|
||||
for await (const event of streamPiggyChat(request, abort.signal)) {
|
||||
accepted = true;
|
||||
if (event.type === 'meta' && event.conversationId !== conversationRef.current) {
|
||||
conversationRef.current = event.conversationId;
|
||||
setConversationId(event.conversationId);
|
||||
@@ -588,8 +641,9 @@ export function usePiggyConversation({
|
||||
}
|
||||
// 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 };
|
||||
// the relay refused out of the model's history. Only a turn the
|
||||
// relay never began answering is marked — see `accepted`.
|
||||
if (entry.id === userId && !accepted) return { ...entry, failed: true };
|
||||
return entry;
|
||||
}),
|
||||
);
|
||||
@@ -810,10 +864,12 @@ export async function* readNdjson<Value>(
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) yield JSON.parse(line) as Value;
|
||||
const event = parseNdjsonLine<Value>(line);
|
||||
if (event !== undefined) yield event;
|
||||
}
|
||||
if (done) {
|
||||
if (buffer.trim()) yield JSON.parse(buffer) as Value;
|
||||
const event = parseNdjsonLine<Value>(buffer);
|
||||
if (event !== undefined) yield event;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -821,3 +877,34 @@ export async function* readNdjson<Value>(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One NDJSON record, or nothing at all.
|
||||
*
|
||||
* A body cut mid-line leaves a fragment behind, and parsing it threw out of the
|
||||
* generator: the transcript then showed `Unexpected end of JSON input` where
|
||||
* Piggy's answer had been — the partial text already streamed was replaced by
|
||||
* that sentence, and the question above it was labelled "Not sent" after the
|
||||
* tokens had been spent. A fragment is not an event. Dropping it lets `send`
|
||||
* see the stream end without a `done`, which is exactly what happened, and the
|
||||
* turn is marked truncated with everything that did arrive still on screen.
|
||||
*/
|
||||
function parseNdjsonLine<Value>(line: string): Value | undefined {
|
||||
if (!line.trim()) return undefined;
|
||||
try {
|
||||
return JSON.parse(line) as Value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two transcripts are the same turns in the same order.
|
||||
*
|
||||
* Ids are enough: a stored row's id is its primary key and a live turn's is a
|
||||
* UUID minted once, so two arrays agreeing on every id are two readings of one
|
||||
* conversation.
|
||||
*/
|
||||
function sameTurns(a: TranscriptMessage[], b: TranscriptMessage[]): boolean {
|
||||
return a.length === b.length && a.every((entry, index) => entry.id === b[index]?.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user