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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user