18d5f5bfc0
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>
911 lines
36 KiB
TypeScript
911 lines
36 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import type {
|
|
PiggyApprovalDecision,
|
|
PiggyChatContext,
|
|
PiggyChatEvent,
|
|
PiggyMode,
|
|
PiggyModelOption,
|
|
PiggyProposedChange,
|
|
} from '@pig/core';
|
|
import { ApiError, get, getSupabase, post } from './api';
|
|
|
|
/**
|
|
* Re-exported from @pig/core rather than declared here. The old local copy was
|
|
* a fourth definition of a shape that already existed in the relay's zod
|
|
* schema, the Piggy server's own interface and the model prompt — and widening
|
|
* it for the docked panel meant widening it in all of them or getting a 400
|
|
* from whichever hop was missed.
|
|
*/
|
|
export type { PiggyChatContext };
|
|
export {
|
|
PiggyContextProvider,
|
|
usePiggyContext,
|
|
usePiggyCurrentContext,
|
|
} from './piggy-context';
|
|
|
|
export interface PiggyChatTurn {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* The event union comes from @pig/core now, and the local copy is gone.
|
|
*
|
|
* It was declared here as well, which was survivable while the server only ever
|
|
* added fields, and stopped being survivable the moment a turn could pause on an
|
|
* approval: a client that has not been told about `approval_required` folds it
|
|
* into nothing, the card never appears, and the turn sits open until the agent's
|
|
* five-minute timeout rejects a change the user was never shown.
|
|
*/
|
|
export type { PiggyChatEvent, PiggyMode, PiggyModelOption, PiggyProposedChange };
|
|
|
|
/**
|
|
* What Piggy may do without being asked again.
|
|
*
|
|
* `read_only` is the default here for the same reason the relay defaults to it:
|
|
* write tools are something the user turns on, never something a forgotten
|
|
* field turns on for them.
|
|
*/
|
|
export const PIGGY_DEFAULT_MODE: PiggyMode = 'read_only';
|
|
|
|
export interface PiggyStatus {
|
|
enabled: boolean;
|
|
canUse: boolean;
|
|
/** What a client with no stored preference should open in. */
|
|
mode: PiggyMode;
|
|
/** The deployment's default model, or null when the agent cannot be asked. */
|
|
modelId: string | null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* A write Piggy has proposed and not made, as the transcript holds it.
|
|
*
|
|
* The states are deliberately more than "pending or done". `submitting` exists
|
|
* because the decision travels on a second request while the turn's own stream
|
|
* stays open, so there is a real interval in which the user has answered and
|
|
* nothing has happened yet; and `error` sits alongside `pending` rather than
|
|
* replacing it, because a decision that did not reach the relay leaves the
|
|
* change exactly as it was — still waiting, still answerable.
|
|
*/
|
|
export interface ApprovalStep {
|
|
change: PiggyProposedChange;
|
|
state: 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
|
|
/** What the user answered, once they have. */
|
|
decision?: PiggyApprovalDecision;
|
|
/** Why the decision could not be delivered, or why the write itself failed. */
|
|
error?: string;
|
|
}
|
|
|
|
export interface TranscriptMessage {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
reasoning?: string;
|
|
tools?: ToolStep[];
|
|
/** Writes this turn proposed, in the order they were proposed. */
|
|
approvals?: ApprovalStep[];
|
|
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;
|
|
/** From the `meta` event: what Piggy was allowed to do while answering. */
|
|
mode?: PiggyMode;
|
|
inputTokens?: number | null;
|
|
outputTokens?: number | null;
|
|
/** Whole micro-cents this turn cost, when the provider reported usage. */
|
|
costMicroCents?: number | null;
|
|
/**
|
|
* The provider's own word for why the answer stopped. `length` means the
|
|
* token budget cut it off mid-sentence, which is a different thing from the
|
|
* connection dying and reads differently to the user.
|
|
*/
|
|
finishReason?: string;
|
|
}
|
|
|
|
/**
|
|
* 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, mode: event.mode };
|
|
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 === 'approval_required') {
|
|
// Parked, not applied. Nothing in the CRM has changed at this point and the
|
|
// card must not suggest otherwise — the tool is holding its own turn open
|
|
// waiting for the answer this event asks for.
|
|
const already = (message.approvals ?? []).some((entry) => entry.change.id === event.change.id);
|
|
if (already) return message;
|
|
return {
|
|
...message,
|
|
approvals: [...(message.approvals ?? []), { change: event.change, state: 'pending' }],
|
|
};
|
|
}
|
|
if (event.type === 'approval_resolved') {
|
|
return {
|
|
...message,
|
|
approvals: (message.approvals ?? []).map((entry) =>
|
|
entry.change.id === event.changeId ? settleApproval(entry, event) : entry,
|
|
),
|
|
};
|
|
}
|
|
if (event.type === 'done') {
|
|
return {
|
|
...message,
|
|
pending: false,
|
|
inputTokens: event.inputTokens,
|
|
outputTokens: event.outputTokens,
|
|
costMicroCents: event.costMicroCents,
|
|
finishReason: event.finishReason,
|
|
/**
|
|
* A turn cannot end with a write still waiting: the agent resolves every
|
|
* pending change before it settles, and one still `pending` here means the
|
|
* timeout rejected it. Leaving the card mid-flight would keep offering
|
|
* buttons that no longer answer anything.
|
|
*/
|
|
approvals: message.approvals?.map((entry) =>
|
|
entry.state === 'pending' || entry.state === 'submitting'
|
|
? { ...entry, state: 'failed', error: 'This change expired before it was answered.' }
|
|
: entry,
|
|
),
|
|
};
|
|
}
|
|
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* The authoritative outcome of an approval: the agent has now either performed
|
|
* the write or not, and says which. It is the only thing allowed to move a card
|
|
* to `applied`, so no failure path can leave the transcript claiming a change
|
|
* was saved.
|
|
*/
|
|
function settleApproval(
|
|
entry: ApprovalStep,
|
|
event: Extract<PiggyChatEvent, { type: 'approval_resolved' }>,
|
|
): ApprovalStep {
|
|
if (!event.ok) {
|
|
return { ...entry, state: 'failed', decision: event.decision, error: event.error };
|
|
}
|
|
return {
|
|
...entry,
|
|
state: event.decision === 'apply' ? 'applied' : 'rejected',
|
|
decision: event.decision,
|
|
error: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, error.message), 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.
|
|
*
|
|
* 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, serverReason: string): string {
|
|
const time = clearsAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
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. */
|
|
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;
|
|
/**
|
|
* How far Piggy may act, and in which model. Owned here rather than by the
|
|
* controls that set them, because they belong to the conversation: the panel
|
|
* that draws the pickers is unmounted every time the sheet closes, and a mode
|
|
* that reset itself to read-only behind a closed overlay would be a silent
|
|
* change to what the next question is allowed to do.
|
|
*/
|
|
mode: PiggyMode;
|
|
setMode: (mode: PiggyMode) => void;
|
|
/** Undefined means "whatever the deployment's default is" — never a guess. */
|
|
modelId: string | undefined;
|
|
setModelId: (modelId: string | undefined) => void;
|
|
/** Assigned by the relay on the first turn; needed to answer an approval. */
|
|
conversationId: string | undefined;
|
|
/**
|
|
* Answer a proposed write. Resolves once the decision has been delivered, not
|
|
* once the write has happened — the outcome arrives on the open stream as an
|
|
* `approval_resolved` event, which is the only thing that marks a card applied.
|
|
*/
|
|
approve: (changeId: string, decision: PiggyApprovalDecision) => void;
|
|
/**
|
|
* 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 = '',
|
|
initialMode = PIGGY_DEFAULT_MODE,
|
|
initialModelId,
|
|
initialMessages,
|
|
initialConversationId,
|
|
}: {
|
|
context?: PiggyChatContext;
|
|
initialPrompt?: string;
|
|
/** Seeds only. The conversation owns both afterwards; see `PiggyConversation`. */
|
|
initialMode?: PiggyMode;
|
|
initialModelId?: string;
|
|
/**
|
|
* A transcript this conversation is resuming, read back from
|
|
* `GET /api/piggy/conversations/:id`.
|
|
*
|
|
* 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[];
|
|
/**
|
|
* The stored conversation this thread continues, when it is not a new one.
|
|
*
|
|
* Sent with the first turn so the relay carries on the same conversation
|
|
* rather than minting a second id for a thread the sidebar already lists,
|
|
* and so an approval posted before any `meta` event has an id to travel with.
|
|
*/
|
|
initialConversationId?: string;
|
|
} = {}): PiggyConversation {
|
|
const [messages, setMessages] = useState<TranscriptMessage[]>(initialMessages ?? []);
|
|
const [draft, setDraft] = useState(initialPrompt);
|
|
const [running, setRunning] = useState(false);
|
|
const [mode, setMode] = useState<PiggyMode>(initialMode);
|
|
const [modelId, setModelId] = useState<string | undefined>(initialModelId);
|
|
const [conversationId, setConversationId] = useState<string | undefined>(initialConversationId);
|
|
/**
|
|
* The id `approve` posts with.
|
|
*
|
|
* A ref as well as state because an approval can be answered in the same
|
|
* frame the `meta` event arrived in — the card is drawn from a `setMessages`
|
|
* that React may commit before it commits `setConversationId`, and posting an
|
|
* approval with no conversation is a 400 the user reads as Piggy losing their
|
|
* change.
|
|
*/
|
|
const conversationRef = useRef<string | undefined>(initialConversationId);
|
|
/**
|
|
* 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);
|
|
/**
|
|
* 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
|
|
// into would ever re-render to bring it back.
|
|
const [retryClock, setRetryClock] = useState(0);
|
|
|
|
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
|
|
.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;
|
|
touched.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;
|
|
/**
|
|
* 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,
|
|
history,
|
|
context,
|
|
mode,
|
|
modelId,
|
|
// Sent from the second turn on, so the agent can keep one thread rather
|
|
// than starting a fresh one under every question.
|
|
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);
|
|
}
|
|
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) =>
|
|
strandApprovals({ ...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) =>
|
|
strandApprovals({ ...turn, pending: false, stopped: true }),
|
|
);
|
|
} else {
|
|
const failure = describeFailure(error);
|
|
setMessages((current) =>
|
|
current.map((entry) => {
|
|
if (entry.id === assistantId) {
|
|
return strandApprovals({
|
|
...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. Only a turn the
|
|
// relay never began answering is marked — see `accepted`.
|
|
if (entry.id === userId && !accepted) 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));
|
|
};
|
|
|
|
/**
|
|
* Answer a proposed write.
|
|
*
|
|
* Optimistic only as far as honesty allows: the card moves to `submitting` so
|
|
* the buttons stop inviting a second press, and no further. Only the
|
|
* `approval_resolved` event that comes back on the open stream can say the
|
|
* change was applied, because only the agent knows whether `executeMutation`
|
|
* accepted it. A POST that fails puts the card back where it was, with the
|
|
* reason attached — the change really is still pending at the agent, so
|
|
* offering the buttons again is the truth rather than a courtesy.
|
|
*/
|
|
const approve = async (changeId: string, decision: PiggyApprovalDecision) => {
|
|
const conversation = conversationRef.current;
|
|
// Only the turn actually holding the card is rewritten. Mapping every
|
|
// message would give the whole transcript new identities and re-render a
|
|
// long conversation on each button press.
|
|
const settleCard = (change: (entry: ApprovalStep) => ApprovalStep) =>
|
|
setMessages((current) =>
|
|
current.map((entry) =>
|
|
entry.approvals?.some((approval) => approval.change.id === changeId)
|
|
? {
|
|
...entry,
|
|
approvals: entry.approvals.map((approval) =>
|
|
approval.change.id === changeId ? change(approval) : approval,
|
|
),
|
|
}
|
|
: entry,
|
|
),
|
|
);
|
|
|
|
if (!conversation) {
|
|
settleCard((entry) => ({
|
|
...entry,
|
|
error: 'Piggy has not identified this conversation yet.',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
settleCard((entry) => ({ ...entry, state: 'submitting', decision, error: undefined }));
|
|
try {
|
|
await post<{ ok: boolean }>('/api/piggy/approve', {
|
|
conversationId: conversation,
|
|
changeId,
|
|
decision,
|
|
});
|
|
} catch (error) {
|
|
/*
|
|
* A 404 is not a delivery failure, it is the change being over.
|
|
*
|
|
* The relay answers `approval_not_pending` when the agent no longer holds
|
|
* the card: the five-minute deadline rejected it, or the turn was
|
|
* abandoned. Putting the card back to `pending` there — which is what
|
|
* this did for every failure alike — leaves it reading "Needs you" with a
|
|
* live Apply button over a decision that can never be delivered, so the
|
|
* user presses it and gets the same 404 for ever. Every OTHER failure
|
|
* really does leave the change pending at the agent, and for those
|
|
* offering the buttons again is the truth rather than a courtesy.
|
|
*/
|
|
const settled = error instanceof ApiError && error.code === 'approval_not_pending';
|
|
settleCard((entry) => ({
|
|
...entry,
|
|
state: settled ? 'failed' : 'pending',
|
|
decision: undefined,
|
|
error: error instanceof Error ? error.message : 'That decision did not reach Piggy.',
|
|
}));
|
|
}
|
|
};
|
|
|
|
return {
|
|
messages,
|
|
draft,
|
|
setDraft,
|
|
running,
|
|
mode,
|
|
setMode,
|
|
modelId,
|
|
setModelId,
|
|
conversationId,
|
|
approve: (changeId, decision) => void approve(changeId, decision),
|
|
send: (text, fromTranscript) => void send(text, fromTranscript),
|
|
stop: () => abortRef.current?.abort(),
|
|
retry,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A turn that ended without the agent's word on its pending writes.
|
|
*
|
|
* Stop, a dropped connection and a refused request all leave the stream that
|
|
* `approval_resolved` would have arrived on closed for good. The change may
|
|
* genuinely still be waiting at the agent until its five-minute timeout, but
|
|
* nothing this client does can answer it any more, so the card says so instead
|
|
* of showing buttons that post into a conversation nobody is reading.
|
|
*/
|
|
function strandApprovals(message: TranscriptMessage): TranscriptMessage {
|
|
if (!message.approvals?.length) return message;
|
|
return {
|
|
...message,
|
|
approvals: message.approvals.map((entry) =>
|
|
entry.state === 'pending' || entry.state === 'submitting'
|
|
? { ...entry, state: 'failed', error: 'This turn ended before the change was answered.' }
|
|
: entry,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The models this deployment offers, for the picker.
|
|
*
|
|
* Served by the relay from the agent's own catalogue rather than a list kept
|
|
* here, because the relay refuses any model id that is not in it — a hard-coded
|
|
* option that has been retired upstream would be a menu entry whose only effect
|
|
* is a 400.
|
|
*/
|
|
export function fetchPiggyModels(): Promise<{
|
|
models: PiggyModelOption[];
|
|
defaultModelId: string | null;
|
|
}> {
|
|
return get('/api/piggy/models');
|
|
}
|
|
|
|
export interface PiggyChatRequest {
|
|
message: string;
|
|
history?: PiggyChatTurn[];
|
|
context?: PiggyChatContext;
|
|
/** Omitted, the relay reads it as `read_only`. Sent explicitly all the same. */
|
|
mode?: PiggyMode;
|
|
/** Must be one the relay's catalogue lists, or the turn is a 400. */
|
|
modelId?: string;
|
|
conversationId?: string;
|
|
}
|
|
|
|
export async function* streamPiggyChat(
|
|
request: PiggyChatRequest,
|
|
signal?: AbortSignal,
|
|
): AsyncGenerator<PiggyChatEvent> {
|
|
const supabase = getSupabase();
|
|
const token = supabase ? (await supabase.auth.getSession()).data.session?.access_token : null;
|
|
const response = await fetch('/api/piggy/chat', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(request),
|
|
signal,
|
|
});
|
|
|
|
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;
|
|
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.');
|
|
|
|
yield* readNdjson<PiggyChatEvent>(response.body, signal);
|
|
}
|
|
|
|
export async function* readNdjson<Value>(
|
|
stream: ReadableStream<Uint8Array>,
|
|
signal?: AbortSignal,
|
|
): AsyncGenerator<Value> {
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
try {
|
|
while (true) {
|
|
if (signal?.aborted) throw signal.reason;
|
|
const { done, value } = await reader.read();
|
|
buffer += decoder.decode(value, { stream: !done });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
const event = parseNdjsonLine<Value>(line);
|
|
if (event !== undefined) yield event;
|
|
}
|
|
if (done) {
|
|
const event = parseNdjsonLine<Value>(buffer);
|
|
if (event !== undefined) yield event;
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
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);
|
|
}
|