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:
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
@@ -12,17 +16,45 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { TEAM_LABELS, TEAM_ROLES, TEAMS, type Team, type TeamRole } from '@pig/core';
|
||||
import { api, get, patch, post, relativeTime } from '@/lib/api';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cn,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from '@/components/ui';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { IntegrationSettings } from './IntegrationSettings';
|
||||
|
||||
/**
|
||||
* What the server can honestly say about Piggy, all of it derived from the
|
||||
* deployment environment or from a live probe of the chat server. Nothing here
|
||||
* comes from `platform_settings`, because nothing in `apps/piggy` reads it.
|
||||
*/
|
||||
interface PiggyRuntimeStatus {
|
||||
enabledByEnvironment: boolean;
|
||||
chatEnabled: boolean;
|
||||
internalUrlConfigured: boolean;
|
||||
internalTokenConfigured: boolean;
|
||||
model: string | null;
|
||||
inferenceBase: string | null;
|
||||
inferenceIsolated: boolean;
|
||||
/** Null means the API did not probe for this response, not "down". */
|
||||
reachable: boolean | null;
|
||||
reportedModel: string | null;
|
||||
}
|
||||
|
||||
interface AdminRuntimeSettings {
|
||||
piggyModel: string;
|
||||
piggyInferenceBase: string;
|
||||
piggyEnabled: boolean;
|
||||
piggy: PiggyRuntimeStatus;
|
||||
primeComputeBase: string;
|
||||
primeApiKey: {
|
||||
configured: boolean;
|
||||
@@ -102,8 +134,6 @@ export function AdminSettings() {
|
||||
|
||||
function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [model, setModel] = useState(settings.piggyModel);
|
||||
const [inferenceBase, setInferenceBase] = useState(settings.piggyInferenceBase);
|
||||
const [piggyEnabled, setPiggyEnabled] = useState(settings.piggyEnabled);
|
||||
const [syncEnabled, setSyncEnabled] = useState(settings.primeSyncEnabled);
|
||||
const [interval, setIntervalValue] = useState(String(settings.primeSyncIntervalMinutes));
|
||||
@@ -114,8 +144,6 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
patch<AdminRuntimeSettings>('/api/admin/settings', {
|
||||
piggyModel: model,
|
||||
piggyInferenceBase: inferenceBase,
|
||||
piggyEnabled,
|
||||
primeSyncEnabled: syncEnabled,
|
||||
primeSyncIntervalMinutes: Number(interval),
|
||||
@@ -133,24 +161,7 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
return (
|
||||
<form className="flex flex-col gap-5" onSubmit={(event) => { event.preventDefault(); setMessage(null); save.mutate(); }}>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2"><Bot className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Piggy intelligence</CardTitle></div>
|
||||
<p className="text-sm text-muted">Inference is deliberately isolated from the compute API.</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5" htmlFor="piggy-model">
|
||||
<span className="text-sm font-medium">Model</span>
|
||||
<Input id="piggy-model" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
<span className="text-xs text-muted">Nemotron runs tool calls with reasoning disabled to prevent think-aloud truncation.</span>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5" htmlFor="inference-base">
|
||||
<span className="text-sm font-medium">Inference endpoint</span>
|
||||
<Input id="inference-base" type="url" value={inferenceBase} onChange={(event) => setInferenceBase(event.target.value)} />
|
||||
</label>
|
||||
<ToggleRow id="piggy-enabled" label="Piggy worker" description="Allow the configured worker to process queued tasks." checked={piggyEnabled} onCheckedChange={setPiggyEnabled} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PiggyCard status={settings.piggy} chatEnabled={piggyEnabled} onChatEnabledChange={setPiggyEnabled} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -183,13 +194,273 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ id, label, description, checked, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; onCheckedChange(value: boolean): void }) {
|
||||
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
|
||||
/**
|
||||
* Piggy's control panel, which for the most part controls nothing.
|
||||
*
|
||||
* This card used to offer an editable model and inference endpoint. Both saved
|
||||
* happily into `platform_settings`, and `apps/piggy` has never read that table:
|
||||
* it takes its model, its endpoint and its inference key from `process.env` at
|
||||
* boot. An admin could therefore change the model here, be told it was saved,
|
||||
* and watch the old one keep answering. They are reported as environment facts
|
||||
* now, and the only genuinely live control — the chat switch — is labelled with
|
||||
* what it actually gates.
|
||||
*/
|
||||
function PiggyCard({
|
||||
status,
|
||||
chatEnabled,
|
||||
onChatEnabledChange,
|
||||
}: {
|
||||
status: PiggyRuntimeStatus;
|
||||
chatEnabled: boolean;
|
||||
onChatEnabledChange(value: boolean): void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const verdict = piggyVerdict(status);
|
||||
// Two containers, two copies of PIGGY_MODEL. When they disagree, the process
|
||||
// doing the inference wins, and the operator is looking at the wrong one.
|
||||
const modelDisagrees =
|
||||
status.reportedModel !== null && status.model !== null && status.reportedModel !== status.model;
|
||||
|
||||
function recheck() {
|
||||
setRechecking(true);
|
||||
void queryClient
|
||||
.refetchQueries({ queryKey: ['admin-settings'] })
|
||||
.finally(() => setRechecking(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="text-accent-fg" aria-hidden />
|
||||
<CardTitle className="text-base">Piggy intelligence</CardTitle>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={recheck} disabled={rechecking}>
|
||||
<RefreshCw className={cn('size-4', rechecking && 'animate-spin')} aria-hidden />
|
||||
{rechecking ? 'Checking…' : 'Recheck'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Piggy reads its model, endpoint and inference key from the deployment environment once, at boot. Nothing on this page can change them.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className={cn('rounded-xl border p-3', VERDICT_SURFACE[verdict.tone])}>
|
||||
<p className={cn('text-sm font-medium', VERDICT_TEXT[verdict.tone])}>{verdict.title}</p>
|
||||
<p className="mt-1 text-xs text-muted">{verdict.detail}</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
<PiggyFact
|
||||
state={status.enabledByEnvironment ? 'ok' : 'bad'}
|
||||
label="Deployment gate"
|
||||
detail={status.enabledByEnvironment ? 'PIGGY_ENABLED is set' : 'PIGGY_ENABLED is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.internalUrlConfigured ? 'ok' : 'bad'}
|
||||
label="Relay address"
|
||||
detail={status.internalUrlConfigured ? 'PIGGY_INTERNAL_URL is set' : 'PIGGY_INTERNAL_URL is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.internalTokenConfigured ? 'ok' : 'bad'}
|
||||
label="Internal token"
|
||||
detail={status.internalTokenConfigured ? 'PIGGY_INTERNAL_TOKEN is set' : 'PIGGY_INTERNAL_TOKEN is unset'}
|
||||
/>
|
||||
<PiggyFact
|
||||
state={status.reachable === null ? 'unknown' : status.reachable ? 'ok' : 'bad'}
|
||||
label="Chat server"
|
||||
detail={
|
||||
status.reachable === null
|
||||
? 'Not probed'
|
||||
: status.reachable
|
||||
? 'Answering /internal/health'
|
||||
: 'No answer on /internal/health'
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
|
||||
{status.inferenceIsolated ? null : (
|
||||
<p className="flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-px size-4 shrink-0" aria-hidden />
|
||||
PIGGY_INFERENCE_BASE points at the Prime compute API host. Inference lives on a different host and no model call can succeed against this one.
|
||||
</p>
|
||||
)}
|
||||
{modelDisagrees ? (
|
||||
<p className="flex items-start gap-2 text-xs text-warning">
|
||||
<AlertTriangle className="mt-px size-4 shrink-0" aria-hidden />
|
||||
This API container is configured for {status.model}, but the Piggy process reports {status.reportedModel}. The two environments disagree; the one Piggy holds is the one being billed.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<EnvironmentValue
|
||||
label="Model"
|
||||
variable="PIGGY_MODEL"
|
||||
value={status.reportedModel ?? status.model}
|
||||
note={
|
||||
status.reportedModel
|
||||
? 'Reported by the running chat server, which is the copy that matters.'
|
||||
: 'From this API container. The Piggy process holds its own copy and only it can confirm what is in force.'
|
||||
}
|
||||
/>
|
||||
<EnvironmentValue
|
||||
label="Inference endpoint"
|
||||
variable="PIGGY_INFERENCE_BASE"
|
||||
value={status.inferenceBase}
|
||||
note={
|
||||
status.inferenceIsolated
|
||||
? 'A different host from the Prime compute API, as it must be. The inference key that goes with it never reaches this container.'
|
||||
: 'It should name an inference host. The inference key that goes with it never reaches this container.'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Locked rather than merely ineffective when the environment gate is
|
||||
shut: a switch that saves and changes nothing is the exact failure
|
||||
this card was rewritten to remove. */}
|
||||
<ToggleRow
|
||||
id="piggy-enabled"
|
||||
label="Interactive chat"
|
||||
description={
|
||||
status.enabledByEnvironment
|
||||
? 'Lets people open Piggy and ask questions. The background task worker ignores this switch entirely — it runs whenever the Piggy process is up.'
|
||||
: 'Locked until PIGGY_ENABLED is set in the environment. It gates the chat panel only; the background task worker never reads it.'
|
||||
}
|
||||
checked={chatEnabled}
|
||||
disabled={!status.enabledByEnvironment}
|
||||
onCheckedChange={onChatEnabledChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const VERDICT_SURFACE = {
|
||||
positive: 'border-positive bg-positive/10',
|
||||
warning: 'border-warning bg-warning/10',
|
||||
danger: 'border-danger bg-danger/10',
|
||||
neutral: 'border-border bg-surface-2',
|
||||
} as const;
|
||||
|
||||
const VERDICT_TEXT = {
|
||||
positive: 'text-positive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
neutral: 'text-fg',
|
||||
} as const;
|
||||
|
||||
interface PiggyVerdict {
|
||||
tone: keyof typeof VERDICT_SURFACE;
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered outermost gate first, because only the first unmet condition is
|
||||
* actionable: telling an operator their chat server is unreachable when
|
||||
* PIGGY_ENABLED is unset sends them to read container logs for a service they
|
||||
* never asked to run.
|
||||
*
|
||||
* Read from the saved status rather than the pending switch, so an unsaved
|
||||
* toggle cannot make the panel describe a state that is not in force.
|
||||
*/
|
||||
function piggyVerdict(status: PiggyRuntimeStatus): PiggyVerdict {
|
||||
if (!status.enabledByEnvironment) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Piggy is switched off in this deployment',
|
||||
detail:
|
||||
'PIGGY_ENABLED is unset, so chat is hidden for everyone and the switch below is locked. Set it in the environment and restart the API.',
|
||||
};
|
||||
}
|
||||
if (!status.internalUrlConfigured || !status.internalTokenConfigured) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
title: 'Piggy is enabled but not wired up',
|
||||
detail:
|
||||
'The API has no authenticated route to the chat server. Chat stays unavailable until both PIGGY_INTERNAL_URL and PIGGY_INTERNAL_TOKEN are set on this container.',
|
||||
};
|
||||
}
|
||||
if (status.reachable === null) {
|
||||
return {
|
||||
tone: 'neutral',
|
||||
title: 'Chat server not checked',
|
||||
detail: 'Press Recheck to probe it.',
|
||||
};
|
||||
}
|
||||
if (!status.reachable) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
title: 'The chat server is not answering',
|
||||
detail:
|
||||
'PIGGY_INFERENCE_API_KEY never reaches this container, and Piggy exits at boot without it — a missing key looks exactly like this. Check the Piggy container logs before anything else.',
|
||||
};
|
||||
}
|
||||
if (!status.chatEnabled) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Reachable, but chat is switched off',
|
||||
detail:
|
||||
'The chat server answered and the queued task worker is running, but nobody can open the chat panel until the switch below is on and saved.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: 'positive',
|
||||
title: 'Piggy is answering',
|
||||
detail: status.reportedModel
|
||||
? `The chat server is up and running ${status.reportedModel}.`
|
||||
: 'The chat server is up.',
|
||||
};
|
||||
}
|
||||
|
||||
function PiggyFact({ state, label, detail }: { state: 'ok' | 'bad' | 'unknown'; label: string; detail: string }) {
|
||||
const Icon = state === 'ok' ? CircleCheck : state === 'bad' ? CircleX : CircleDashed;
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0',
|
||||
state === 'ok' ? 'text-positive' : state === 'bad' ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted">{detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value an admin may need to read and quote, but must not be invited to edit.
|
||||
* Rendered as text rather than a disabled input on purpose: a greyed-out field
|
||||
* still reads as "editable later", and this one never will be.
|
||||
*/
|
||||
function EnvironmentValue({ label, variable, value, note }: { label: string; variable: string; value: string | null; note: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Badge>Set by environment</Badge>
|
||||
</div>
|
||||
<p className="min-w-0 break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs">
|
||||
{value ?? 'unset'}
|
||||
</p>
|
||||
<span className="text-xs text-muted">
|
||||
<code className="font-mono">{variable}</code> · {note}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ id, label, description, checked, disabled, onCheckedChange }: { id: string; label: string; description: string; checked: boolean; disabled?: boolean; onCheckedChange(value: boolean): void }) {
|
||||
return <div className="flex min-w-0 items-center justify-between gap-4 rounded-xl border border-border p-3"><div className="min-w-0"><Label htmlFor={id}>{label}</Label><p className="mt-1 text-xs text-muted">{description}</p></div><Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} /></div>;
|
||||
}
|
||||
|
||||
function InviteManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
|
||||
const ledger = useQuery({ queryKey: ['admin-invites'], queryFn: () => get<Invite[]>('/api/admin/invites') });
|
||||
const [email, setEmail] = useState('');
|
||||
const [team, setTeam] = useState<Team | 'any'>('any');
|
||||
const [role, setRole] = useState<TeamRole>('member');
|
||||
@@ -210,13 +481,73 @@ function InviteManager() {
|
||||
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
|
||||
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
|
||||
</form></CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader><CardContent className="flex flex-col gap-2">{data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader>{/* A failed ledger read must not render as "no invites issued": an admin who
|
||||
believes the workspace is empty issues a second code to someone who
|
||||
already has one. */}
|
||||
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites…</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab an admin is sent to in order to grant someone access.
|
||||
*
|
||||
* It used to destructure `data = []` with no loading or error branch, so a slow
|
||||
* or failed request drew a heading over nothing — indistinguishable from a
|
||||
* workspace with no members, and no indication that anything had gone wrong.
|
||||
*/
|
||||
function MemberManager() {
|
||||
const { data = [] } = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
|
||||
return <div className="flex flex-col gap-3"><div className="flex items-center gap-2"><Users className="text-accent-fg" aria-hidden /><div><h3 className="font-semibold">Team and role administration</h3><p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p></div></div>{data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)}</div>;
|
||||
const query = useQuery({ queryKey: ['admin-members'], queryFn: () => get<Member[]>('/api/admin/members') });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-accent-fg" aria-hidden />
|
||||
<div>
|
||||
<h3 className="font-semibold">Team and role administration</h3>
|
||||
<p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p>
|
||||
</div>
|
||||
</div>
|
||||
{query.isPending ? (
|
||||
<div className="flex flex-col gap-3" aria-busy>
|
||||
<span className="sr-only">Loading members…</span>
|
||||
{/* Shaped like a member row rather than a plain bar, so the tab does
|
||||
not visibly reflow the moment the request lands. */}
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Card key={row}>
|
||||
<CardContent className="flex flex-col gap-4 p-4 sm:p-5 xl:flex-row xl:items-center">
|
||||
<div className="flex flex-col gap-2 xl:w-64"><Skeleton className="h-4 w-32" /><Skeleton className="h-3 w-44" /></div>
|
||||
<div className="grid flex-1 gap-2 sm:grid-cols-3">{[0, 1, 2].map((column) => <Skeleton key={column} className="h-11" />)}</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle aria-hidden />}
|
||||
title="Access list unavailable"
|
||||
description={query.error.message}
|
||||
action={<Button type="button" variant="outline" onClick={() => void query.refetch()}><RefreshCw aria-hidden />Try again</Button>}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : query.data.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<EmptyState
|
||||
icon={<Users aria-hidden />}
|
||||
title="No active members"
|
||||
description="Everyone with an account has been deactivated. Issue an invite from the Invites tab to bring someone back in."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
query.data.map((member) => <MemberAccess key={`${member.id}:${JSON.stringify(member.memberships)}:${member.isPlatformAdmin}`} member={member} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberAccess({ member }: { member: Member }) {
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||
import { ApiError, compactNumber, dateRange, get, percent, post, shortDate, unitPrice } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface AvailabilityRow {
|
||||
@@ -439,7 +439,12 @@ export function AllocationSheet({
|
||||
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
|
||||
) : options.length === 0 && !availabilityLoading ? (
|
||||
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
No currently available commitment remains in this context. Run the matcher again before promising capacity.
|
||||
{/* Two different dead ends. Told to re-run a matcher they
|
||||
never ran, someone with an empty book has nowhere to go —
|
||||
the answer there is to record what capacity was bought. */}
|
||||
{matches
|
||||
? 'No currently available commitment remains in this context. Run the matcher again before promising capacity.'
|
||||
: 'No capacity commitment has any hours left to sell. Record what capacity has been committed to buy before promising any.'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -495,7 +500,7 @@ export function AllocationSheet({
|
||||
<Badge tone={allocation.status === 'planned' ? 'warning' : 'positive'}>{allocation.status === 'planned' ? 'Held' : allocation.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {shortDate(allocation.startsAt)}–{shortDate(allocation.endsAt)}
|
||||
{compactNumber(Number(allocation.gpuHours))} GPU-hrs · {dateRange(allocation.startsAt, allocation.endsAt)}
|
||||
{allocation.holdExpiresAt ? ` · expires ${shortDate(allocation.holdExpiresAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -562,12 +567,12 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted">Contract window</dt><dd className="text-right">{shortDate(row.startsAt)}–{shortDate(row.endsAt)}</dd>
|
||||
<dt className="text-muted">Contract window</dt><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
|
||||
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{money(row.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${money(row.breakEvenPriceCents)}/GPU-hr`}</dd>
|
||||
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
|
||||
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
|
||||
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
|
||||
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{money(Math.round(delta * 100))}/GPU-hr</dd></> : null}
|
||||
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
|
||||
</dl>
|
||||
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Search } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { activeNavItem, visibleNav } from '@/lib/nav';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { CommandPalette, searchLabel, searchPlaceholder } from './CommandPalette';
|
||||
import { PiggyLogo } from './PiggyMark';
|
||||
import { PiggyDockToggle } from './PiggyDock';
|
||||
import { AudioControl } from './AudioControl';
|
||||
@@ -44,6 +44,10 @@ export function AppHeader() {
|
||||
const current = activeNavItem(items, pathname);
|
||||
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
// The button says what the palette will actually search, so the two cannot
|
||||
// disagree about whether this person's grants reach the book.
|
||||
const label = searchLabel(identity);
|
||||
const placeholder = searchPlaceholder(identity);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -99,7 +103,7 @@ export function AppHeader() {
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">Search pages and workflows…</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}…</span>
|
||||
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
|
||||
⌘K
|
||||
</kbd>
|
||||
@@ -110,7 +114,7 @@ export function AppHeader() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted md:hidden"
|
||||
aria-label="Search and navigate"
|
||||
aria-label={placeholder}
|
||||
onClick={() => setCommandOpen(true)}
|
||||
>
|
||||
<Search className="size-5" aria-hidden />
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
/**
|
||||
* ⌘K — pages and the book, in one ranking.
|
||||
*
|
||||
* This used to search page names only, so every account, deal and contract in
|
||||
* the book answered "No pages found". That is the opposite of what anyone
|
||||
* presses ⌘K for: the reflex is the palette, then a customer's name.
|
||||
*
|
||||
* Records are read through the SAME react-query keys the list pages use, so a
|
||||
* palette opened after a visit to Accounts or a pipeline board costs nothing,
|
||||
* and typing costs no requests at all — the book is fetched once per open and
|
||||
* filtered in memory. A search that issued a request per keystroke would be
|
||||
* slower than opening the page it is trying to save you from.
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { Building2, FileText, Handshake, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
DEMAND_STAGE_LABELS,
|
||||
SUPPLY_STAGE_LABELS,
|
||||
type AccountSide,
|
||||
type ContractStatus,
|
||||
type ContractType,
|
||||
type DemandStage,
|
||||
type SupplyStage,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -11,6 +34,9 @@ import {
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { get, money } from '@/lib/api';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { canAny, type PermissionIdentity } from '@/lib/permissions';
|
||||
|
||||
export interface CommandDestination {
|
||||
to: string;
|
||||
@@ -20,6 +46,312 @@ export interface CommandDestination {
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a record lives.
|
||||
*
|
||||
* One line each, deliberately. `/accounts/:id` is a real detail route; the
|
||||
* deal boards and the contract list still select in local state, so those two
|
||||
* land on the right page with the id carried in the query string — the honest
|
||||
* destination today, and a one-line change to a detail route the moment one
|
||||
* exists.
|
||||
*/
|
||||
const RECORD_ROUTES = {
|
||||
account: (id: string) => `/accounts/${id}`,
|
||||
demandDeal: (id: string) => `/demand?deal=${id}`,
|
||||
supplyDeal: (id: string) => `/supply?deal=${id}`,
|
||||
contract: (id: string) => `/contracts?contract=${id}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Enough rows to recognise the one you meant, few enough that the palette does
|
||||
* not become the list page. The heading says when there are more, because a
|
||||
* silent cap is indistinguishable from a missing record.
|
||||
*/
|
||||
const ROWS_PER_GROUP = 5;
|
||||
|
||||
type RecordKind = 'account' | 'deal' | 'contract';
|
||||
|
||||
interface RecordHit {
|
||||
kind: RecordKind;
|
||||
id: string;
|
||||
name: string;
|
||||
/** Secondary line: what tells two similarly named records apart. */
|
||||
meta: string;
|
||||
/** Right-hand figure — money or capacity — where the record has one. */
|
||||
trailing?: string;
|
||||
to: string;
|
||||
/** Lowercased match text, including terms the row does not display. */
|
||||
haystack: string;
|
||||
}
|
||||
|
||||
const RECORD_GROUPS: readonly { kind: RecordKind; heading: string; icon: LucideIcon }[] = [
|
||||
{ kind: 'account', heading: 'Accounts', icon: Building2 },
|
||||
{ kind: 'deal', heading: 'Deals', icon: Handshake },
|
||||
{ kind: 'contract', heading: 'Contracts', icon: FileText },
|
||||
];
|
||||
|
||||
const SIDE_LABELS: Record<AccountSide, string> = {
|
||||
supply: 'Supply',
|
||||
demand: 'Demand',
|
||||
both: 'Supply & demand',
|
||||
};
|
||||
|
||||
const CONTRACT_TYPE_LABELS: Record<ContractType, string> = {
|
||||
msa: 'MSA',
|
||||
dpa: 'DPA',
|
||||
sla: 'SLA',
|
||||
order_form: 'Order form',
|
||||
capacity_commitment: 'Capacity commitment',
|
||||
nda: 'NDA',
|
||||
amendment: 'Amendment',
|
||||
};
|
||||
|
||||
/** Enum values are snake_case everywhere; this is presentation, not a table. */
|
||||
function humanise(value: string): string {
|
||||
const spaced = value.replace(/_/g, ' ');
|
||||
return spaced.charAt(0).toLocaleUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function joinMeta(parts: (string | null | undefined)[]): string {
|
||||
return parts.filter((part): part is string => Boolean(part)).join(' · ');
|
||||
}
|
||||
|
||||
/** The words a row must contain. An empty query asks nothing and matches all. */
|
||||
function queryWords(query: string): string[] {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
return needle ? needle.split(/\s+/) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every word, in any order — so "labs tess" and "tess labs" both find
|
||||
* Tessellate Labs, which a plain substring test would not.
|
||||
*
|
||||
* This gate is also what keeps the ranking sane. cmdk scores with
|
||||
* command-score, which is a subsequence matcher: it rates "Import Records"
|
||||
* against "tess" at 0.003 rather than zero, and it leaves groups in the order
|
||||
* they were written. Left to itself the palette therefore put four irrelevant
|
||||
* pages above the account someone had just typed the name of, with the first
|
||||
* of them selected — so Enter opened Import. Filtering both pages and records
|
||||
* on whole words first means everything cmdk still sees is a genuine match.
|
||||
*/
|
||||
function matchesWords(haystack: string, words: readonly string[]): boolean {
|
||||
return words.every((word) => haystack.includes(word));
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
side: AccountSide;
|
||||
country: string | null;
|
||||
customerSegment: string | null;
|
||||
supplierType: string | null;
|
||||
}
|
||||
|
||||
interface DealBoard<T> {
|
||||
deals: { deal: T; accountName: string | null }[];
|
||||
}
|
||||
|
||||
interface DemandDealRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: DemandStage;
|
||||
productLine: string;
|
||||
acvCents: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface SupplyDealRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: SupplyStage;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
}
|
||||
|
||||
interface ContractRow {
|
||||
contract: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ContractType;
|
||||
status: ContractStatus;
|
||||
valueCents: number | null;
|
||||
currency: string;
|
||||
};
|
||||
accountName: string | null;
|
||||
}
|
||||
|
||||
function hit(fields: Omit<RecordHit, 'haystack'> & { hidden?: string }): RecordHit {
|
||||
const { hidden, ...record } = fields;
|
||||
return {
|
||||
...record,
|
||||
haystack: `${record.name} ${record.meta} ${record.trailing ?? ''} ${hidden ?? ''}`.toLocaleLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function accountHits(rows: AccountRow[] | undefined): RecordHit[] {
|
||||
return (rows ?? []).map((account) =>
|
||||
hit({
|
||||
kind: 'account',
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
meta: joinMeta([SIDE_LABELS[account.side], account.domain, account.country]),
|
||||
to: RECORD_ROUTES.account(account.id),
|
||||
hidden: joinMeta([account.customerSegment, account.supplierType]),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function demandDealHits(board: DealBoard<DemandDealRow> | undefined): RecordHit[] {
|
||||
return (board?.deals ?? []).map(({ deal, accountName }) =>
|
||||
hit({
|
||||
kind: 'deal',
|
||||
id: deal.id,
|
||||
name: deal.name,
|
||||
meta: joinMeta(['Demand', accountName, DEMAND_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
|
||||
trailing: deal.acvCents == null ? undefined : money(deal.acvCents, deal.currency),
|
||||
to: RECORD_ROUTES.demandDeal(deal.id),
|
||||
hidden: humanise(deal.productLine),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function supplyDealHits(board: DealBoard<SupplyDealRow> | undefined): RecordHit[] {
|
||||
return (board?.deals ?? []).map(({ deal, accountName }) =>
|
||||
hit({
|
||||
kind: 'deal',
|
||||
id: deal.id,
|
||||
name: deal.name,
|
||||
meta: joinMeta(['Supply', accountName, SUPPLY_STAGE_LABELS[deal.stage] ?? humanise(deal.stage)]),
|
||||
trailing:
|
||||
deal.gpuCount != null && deal.gpuType ? `${deal.gpuCount}× ${deal.gpuType}` : undefined,
|
||||
to: RECORD_ROUTES.supplyDeal(deal.id),
|
||||
hidden: deal.gpuType ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function contractHits(rows: ContractRow[] | undefined): RecordHit[] {
|
||||
return (rows ?? []).map(({ contract, accountName }) =>
|
||||
hit({
|
||||
kind: 'contract',
|
||||
id: contract.id,
|
||||
name: contract.title,
|
||||
meta: joinMeta([
|
||||
accountName,
|
||||
CONTRACT_TYPE_LABELS[contract.type],
|
||||
humanise(contract.status),
|
||||
]),
|
||||
trailing:
|
||||
contract.valueCents == null
|
||||
? undefined
|
||||
: money(contract.valueCents, contract.currency),
|
||||
to: RECORD_ROUTES.contract(contract.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hit on the record's own name beats one that only matched its second line,
|
||||
* so typing an account's name puts the account above the several deals that
|
||||
* merely mention it. cmdk re-scores whatever survives; this decides which rows
|
||||
* survive the cap, which is the decision cmdk cannot make for us.
|
||||
*/
|
||||
function rankOf(record: RecordHit, needle: string): number {
|
||||
const name = record.name.toLocaleLowerCase();
|
||||
if (name.startsWith(needle)) return 0;
|
||||
if (name.includes(needle)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
interface BookSearch {
|
||||
hits: RecordHit[];
|
||||
isLoading: boolean;
|
||||
/** Every source failed — the palette can only offer pages. */
|
||||
isUnavailable: boolean;
|
||||
/** At least one source failed, so the results are known to be incomplete. */
|
||||
isIncomplete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The book, filtered.
|
||||
*
|
||||
* Every key here is copied from the page that owns it — `['accounts', 'all']`
|
||||
* from Accounts, the endpoint-keyed boards from Pipeline, `['contracts']` from
|
||||
* Contracts — so this shares their cache rather than shadowing it with a
|
||||
* fourth copy of the same rows.
|
||||
*/
|
||||
function useBookSearch(query: string, enabled: boolean): BookSearch {
|
||||
const accounts = useQuery({
|
||||
queryKey: ['accounts', 'all'],
|
||||
queryFn: () => get<AccountRow[]>('/api/accounts'),
|
||||
enabled,
|
||||
});
|
||||
const demand = useQuery({
|
||||
queryKey: ['/api/deals/demand'],
|
||||
queryFn: () => get<DealBoard<DemandDealRow>>('/api/deals/demand'),
|
||||
enabled,
|
||||
});
|
||||
const supply = useQuery({
|
||||
queryKey: ['/api/deals/supply'],
|
||||
queryFn: () => get<DealBoard<SupplyDealRow>>('/api/deals/supply'),
|
||||
enabled,
|
||||
});
|
||||
const contracts = useQuery({
|
||||
queryKey: ['contracts'],
|
||||
queryFn: () => get<ContractRow[]>('/api/contracts'),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const all = useMemo(
|
||||
() => [
|
||||
...accountHits(accounts.data),
|
||||
...demandDealHits(demand.data),
|
||||
...supplyDealHits(supply.data),
|
||||
...contractHits(contracts.data),
|
||||
],
|
||||
[accounts.data, demand.data, supply.data, contracts.data],
|
||||
);
|
||||
|
||||
const hits = useMemo(() => {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const words = queryWords(query);
|
||||
if (words.length === 0) return [];
|
||||
return all
|
||||
.filter((record) => matchesWords(record.haystack, words))
|
||||
.sort((left, right) => rankOf(left, needle) - rankOf(right, needle));
|
||||
}, [all, query]);
|
||||
|
||||
const queries: UseQueryResult<unknown>[] = [accounts, demand, supply, contracts];
|
||||
const failed = queries.filter((result) => result.isError).length;
|
||||
return {
|
||||
hits,
|
||||
isLoading: queries.some((result) => result.isLoading),
|
||||
isUnavailable: failed === queries.length,
|
||||
isIncomplete: failed > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the palette can actually search for this person, said honestly.
|
||||
*
|
||||
* Exported because the header's search button makes the same promise, and a
|
||||
* button offering to find accounts to somebody whose grants stop the palette
|
||||
* from loading any is a promise the dialog then breaks. No trailing ellipsis:
|
||||
* these are accessible names as well as placeholders, and a screen reader
|
||||
* announces the dots.
|
||||
*/
|
||||
export function searchPlaceholder(identity: PermissionIdentity | undefined): string {
|
||||
return canAny(identity, 'book:read')
|
||||
? 'Search accounts, deals, contracts and pages'
|
||||
: 'Search pages and workflows';
|
||||
}
|
||||
|
||||
/** The same promise, short enough to survive the header button at 224px. */
|
||||
export function searchLabel(identity: PermissionIdentity | undefined): string {
|
||||
return canAny(identity, 'book:read') ? 'Search records and pages' : 'Search pages';
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
destinations,
|
||||
open,
|
||||
@@ -31,7 +363,36 @@ export function CommandPalette({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const groups = Array.from(new Set(destinations.map((destination) => destination.group ?? 'Navigate')));
|
||||
const identity = useIdentity();
|
||||
const placeholder = searchPlaceholder(identity);
|
||||
const canSearchRecords = canAny(identity, 'book:read');
|
||||
|
||||
/*
|
||||
* Filtering is deliberately synchronous with the keystroke, and there is no
|
||||
* timer anywhere in this file.
|
||||
*
|
||||
* The debounce that matters happened already: the book is fetched once per
|
||||
* open and matched in memory, so typing costs no requests. Deferring the
|
||||
* *render* on top of that — `useDeferredValue`, a timeout, either — is not
|
||||
* free but actively broken: cmdk picks the item Enter will open in the same
|
||||
* pass that handles the keystroke, so rows arriving a frame later are rows
|
||||
* it has already decided are not there. Measured, with the rows deferred:
|
||||
* typing "demo msa" listed four contracts with none selected, and Enter did
|
||||
* nothing at all. Eighty rows of string matching is far cheaper than that.
|
||||
*/
|
||||
const records = useBookSearch(query, open && canSearchRecords);
|
||||
const words = useMemo(() => queryWords(query), [query]);
|
||||
const pages = useMemo(
|
||||
() =>
|
||||
destinations.filter((destination) =>
|
||||
matchesWords(
|
||||
`${destination.label} ${destination.group ?? 'Navigate'}`.toLocaleLowerCase(),
|
||||
words,
|
||||
),
|
||||
),
|
||||
[destinations, words],
|
||||
);
|
||||
const groups = Array.from(new Set(pages.map((destination) => destination.group ?? 'Navigate')));
|
||||
|
||||
// Clear on close rather than on open: reopening must not present yesterday's
|
||||
// query over a list it is already silently filtering. Keyed off `open` and
|
||||
@@ -54,6 +415,8 @@ export function CommandPalette({
|
||||
if (open) opener.current = document.activeElement as HTMLElement | null;
|
||||
}, [open]);
|
||||
|
||||
const typing = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
@@ -73,16 +436,27 @@ export function CommandPalette({
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Search pages and workflows…"
|
||||
aria-label="Search pages and workflows"
|
||||
placeholder={`${placeholder}…`}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
<CommandList className="max-h-[min(70dvh,32rem)] p-1">
|
||||
<CommandEmpty>No pages found.</CommandEmpty>
|
||||
{/*
|
||||
Three states, three sentences. "Nothing matches" while the book is
|
||||
still arriving is a lie that sends someone off to check whether the
|
||||
record exists at all.
|
||||
*/}
|
||||
<CommandEmpty>
|
||||
{records.isLoading
|
||||
? 'Searching accounts, deals and contracts…'
|
||||
: records.isUnavailable
|
||||
? `No pages match “${query.trim()}”.`
|
||||
: `Nothing matches “${query.trim()}”.`}
|
||||
</CommandEmpty>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group}>
|
||||
{index > 0 ? <CommandSeparator /> : null}
|
||||
<CommandGroup heading={group}>
|
||||
{destinations
|
||||
{pages
|
||||
.filter((destination) => (destination.group ?? 'Navigate') === group)
|
||||
.map((destination) => (
|
||||
<CommandItem
|
||||
@@ -104,7 +478,70 @@ export function CommandPalette({
|
||||
</CommandGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
{/*
|
||||
Records only once there is something to match on. With an empty query
|
||||
they would bury the navigation under sixty rows of book, which is the
|
||||
palette failing at the job it already did well.
|
||||
*/}
|
||||
{typing
|
||||
? RECORD_GROUPS.map(({ kind, heading, icon: Icon }) => {
|
||||
const matches = records.hits.filter((record) => record.kind === kind);
|
||||
const shown = matches.slice(0, ROWS_PER_GROUP);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
// No separator: cmdk hides those while a search is running, and
|
||||
// records only ever render while one is. The headings carry the
|
||||
// division on their own.
|
||||
<CommandGroup
|
||||
key={kind}
|
||||
heading={
|
||||
matches.length > shown.length
|
||||
? `${heading} · closest ${shown.length} of ${matches.length}`
|
||||
: heading
|
||||
}
|
||||
>
|
||||
{shown.map((record) => (
|
||||
<CommandItem
|
||||
key={`${kind}-${record.id}`}
|
||||
// The id keeps the value unique where two records share a
|
||||
// name; it is never rendered, and cannot widen the match
|
||||
// because these rows are pre-filtered above.
|
||||
value={`${record.haystack} ${record.id}`}
|
||||
className="min-h-11 items-start rounded-lg"
|
||||
onSelect={() => {
|
||||
navigate(record.to);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden className="mt-0.5 text-muted" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{record.name}</span>
|
||||
<span className="truncate text-xs text-muted">{record.meta}</span>
|
||||
</span>
|
||||
{record.trailing ? (
|
||||
<span className="nums mt-0.5 shrink-0 text-xs text-muted">
|
||||
{record.trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</CommandList>
|
||||
{/*
|
||||
Outside the list, so a failed fetch reads as a status rather than as a
|
||||
result. Silently returning pages only would leave someone convinced
|
||||
their customer is not in the book.
|
||||
*/}
|
||||
{typing && records.isIncomplete ? (
|
||||
<p className="border-t border-border px-3 py-2 text-xs text-muted">
|
||||
{records.isUnavailable
|
||||
? 'Record search is unavailable just now — pages only.'
|
||||
: 'Some records could not be searched, so this list may be incomplete.'}
|
||||
</p>
|
||||
) : null}
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
/**
|
||||
* The sortable, paginated, column-choosable table.
|
||||
*
|
||||
* It is deliberately the *only* table primitive: pages that hand-roll a
|
||||
* `<table>` get none of this, and the divergence shows — Margin's own markup
|
||||
* cannot sort or paginate, and within Accounts the desktop empty state is a
|
||||
* bare row of text while the phone layout renders a full EmptyState for the
|
||||
* same condition. Loading, error and empty are therefore states this component
|
||||
* owns rather than states each adopting page invents, so the next page to move
|
||||
* across brings its query straight here.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -12,7 +23,16 @@ import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,7 +43,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui';
|
||||
import { EmptyState, Input, Skeleton } from '@/components/ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,7 +64,24 @@ import {
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
/** One line of text for the empty case. `empty` supersedes it when given. */
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* The empty state in full — an `EmptyState` with an icon and, where there is
|
||||
* one, the action that would fill the table. Prefer it to `emptyMessage`:
|
||||
* a table that is empty because nobody has created a record yet should say
|
||||
* so and offer the way out, not print "No results." at someone.
|
||||
*/
|
||||
empty?: ReactNode;
|
||||
/**
|
||||
* The first load only — react-query's `isLoading`, never `isFetching`.
|
||||
* Blanking populated rows into skeletons on every background refetch is how
|
||||
* a table flickers under the reader's cursor.
|
||||
*/
|
||||
loading?: boolean;
|
||||
error?: Error | null;
|
||||
/** Wired to a retry button on the error state; omitted means no button. */
|
||||
onRetry?: () => void;
|
||||
filterColumn?: string;
|
||||
filterPlaceholder?: string;
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
@@ -54,6 +91,10 @@ export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage = 'No results.',
|
||||
empty,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
filterColumn,
|
||||
filterPlaceholder = 'Filter results',
|
||||
initialColumnVisibility = {},
|
||||
@@ -78,6 +119,16 @@ export function DataTable<TData, TValue>({
|
||||
});
|
||||
const activeFilter = filterColumn ? table.getColumn(filterColumn) : undefined;
|
||||
const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide());
|
||||
const rows = table.getRowModel().rows;
|
||||
const columnCount = Math.max(table.getVisibleLeafColumns().length, 1);
|
||||
// Precedence matters: an error that arrives mid-load must not be reported as
|
||||
// "no results", which is a true statement and a false explanation.
|
||||
const state = error ? 'error' : loading ? 'loading' : rows.length ? 'rows' : 'empty';
|
||||
// Only when there is no dataset at all. Disabling the filter whenever the
|
||||
// table looks empty would trap the reader inside a search that matched
|
||||
// nothing, with no way to clear it.
|
||||
const controlsDisabled = state === 'loading' || state === 'error';
|
||||
const filterValue = (activeFilter?.getFilterValue() as string | undefined) ?? '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -85,10 +136,11 @@ export function DataTable<TData, TValue>({
|
||||
{activeFilter ? (
|
||||
<Input
|
||||
type="search"
|
||||
value={(activeFilter.getFilterValue() as string | undefined) ?? ''}
|
||||
value={filterValue}
|
||||
onChange={(event) => activeFilter.setFilterValue(event.target.value)}
|
||||
placeholder={filterPlaceholder}
|
||||
aria-label={filterPlaceholder}
|
||||
disabled={controlsDisabled}
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
) : (
|
||||
@@ -96,7 +148,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="tap sm:ml-auto">
|
||||
<Button variant="outline" className="tap sm:ml-auto" disabled={controlsDisabled}>
|
||||
<SlidersHorizontal data-icon="inline-start" aria-hidden />
|
||||
Columns
|
||||
</Button>
|
||||
@@ -135,37 +187,101 @@ export function DataTable<TData, TValue>({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={Math.max(table.getVisibleLeafColumns().length, 1)} className="h-28 text-center text-muted">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{state === 'rows'
|
||||
? rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
|
||||
{/* Skeletons in the real grid, not one bar over the whole table:
|
||||
the column widths the reader is about to get are part of the
|
||||
answer, and settling into them costs nothing to show. */}
|
||||
{state === 'loading'
|
||||
? Array.from({ length: SKELETON_ROWS }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} aria-hidden>
|
||||
{Array.from({ length: columnCount }, (_, cell) => (
|
||||
<TableCell key={cell}>
|
||||
<Skeleton className="h-4 w-full max-w-[12rem]" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: null}
|
||||
|
||||
{state === 'error' ? (
|
||||
<MessageRow colSpan={columnCount}>
|
||||
<EmptyState
|
||||
icon={<TriangleAlert aria-hidden />}
|
||||
title="Results unavailable"
|
||||
description={error?.message}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" className="tap" onClick={onRetry}>
|
||||
<RefreshCw data-icon="inline-start" aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</MessageRow>
|
||||
) : null}
|
||||
|
||||
{state === 'empty' ? (
|
||||
<MessageRow colSpan={columnCount}>
|
||||
{/* A filter that matched nothing is not an empty table, and
|
||||
telling someone to create their first record when they have
|
||||
simply mistyped a search is how a product loses trust. */}
|
||||
{data.length > 0 ? (
|
||||
<EmptyState
|
||||
title="No results match"
|
||||
description="Nothing here matches the current filter."
|
||||
action={
|
||||
filterValue ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="tap"
|
||||
onClick={() => activeFilter?.setFilterValue('')}
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
(empty ?? <p className="py-10 text-sm text-muted">{emptyMessage}</p>)
|
||||
)}
|
||||
</MessageRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* "0 results · Page 1 of 1" while a request is still in flight is a
|
||||
count of something nobody has counted yet. */}
|
||||
<p className="text-sm text-muted" aria-live="polite">
|
||||
{table.getFilteredRowModel().rows.length} result
|
||||
{table.getFilteredRowModel().rows.length === 1 ? '' : 's'} · Page{' '}
|
||||
{table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
|
||||
{state === 'loading'
|
||||
? 'Loading results…'
|
||||
: state === 'error'
|
||||
? 'Results could not be loaded.'
|
||||
: `${table.getFilteredRowModel().rows.length} result${
|
||||
table.getFilteredRowModel().rows.length === 1 ? '' : 's'
|
||||
} · Page ${table.getState().pagination.pageIndex + 1} of ${Math.max(
|
||||
table.getPageCount(),
|
||||
1,
|
||||
)}`}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
||||
<Select
|
||||
value={String(table.getState().pagination.pageSize)}
|
||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
||||
disabled={controlsDisabled}
|
||||
>
|
||||
<SelectTrigger className="h-11 w-[7.5rem]" aria-label="Rows per page">
|
||||
<SelectValue />
|
||||
@@ -235,6 +351,26 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
);
|
||||
}
|
||||
|
||||
/** Enough to read as a table settling in, few enough not to imply a page size. */
|
||||
const SKELETON_ROWS = 5;
|
||||
|
||||
/**
|
||||
* One cell spanning the grid, for the states that replace the rows.
|
||||
*
|
||||
* `h-40` rather than the rows' natural height so that loading, empty and error
|
||||
* occupy roughly the same space: a table that changes height as it resolves
|
||||
* pushes whatever sits beneath it around the screen.
|
||||
*/
|
||||
function MessageRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {
|
||||
return (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={colSpan} className="h-40 p-0 text-center align-middle">
|
||||
<div className="flex items-center justify-center">{children}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function columnLabel(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
CircleStop,
|
||||
Database,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
Send,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Bot, CircleStop, Database, Loader2, MessageCircleMore, Send, Sparkles, XCircle } from 'lucide-react';
|
||||
import { get } from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import {
|
||||
streamPiggyChat,
|
||||
PIGGY_MESSAGE_MAX_LENGTH,
|
||||
isRetryable,
|
||||
usePiggyConversation,
|
||||
type PiggyChatContext,
|
||||
type PiggyChatEvent,
|
||||
type PiggyChatTurn,
|
||||
// Aliased because the transcript viewport below is also called
|
||||
// `PiggyConversation`: one is the state a panel is driven by, the other is
|
||||
// the element it is drawn in, and they meet in this file only.
|
||||
type PiggyConversation as PiggyConversationState,
|
||||
type PiggyStatus,
|
||||
type TranscriptMessage,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } from '@/lib/piggy-suggestions';
|
||||
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
|
||||
import { PiggyMessageActions } from './piggy/message-actions';
|
||||
import { PiggyReasoning } from './piggy/reasoning';
|
||||
import { PiggyResponse } from './piggy/response';
|
||||
import { PiggyToolStep } from './piggy/tool';
|
||||
import { Badge, Button, EmptyState, cn } from './ui';
|
||||
import {
|
||||
Drawer,
|
||||
@@ -39,23 +39,13 @@ import {
|
||||
} from './ui/sheet';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
interface ToolStep {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
state: 'running' | 'succeeded' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TranscriptMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
tools?: ToolStep[];
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
/**
|
||||
* The composer starts counting down only near the cap. A counter that is
|
||||
* always on reads as a limit the user is expected to work within; one that
|
||||
* appears in the last few hundred characters reads as a warning, which is what
|
||||
* it is — past 4,000 the relay answers 400 and the send is lost.
|
||||
*/
|
||||
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;
|
||||
|
||||
export function PiggyAskButton({
|
||||
context,
|
||||
@@ -97,9 +87,28 @@ export function PiggyAskButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The height the workspace panel and its placeholder both take.
|
||||
*
|
||||
* Named once because the two must agree: a placeholder of a different height
|
||||
* makes the page jump the moment the status query answers. It is sized to land
|
||||
* just inside the page rather than just outside it — the panel scrolls, so a
|
||||
* page scrolling behind it means following an answer moves two things at once
|
||||
* and the composer drifts under the fold. Below `lg` the subtraction is larger:
|
||||
* the phone layout stacks the page header above and the tab bar below.
|
||||
*
|
||||
* The floor yields to the viewport rather than being a flat 32rem, because a
|
||||
* flat one is taller than a phone held sideways: at 852x393 the panel was 512px
|
||||
* inside a 393px window, which put the composer 230px below the fold on a page
|
||||
* whose only control is the composer. `min()` keeps the comfortable floor
|
||||
* everywhere it fits and stops claiming space that does not exist.
|
||||
*/
|
||||
const WORKSPACE_HEIGHT =
|
||||
'h-[calc(100dvh-19rem)] min-h-[min(32rem,calc(100dvh-11rem))] lg:h-[calc(100dvh-13rem)]';
|
||||
|
||||
export function PiggyChatWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
if (status.isLoading) return <div className="h-96 animate-pulse rounded-xl bg-surface-2" />;
|
||||
if (status.isLoading) return <div className={cn(WORKSPACE_HEIGHT, 'animate-pulse rounded-xl bg-surface-2')} />;
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -113,7 +122,7 @@ export function PiggyChatWorkspace() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
|
||||
return <PiggyChatPanel className={cn(WORKSPACE_HEIGHT, 'rounded-xl border border-border bg-surface')} />;
|
||||
}
|
||||
|
||||
export function ResponsivePiggyChat({
|
||||
@@ -131,6 +140,11 @@ export function ResponsivePiggyChat({
|
||||
// which meant a 900px tablet got the desktop side sheet sliding in behind
|
||||
// the phone tab bar it was still showing.
|
||||
const desktop = !useIsMobile();
|
||||
// Held here, one level above the overlay, because both the Sheet and the
|
||||
// Drawer unmount their children when they close. With the thread inside,
|
||||
// dismissing the overlay for two seconds to look at the record underneath
|
||||
// destroyed the conversation, the draft and any answer still streaming.
|
||||
const conversation = usePiggyConversation({ context, initialPrompt });
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -139,7 +153,7 @@ export function ResponsivePiggyChat({
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
<PiggyChatPanel conversation={conversation} context={context} autoFocusComposer className="min-h-0 flex-1" />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -151,7 +165,9 @@ export function ResponsivePiggyChat({
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
|
||||
{/* No autofocus on the phone: focusing the composer raises the keyboard
|
||||
over most of the drawer before the user has read anything. */}
|
||||
<PiggyChatPanel conversation={conversation} context={context} className="min-h-0 flex-1" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
@@ -171,117 +187,188 @@ export function PiggyChatPanel({
|
||||
initialPrompt = '',
|
||||
className,
|
||||
compact = false,
|
||||
conversation,
|
||||
autoFocusComposer = false,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* A conversation owned by something that outlives this panel. The overlays
|
||||
* pass one because they unmount their children on close; the dock and the
|
||||
* workspace page stay mounted and let the panel keep its own.
|
||||
*/
|
||||
conversation?: PiggyConversationState;
|
||||
autoFocusComposer?: boolean;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [draft, setDraft] = useState(initialPrompt);
|
||||
const [running, setRunning] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
// Called unconditionally — hooks must be — and then ignored when a
|
||||
// conversation was handed in. It holds no resources until something is sent.
|
||||
const own = usePiggyConversation({ context, initialPrompt });
|
||||
const { messages, draft, setDraft, running, send, stop, retry } = conversation ?? own;
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// The dock keeps one. Nothing fits two on a line at 22rem, so the second is a
|
||||
// whole extra row of chrome taken off the shortest transcript of the three.
|
||||
const followUps = messages.length
|
||||
? piggyFollowUps(context, userQuestions(messages)).slice(0, compact ? 1 : PIGGY_FOLLOW_UP_COUNT)
|
||||
: [];
|
||||
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: running ? 'auto' : 'smooth' }), [messages, running]);
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const send = async () => {
|
||||
const message = draft.trim();
|
||||
if (!message || running) return;
|
||||
const user: TranscriptMessage = { id: crypto.randomUUID(), role: 'user', content: message };
|
||||
const assistantId = crypto.randomUUID();
|
||||
const history: PiggyChatTurn[] = messages
|
||||
.filter((entry) => entry.content.trim())
|
||||
.slice(-20)
|
||||
.map((entry) => ({ role: entry.role, content: entry.content }));
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
user,
|
||||
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
|
||||
]);
|
||||
setDraft('');
|
||||
setRunning(true);
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId ? applyEvent(entry, event) : entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
setMessages((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === assistantId
|
||||
? { ...entry, pending: false, error: error instanceof Error ? error.message : 'Piggy chat failed.' }
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!autoFocusComposer) return;
|
||||
const composer = composerRef.current;
|
||||
if (!composer) return;
|
||||
// Radix moves focus to the first tabbable element in the sheet — its own
|
||||
// close button — from a layout effect that runs after this one, so a
|
||||
// synchronous focus here is immediately undone. A frame later it is not.
|
||||
// Without this, "Ask Piggy" opened with a prefilled prompt and put the
|
||||
// caret nowhere.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
composer.focus();
|
||||
composer.setSelectionRange(composer.value.length, composer.value.length);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [autoFocusComposer]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{/* The viewport owns the scrolling, the log role and the follow-the-tail
|
||||
behaviour. There is deliberately no scroll effect left in this file:
|
||||
the `scrollIntoView` it replaced fired once per streamed token, which
|
||||
made re-reading an earlier answer mid-stream impossible and dragged
|
||||
the page behind the dock down with it. Gutters go on the scrollport
|
||||
so they scroll with the transcript rather than fencing it. */}
|
||||
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(context && context.type !== 'page'
|
||||
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
|
||||
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
|
||||
).map((suggestion) => (
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => setDraft(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PiggyStarters compact={compact} context={context} onAsk={send} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => <ChatMessage key={message.id} message={message} compact={compact} />)}
|
||||
<div ref={bottomRef} />
|
||||
<div
|
||||
// The column is capped at a reading measure rather than filling the
|
||||
// page: at 1440 the workspace panel is over a thousand pixels wide,
|
||||
// and a markdown answer set across all of it is a wall.
|
||||
// The busy state that holds the announcement back belongs on the
|
||||
// live region root, which is the viewport above, not on this column.
|
||||
className={cn('mx-auto flex w-full max-w-3xl flex-col', compact ? 'gap-5' : 'gap-6')}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
compact={compact}
|
||||
onRetry={isRetryable(message) && !running ? () => retry(message.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PiggyConversationScrollButton />
|
||||
</PiggyConversation>
|
||||
|
||||
<form className={cn('border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); void send(); }}>
|
||||
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); send(); }}>
|
||||
{followUps.length ? (
|
||||
// Wrapped, not scrolled sideways. A row of whole questions is wider
|
||||
// than every surface but the full page, and a chip sliced off by the
|
||||
// panel edge reads as a rendering fault — where a second line reads
|
||||
// as a second suggestion.
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
|
||||
{followUps.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
// Dead rather than absent while a turn runs: `send` refuses
|
||||
// anything mid-stream, and a row that vanishes and returns
|
||||
// moves the composer under the user's thumb.
|
||||
disabled={running}
|
||||
// Each chip is one line whatever the width, so the row can only
|
||||
// ever be as tall as the number of suggestions.
|
||||
title={suggestion}
|
||||
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
|
||||
onClick={() => send(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
|
||||
className="min-h-11 max-h-36 resize-none"
|
||||
placeholder="Ask about capacity, margin, paper or next actions…"
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={() => abortRef.current?.abort()}><CircleStop aria-hidden /></Button>
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><CircleStop aria-hidden /></Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[11px] leading-4 text-muted">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
<div className="mt-2 flex items-baseline gap-2 text-[11px] leading-4 text-muted">
|
||||
<p className="flex-1 text-center">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
{/* No live region: this changes on every keystroke, and the cap is
|
||||
already announced from the textarea's own `maxLength`. */}
|
||||
{draft.length >= COUNTER_VISIBLE_FROM ? (
|
||||
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
|
||||
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The blank transcript.
|
||||
*
|
||||
* The openers come from `piggySuggestions`, which chooses them by the one read
|
||||
* tool this context resolves to rather than by what the page is called — so
|
||||
* every line offered here is one Piggy can actually ground. The dock takes
|
||||
* three of them: at 22rem each opener wraps to two lines, and a fourth turns a
|
||||
* quick way in into a page of text to read before typing.
|
||||
*/
|
||||
function PiggyStarters({
|
||||
context,
|
||||
compact,
|
||||
onAsk,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
compact: boolean;
|
||||
onAsk: (text: string) => void;
|
||||
}) {
|
||||
const suggestions = piggySuggestions(context);
|
||||
return (
|
||||
// `flex-1`, not `h-full`: the conversation's content element is sized by its
|
||||
// children, so a percentage height here resolves to nothing.
|
||||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center text-center">
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
|
||||
// Sends rather than fills the composer. Filling it looked like
|
||||
// nothing had happened, so the chip read as a dead control.
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the user has already asked, so a follow-up chip cannot offer back a
|
||||
* question that is sitting in the transcript above it. */
|
||||
function userQuestions(messages: TranscriptMessage[]): string[] {
|
||||
return messages.filter((message) => message.role === 'user').map((message) => message.content);
|
||||
}
|
||||
|
||||
/** A page context has no id and its `type` is the literal 'page', which reads
|
||||
* as nothing useful in a badge — show the route the dock is following. */
|
||||
function contextLabel(context: PiggyChatContext): string {
|
||||
@@ -290,54 +377,76 @@ function contextLabel(context: PiggyChatContext): string {
|
||||
return context.type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function ChatMessage({ message, compact = false }: { message: TranscriptMessage; compact?: boolean }) {
|
||||
function ChatMessage({
|
||||
message,
|
||||
compact = false,
|
||||
onRetry,
|
||||
}: {
|
||||
message: TranscriptMessage;
|
||||
compact?: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
if (message.role === 'user') {
|
||||
return <div className={cn('ml-auto rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'max-w-[94%] px-3' : 'max-w-[88%] px-4')}><p className="whitespace-pre-wrap">{message.content}</p></div>;
|
||||
return (
|
||||
<div className={cn('ml-auto flex flex-col items-end', compact ? 'max-w-[94%]' : 'max-w-[88%]')}>
|
||||
<div className={cn('rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'px-3' : 'px-4')}>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
{/* The question is still on screen after a failed send, so the user's
|
||||
words are never lost — but the bubble alone reads as sent. */}
|
||||
{message.failed ? <p className="mt-1 text-[11px] leading-4 text-muted">Not sent</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
|
||||
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.reasoning ? (
|
||||
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2 font-medium"><Brain aria-hidden /> Reasoning</summary>
|
||||
<p className="whitespace-pre-wrap px-3 pb-3">{message.reasoning}</p>
|
||||
</details>
|
||||
{/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
|
||||
on, and it is repeated here on purpose: the footer marks itself, so
|
||||
without this the only way to find Copy is to sweep the pointer across
|
||||
the blank strip the hidden buttons occupy. A named group matches on
|
||||
any hovered ancestor, so hovering the answer reveals them. */}
|
||||
<div className="group/actions min-w-0 flex-1">
|
||||
{/* Working, then evidence, then the answer, then what the answer cost.
|
||||
Everything above the answer is deliberately smaller and quieter than
|
||||
it: this is a chain of custody, and the reader came for the last
|
||||
link in it. `PiggyReasoning` draws nothing when there is nothing to
|
||||
show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
|
||||
<PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
|
||||
{message.tools?.length ? (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{message.tools.map((tool) => (
|
||||
<PiggyToolStep key={tool.id} step={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.tools?.length ? <ToolTimeline tools={message.tools} /> : null}
|
||||
{message.content ? <p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p> : null}
|
||||
{message.pending && !message.content ? <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div> : null}
|
||||
{message.error ? <div className="mt-2 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
{message.content ? <PiggyResponse content={message.content} /> : null}
|
||||
{/* Only while the turn has produced nothing at all. Once a tool chip or
|
||||
the reasoning panel is on screen, the turn is visibly working and a
|
||||
second spinner saying so is noise. */}
|
||||
{message.pending && !message.content && !message.tools?.length && !message.reasoning?.trim() ? (
|
||||
<div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div>
|
||||
) : null}
|
||||
{/* The state chip in the footer below names a stopped or truncated
|
||||
turn. An error is different: it carries a sentence the chip cannot,
|
||||
and it is the one thing here allowed to be loud. */}
|
||||
{message.error ? <div className="mt-3 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
|
||||
<PiggyMessageActions message={message} onRetry={onRetry} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolTimeline({ tools }: { tools: ToolStep[] }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
{tools.map((tool) => (
|
||||
<details key={tool.id} className="rounded-lg border border-border text-xs">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2">
|
||||
{tool.state === 'running' ? <Loader2 className="animate-spin text-muted" aria-hidden /> : tool.state === 'succeeded' ? <CheckCircle2 className="text-positive" aria-hidden /> : <XCircle className="text-danger" aria-hidden />}
|
||||
<span className="font-medium">{toolLabel(tool.name)}</span>
|
||||
<span className="ml-auto text-muted">{tool.state === 'running' ? 'Running' : tool.state === 'succeeded' ? 'Complete' : 'Failed'}</span>
|
||||
</summary>
|
||||
<pre className="overflow-x-auto border-t border-border p-3 text-[11px] text-muted">{tool.error ?? JSON.stringify(tool.arguments, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
|
||||
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') return { ...message, tools: [...(message.tools ?? []), { id: event.id, name: event.name, arguments: event.arguments, state: 'running' }] };
|
||||
if (event.type === 'tool_result') return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', error: event.error } : tool) };
|
||||
if (event.type === 'done') return { ...message, pending: false };
|
||||
if (event.type === 'error') return { ...message, pending: false, error: event.message };
|
||||
return message;
|
||||
/**
|
||||
* Whether the reasoning panel should read as live.
|
||||
*
|
||||
* The stream has no event for "thinking finished", but the model writes its
|
||||
* scratch work before its answer — so the first content token is the end of the
|
||||
* thinking, and waiting for the turn to settle instead would leave the panel
|
||||
* open, uncollapsed and unmeasured underneath the answer it preceded.
|
||||
*/
|
||||
function isThinking(message: TranscriptMessage): boolean {
|
||||
return Boolean(message.pending && message.reasoning?.trim() && !message.content);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +458,3 @@ function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): Transcri
|
||||
export function usePiggyStatus() {
|
||||
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
|
||||
}
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@ import {
|
||||
CUSTOMER_SEGMENTS,
|
||||
DEMAND_STAGE_LABELS,
|
||||
DEMAND_STAGES,
|
||||
GPU_SOCKETS,
|
||||
INTERCONNECT_TYPES,
|
||||
PRODUCT_LINES,
|
||||
SECURITY_TIERS,
|
||||
SUPPLIER_TYPES,
|
||||
SUPPLY_STAGE_LABELS,
|
||||
SUPPLY_STAGES,
|
||||
type AccountSide,
|
||||
type ActivityType,
|
||||
type Team,
|
||||
} from '@pig/core';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { LoaderCircle, Lock } from 'lucide-react';
|
||||
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
@@ -47,8 +51,8 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ApiError, get, patch, post } from '@/lib/api';
|
||||
import { can, type PermissionIdentity } from '@/lib/permissions';
|
||||
import { ApiError, compactNumber, get, patch, post, unitPrice } from '@/lib/api';
|
||||
import { can, canAny, type PermissionIdentity } from '@/lib/permissions';
|
||||
|
||||
export interface AccountRecord {
|
||||
id: string;
|
||||
@@ -159,10 +163,26 @@ const optionalNonnegativeNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
'Enter zero or a positive number.',
|
||||
);
|
||||
const optionalProbability = z.string().refine(
|
||||
const optionalPercentage = z.string().refine(
|
||||
(value) => value === '' || (Number(value) >= 0 && Number(value) <= 100),
|
||||
'Use a percentage from 0 to 100.',
|
||||
);
|
||||
const requiredPositiveNumber = z.string().refine(
|
||||
(value) => Number.isFinite(Number(value)) && Number(value) > 0,
|
||||
'Enter a number greater than zero.',
|
||||
);
|
||||
const requiredNonnegativeNumber = z.string().refine(
|
||||
(value) => Number.isFinite(Number(value)) && Number(value) >= 0,
|
||||
'Enter zero or a positive amount.',
|
||||
);
|
||||
const requiredWholeNumber = z.string().refine(
|
||||
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
|
||||
'Enter a whole number greater than zero.',
|
||||
);
|
||||
const optionalWholeNumber = z.string().refine(
|
||||
(value) => value === '' || (Number.isInteger(Number(value)) && Number(value) >= 0),
|
||||
'Enter a whole number of days or leave it blank.',
|
||||
);
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required.'),
|
||||
@@ -209,7 +229,7 @@ const demandFormSchema = z.object({
|
||||
tcv: optionalNonnegativeNumber,
|
||||
currency: z.string().trim().length(3, 'Use a three-letter currency code.'),
|
||||
termMonths: optionalPositiveNumber,
|
||||
probability: optionalProbability,
|
||||
probability: optionalPercentage,
|
||||
expectedCloseDate: z.string(),
|
||||
closedReason: z.string(),
|
||||
msaExecuted: z.boolean(),
|
||||
@@ -237,6 +257,112 @@ const supplyFormSchema = z.object({
|
||||
});
|
||||
type SupplyForm = z.infer<typeof supplyFormSchema>;
|
||||
|
||||
/**
|
||||
* Hours between two `datetime-local` values, or null while either is unset or
|
||||
* inverted. Derived from the same instants that get POSTed, so the envelope
|
||||
* shown to the buyer cannot disagree with the one the server checks — including
|
||||
* across a daylight-saving boundary, where a 90-day block is not 2,160 hours.
|
||||
*/
|
||||
function windowHours(startsAt: string, endsAt: string): number | null {
|
||||
const start = new Date(startsAt).getTime();
|
||||
const end = new Date(endsAt).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
|
||||
return (end - start) / 3_600_000;
|
||||
}
|
||||
|
||||
/** The most GPU-hours a flat (unshaped) block of this size can hold. */
|
||||
function flatEnvelopeGpuHours(startsAt: string, endsAt: string, gpuCount: string): number | null {
|
||||
const hours = windowHours(startsAt, endsAt);
|
||||
const count = Number(gpuCount);
|
||||
if (hours === null || !Number.isFinite(count) || count <= 0) return null;
|
||||
return hours * count;
|
||||
}
|
||||
|
||||
const commitmentFormSchema = z
|
||||
.object({
|
||||
accountId: z.string().uuid('Select a supplier account.'),
|
||||
supplyDealId: z.string(),
|
||||
name: z.string().trim().min(1, 'Name the commitment.').max(200, 'Keep the name under 200 characters.'),
|
||||
gpuType: z.string().trim().min(1, 'GPU type is required.'),
|
||||
socket: z.string(),
|
||||
gpuCount: requiredWholeNumber,
|
||||
interconnectType: z.enum(INTERCONNECT_TYPES),
|
||||
securityTier: z.enum(SECURITY_TIERS),
|
||||
startsAt: z.string().min(1, 'Start is required.'),
|
||||
endsAt: z.string().min(1, 'End is required.'),
|
||||
totalGpuHours: requiredPositiveNumber,
|
||||
costPerGpuHour: requiredNonnegativeNumber,
|
||||
currency: z.string().trim().length(3, 'Use a three-letter currency code.'),
|
||||
isContiguous: z.boolean(),
|
||||
oversubscriptionPct: z.string().refine(
|
||||
(value) => value === '' || (Number(value) >= 0 && Number(value) <= 1000),
|
||||
'Use a percentage from 0 to 1000.',
|
||||
),
|
||||
minimumSpend: optionalNonnegativeNumber,
|
||||
takeOrPayFloorPct: optionalPercentage,
|
||||
prepaidPct: optionalPercentage,
|
||||
prepaidAmount: optionalNonnegativeNumber,
|
||||
noticeDays: optionalWholeNumber,
|
||||
isAutoRenew: z.boolean(),
|
||||
notes: z.string().max(10_000, 'Keep notes under 10,000 characters.'),
|
||||
})
|
||||
.superRefine((values, context) => {
|
||||
if (values.startsAt && values.endsAt && windowHours(values.startsAt, values.endsAt) === null) {
|
||||
context.addIssue({ code: 'custom', path: ['endsAt'], message: 'End must be after start.' });
|
||||
}
|
||||
const envelope = flatEnvelopeGpuHours(values.startsAt, values.endsAt, values.gpuCount);
|
||||
const total = Number(values.totalGpuHours);
|
||||
// The server refuses this outright, and it is the easy mistake to make:
|
||||
// contracted GPU-hours are a total for the term, not a per-day figure.
|
||||
if (envelope !== null && total - envelope > 1e-7) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalGpuHours'],
|
||||
message: `${values.gpuCount} GPUs over this window hold at most ${Math.floor(envelope).toLocaleString()} GPU-hours.`,
|
||||
});
|
||||
}
|
||||
if (Math.abs(total * 100 - Math.round(total * 100)) > 1e-7) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['totalGpuHours'],
|
||||
message: 'GPU-hours may have at most two decimal places.',
|
||||
});
|
||||
}
|
||||
});
|
||||
type CommitmentForm = z.infer<typeof commitmentFormSchema>;
|
||||
|
||||
/**
|
||||
* The types a person logs by hand.
|
||||
*
|
||||
* The rest of `ACTIVITY_TYPES` are written by the system — a stage change, a
|
||||
* contract event, an agent's action — and offering them here would let a typed
|
||||
* note claim machine provenance in a timeline that is read as an audit trail.
|
||||
*/
|
||||
const LOGGABLE_ACTIVITY_TYPES = [
|
||||
'call',
|
||||
'meeting',
|
||||
'email',
|
||||
'note',
|
||||
] as const satisfies readonly ActivityType[];
|
||||
|
||||
const ACTIVITY_TYPE_HINTS: Record<(typeof LOGGABLE_ACTIVITY_TYPES)[number], string> = {
|
||||
call: 'What was said, and what was agreed.',
|
||||
meeting: 'Who attended, and what changed as a result.',
|
||||
email: 'Paste the substance, not the thread.',
|
||||
note: 'Something learned that belongs on the record.',
|
||||
};
|
||||
|
||||
const activityFormSchema = z.object({
|
||||
type: z.enum(LOGGABLE_ACTIVITY_TYPES),
|
||||
accountId: z.string().uuid('Select the account this belongs to.'),
|
||||
relatedDeal: z.string(),
|
||||
contactId: z.string(),
|
||||
subject: z.string().trim().min(1, 'Give it a subject.').max(200, 'Keep the subject under 200 characters.'),
|
||||
body: z.string().max(8_000, 'Keep the detail under 8,000 characters.'),
|
||||
occurredAt: z.string().min(1, 'Record when it happened.'),
|
||||
});
|
||||
type ActivityForm = z.infer<typeof activityFormSchema>;
|
||||
|
||||
const label = (value: string) => value.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
|
||||
const blankToNull = (value: string) => value.trim() || null;
|
||||
const optionalNumber = (value: string) => value === '' ? null : Number(value);
|
||||
@@ -251,6 +377,39 @@ function canWriteSide(identity: PermissionIdentity | undefined, side: AccountSid
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `requireSidePermission` on the server: an activity is a supply-side
|
||||
* or demand-side event, and a `both` account admits either. Asking the same
|
||||
* question here means the control is absent rather than answering 403.
|
||||
*
|
||||
* Exported so a page gating a "Log activity" affordance on a specific account
|
||||
* asks exactly the question the server will ask about that account.
|
||||
*/
|
||||
export function canLogAgainstSide(identity: PermissionIdentity | undefined, side: AccountSide): boolean {
|
||||
const teams: Team[] = side === 'both' ? ['supply', 'demand'] : [side];
|
||||
return teams.some((team) => can(identity, 'activity:write', team));
|
||||
}
|
||||
|
||||
/**
|
||||
* `datetime-local` carries no time zone: it wants wall-clock time. Subtracting
|
||||
* the offset before slicing is what stops the field opening hours adrift of
|
||||
* the clock on the wall, which is the one thing a logged call must match.
|
||||
*/
|
||||
function localDateTimeValue(date: Date): string {
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single "related deal" control writes one of two columns. Encoding the side
|
||||
* into the option value keeps that a choice rather than two selects the user
|
||||
* could fill in contradictory ways.
|
||||
*/
|
||||
function dealReference(value: string): { demandDealId?: string; supplyDealId?: string } {
|
||||
const [side, id] = value.split(':');
|
||||
if (!id) return {};
|
||||
return side === 'supply' ? { supplyDealId: id } : { demandDealId: id };
|
||||
}
|
||||
|
||||
function accountDefaults(record?: AccountRecord | null): AccountForm {
|
||||
return {
|
||||
name: record?.name ?? '',
|
||||
@@ -582,6 +741,249 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
|
||||
);
|
||||
}
|
||||
|
||||
export interface CapacityCommitmentRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
totalGpuHours: string | number;
|
||||
costPerGpuHourCents: number;
|
||||
}
|
||||
|
||||
interface CreateSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
identity?: PermissionIdentity;
|
||||
defaultAccountId?: string;
|
||||
}
|
||||
|
||||
function commitmentDefaults(accountId?: string): CommitmentForm {
|
||||
return {
|
||||
accountId: accountId ?? '', supplyDealId: '', name: '', gpuType: '', socket: '', gpuCount: '',
|
||||
interconnectType: 'Unknown', securityTier: 'secure_cloud', startsAt: '', endsAt: '', totalGpuHours: '',
|
||||
costPerGpuHour: '', currency: 'USD', isContiguous: true, oversubscriptionPct: '', minimumSpend: '',
|
||||
takeOrPayFloorPct: '', prepaidPct: '', prepaidAmount: '', noticeDays: '', isAutoRenew: false, notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording capacity we have committed to buy.
|
||||
*
|
||||
* This is the root record of the supply side: availability, the matcher, every
|
||||
* margin figure and the idle-capacity alerts are all derived from these blocks,
|
||||
* so until one exists the product has nothing to show. `commitment:write` is
|
||||
* supply-team and lead-and-above by policy, which is why the team is named here
|
||||
* rather than inferred — see TEAM_CAPABILITY_RULES.
|
||||
*/
|
||||
export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId }: CreateSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const writable = can(identity, 'commitment:write', 'supply');
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'commitment-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts?side=supply'), enabled: open });
|
||||
const { data: supplyBoard } = useQuery({ queryKey: ['/api/deals/supply'], queryFn: () => get<{ deals: { deal: SupplyDealRecord }[] }>('/api/deals/supply'), enabled: open });
|
||||
const form = useForm<CommitmentForm>({ resolver: zodResolver(commitmentFormSchema), defaultValues: commitmentDefaults(defaultAccountId) });
|
||||
useEffect(() => { if (open) form.reset(commitmentDefaults(defaultAccountId)); }, [defaultAccountId, form, open]);
|
||||
const accountId = form.watch('accountId');
|
||||
const dealOptions = (supplyBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId);
|
||||
const envelope = flatEnvelopeGpuHours(form.watch('startsAt'), form.watch('endsAt'), form.watch('gpuCount'));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: CommitmentForm) =>
|
||||
post<CapacityCommitmentRecord>('/api/commitments', {
|
||||
accountId: values.accountId, supplyDealId: blankToNull(values.supplyDealId), name: values.name.trim(),
|
||||
gpuType: values.gpuType.trim(), socket: blankToNull(values.socket), gpuCount: Number(values.gpuCount),
|
||||
interconnectType: values.interconnectType, securityTier: values.securityTier,
|
||||
startsAt: new Date(values.startsAt).toISOString(), endsAt: new Date(values.endsAt).toISOString(),
|
||||
totalGpuHours: Number(values.totalGpuHours), costPerGpuHourCents: Math.round(Number(values.costPerGpuHour) * 100),
|
||||
currency: values.currency.toUpperCase(), isContiguous: values.isContiguous,
|
||||
oversubscriptionPct: values.oversubscriptionPct === '' ? 0 : Number(values.oversubscriptionPct),
|
||||
minimumSpendCents: cents(values.minimumSpend), takeOrPayFloorPct: optionalNumber(values.takeOrPayFloorPct),
|
||||
prepaidPct: optionalNumber(values.prepaidPct), prepaidAmountCents: cents(values.prepaidAmount),
|
||||
noticeDays: optionalNumber(values.noticeDays), isAutoRenew: values.isAutoRenew, notes: blankToNull(values.notes),
|
||||
}),
|
||||
onSuccess: async (commitment) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['availability'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['commitments'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['margin'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] }),
|
||||
]);
|
||||
// The block is only worth recording because it can now be sold, so say
|
||||
// that rather than "saved" — the seller's next move is the matcher.
|
||||
toast.success('Capacity commitment recorded', {
|
||||
description: `${compactNumber(Number(commitment.totalGpuHours))} GPU-hrs at ${unitPrice(commitment.costPerGpuHourCents)}/GPU-hr are now sellable and carried in margin.`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Supply · committed capacity" title="Record capacity commitment" description="Capacity we have contracted to buy. Availability, the matcher and every margin figure are derived from these blocks.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
{writable ? null : <PermissionNotice>Recording committed capacity needs supply-team lead access. A platform administrator can grant it.</PermissionNotice>}
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="accountId" label="Supplier account" className="sm:col-span-2" options={(accountData ?? []).map((account) => ({ value: account.id, label: account.name }))} />
|
||||
<SelectField control={form.control} name="supplyDealId" label="Originating supply deal" optional className="sm:col-span-2" options={dealOptions.map((row) => ({ value: row.deal.id, label: row.deal.name }))} />
|
||||
<TextField control={form.control} name="name" label="Commitment name" placeholder="CoreWeave H100 · Q4 block" className="sm:col-span-2" />
|
||||
{/* Hardware identifiers are case-sensitive upstream; correcting
|
||||
them for the user would produce silent mismatches. */}
|
||||
<TextField control={form.control} name="gpuType" label="GPU type" placeholder="H100_80GB" autoCapitalize="off" autoCorrect="off" spellCheck={false} />
|
||||
<TextField control={form.control} name="gpuCount" label="GPU count" inputMode="numeric" />
|
||||
<SelectField control={form.control} name="socket" label="Socket" optional options={GPU_SOCKETS.map((value) => ({ value, label: value }))} />
|
||||
<SelectField control={form.control} name="interconnectType" label="Interconnect" options={INTERCONNECT_TYPES.map((value) => ({ value, label: value }))} />
|
||||
<SelectField control={form.control} name="securityTier" label="Security tier" options={SECURITY_TIERS.map((value) => ({ value, label: label(value) }))} />
|
||||
<SwitchField control={form.control} name="isContiguous" label="Contiguous block" description="Not one GPU count split across halls." />
|
||||
</FieldGrid>
|
||||
<Section title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
|
||||
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
|
||||
<TextField control={form.control} name="totalGpuHours" label="Contracted GPU-hours" inputMode="decimal" description={envelope === null ? 'Set the window and GPU count to see the flat envelope.' : `A flat block this size holds ${compactNumber(envelope)} GPU-hrs at most.`} />
|
||||
<TextField control={form.control} name="costPerGpuHour" label="Cost $ / GPU-hr" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="currency" label="Currency" maxLength={3} />
|
||||
<TextField control={form.control} name="oversubscriptionPct" label="Oversubscription allowance (%)" inputMode="decimal" description="Leave blank unless the contract permits selling above the envelope." />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
<Section title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
|
||||
<FieldGrid>
|
||||
<TextField control={form.control} name="minimumSpend" label="Minimum spend" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="takeOrPayFloorPct" label="Take-or-pay floor (%)" inputMode="decimal" />
|
||||
<TextField control={form.control} name="prepaidPct" label="Prepaid share (%)" inputMode="decimal" />
|
||||
<TextField control={form.control} name="prepaidAmount" label="Prepaid amount" inputMode="decimal" prefix="$" />
|
||||
<TextField control={form.control} name="noticeDays" label="Notice to exit (days)" inputMode="numeric" />
|
||||
<SwitchField control={form.control} name="isAutoRenew" label="Auto-renews" description="Renewal alerting depends on this being honest." />
|
||||
<TextAreaField control={form.control} name="notes" label="Commercial notes" className="sm:col-span-2" description="Caveats a seller would need before promising this capacity." />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Record commitment" />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function activityDefaults(accountId?: string): ActivityForm {
|
||||
return {
|
||||
type: 'call', accountId: accountId ?? '', relatedDeal: '', contactId: '', subject: '', body: '',
|
||||
occurredAt: localDateTimeValue(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope `POST /api/activities` answers with. A synced event that was
|
||||
* already logged is a success with nothing inserted, so the row can be null —
|
||||
* and the field is optional here because the client must not break if that
|
||||
* response shape is ever tightened.
|
||||
*/
|
||||
interface LoggedActivity {
|
||||
activity: { id: string } | null;
|
||||
deduplicated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging what a person did.
|
||||
*
|
||||
* Every UI mutation already writes a derived note, so the timeline is never
|
||||
* frozen — but calls, meetings and emails are the entries a human recognises,
|
||||
* and until this existed they could only be written over MCP. `activity:write`
|
||||
* is held per team and checked twice on the server: once for the principal, and
|
||||
* once against the side of the account named here, which is why the account is
|
||||
* required rather than optional.
|
||||
*/
|
||||
export function LogActivitySheet({ open, onOpenChange, identity, defaultAccountId, defaultContactId }: CreateSheetProps & { defaultContactId?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: accountData } = useQuery({ queryKey: ['accounts', 'activity-record-options'], queryFn: () => get<AccountRecord[]>('/api/accounts'), enabled: open });
|
||||
const { data: contactRows } = useQuery({ queryKey: ['contacts', 'activity-record-options'], queryFn: () => get<ContactRow[]>('/api/contacts'), enabled: open });
|
||||
const { data: demandBoard } = useQuery({ queryKey: ['/api/deals/demand'], queryFn: () => get<{ deals: { deal: DemandDealRecord }[] }>('/api/deals/demand'), enabled: open });
|
||||
const { data: supplyBoard } = useQuery({ queryKey: ['/api/deals/supply'], queryFn: () => get<{ deals: { deal: SupplyDealRecord }[] }>('/api/deals/supply'), enabled: open });
|
||||
const form = useForm<ActivityForm>({ resolver: zodResolver(activityFormSchema), defaultValues: activityDefaults(defaultAccountId) });
|
||||
useEffect(() => {
|
||||
// Reset on open rather than on mount so the timestamp is the moment the
|
||||
// sheet was opened, not the moment the page was first rendered.
|
||||
if (open) form.reset({ ...activityDefaults(defaultAccountId), contactId: defaultContactId ?? '' });
|
||||
}, [defaultAccountId, defaultContactId, form, open]);
|
||||
|
||||
const loggableAccounts = useMemo(() => (accountData ?? []).filter((account) => canLogAgainstSide(identity, account.side)), [accountData, identity]);
|
||||
const accountId = form.watch('accountId');
|
||||
const type = form.watch('type');
|
||||
// Two questions, and both have to be asked. `canAny` covers the person who
|
||||
// holds the capability nowhere, whose account list is empty and who would
|
||||
// otherwise see an enabled button before choosing anything; the membership
|
||||
// test covers the account whose side they cannot write.
|
||||
const permitted = canAny(identity, 'activity:write');
|
||||
const writable =
|
||||
permitted && (accountId === '' || loggableAccounts.some((account) => account.id === accountId));
|
||||
const contactOptions = (contactRows ?? []).filter((row) => row.contact.accountId === accountId);
|
||||
const dealOptions = [
|
||||
...(demandBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId).map((row) => ({ value: `demand:${row.deal.id}`, label: `${row.deal.name} · demand` })),
|
||||
...(supplyBoard?.deals ?? []).filter((row) => row.deal.accountId === accountId).map((row) => ({ value: `supply:${row.deal.id}`, label: `${row.deal.name} · supply` })),
|
||||
];
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: ActivityForm) =>
|
||||
post<LoggedActivity>('/api/activities', {
|
||||
type: values.type,
|
||||
accountId: values.accountId,
|
||||
// Omitted rather than null: the endpoint accepts these keys only as
|
||||
// UUIDs, and a null would be rejected as an invalid activity.
|
||||
contactId: values.contactId || undefined,
|
||||
subject: values.subject.trim(),
|
||||
body: values.body.trim() || undefined,
|
||||
occurredAt: new Date(values.occurredAt).toISOString(),
|
||||
...dealReference(values.relatedDeal),
|
||||
}),
|
||||
onSuccess: async (result, values) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['growth'] }),
|
||||
]);
|
||||
if (result?.deduplicated) {
|
||||
toast.success('Already on the timeline', { description: 'An identical event had already been synced, so nothing was added.' });
|
||||
} else {
|
||||
toast.success(`${label(values.type)} logged`, { description: 'It is on the account timeline and has moved the last-activity date.' });
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
|
||||
return (
|
||||
<RecordSheet open={open} onOpenChange={onOpenChange} category="Timeline" title="Log activity" description="What actually happened with a counterparty. Record changes write their own notes; this is for the conversation behind them.">
|
||||
<Form {...form}>
|
||||
<form className="flex min-h-0 flex-1 flex-col" onSubmit={form.handleSubmit((values) => save.mutate(values))}>
|
||||
<SheetBody>
|
||||
{writable ? null : (
|
||||
<PermissionNotice>
|
||||
{permitted
|
||||
? 'Logging activity against this account needs write access to its side of the book. Choose another account, or ask a platform administrator.'
|
||||
: 'Logging activity needs write access to one side of the book. A platform administrator can grant it.'}
|
||||
</PermissionNotice>
|
||||
)}
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="type" label="Type" options={LOGGABLE_ACTIVITY_TYPES.map((value) => ({ value, label: label(value) }))} />
|
||||
<TextField control={form.control} name="occurredAt" label="Happened at" type="datetime-local" />
|
||||
<SelectField control={form.control} name="accountId" label="Account" className="sm:col-span-2" options={loggableAccounts.map((account) => ({ value: account.id, label: `${account.name} · ${label(account.side)}` }))} />
|
||||
<TextField control={form.control} name="subject" label="Subject" placeholder="Pricing call on the Q4 renewal" className="sm:col-span-2" />
|
||||
<TextAreaField control={form.control} name="body" label="Detail" description={ACTIVITY_TYPE_HINTS[type]} className="sm:col-span-2" />
|
||||
</FieldGrid>
|
||||
<Section title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
|
||||
<FieldGrid>
|
||||
<SelectField control={form.control} name="relatedDeal" label="Related deal" optional options={dealOptions} />
|
||||
<SelectField control={form.control} name="contactId" label="Contact involved" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
|
||||
</FieldGrid>
|
||||
</Section>
|
||||
</SheetBody>
|
||||
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Log activity" />
|
||||
</form>
|
||||
</Form>
|
||||
</RecordSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordSheet({ open, onOpenChange, category, title, description, children }: { open: boolean; onOpenChange(open: boolean): void; category: string; title: string; description: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -602,13 +1004,13 @@ function SheetBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-7 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">{children}</div>;
|
||||
}
|
||||
|
||||
function SheetActions({ pending, onCancel, label: actionLabel }: { pending: boolean; onCancel(): void; label: string }) {
|
||||
function SheetActions({ pending, onCancel, label: actionLabel, disabled = false }: { pending: boolean; onCancel(): void; label: string; disabled?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="submit" className="h-11" disabled={pending}>
|
||||
<Button type="submit" className="h-11" disabled={pending || disabled}>
|
||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : null}
|
||||
{pending ? 'Saving…' : actionLabel}
|
||||
</Button>
|
||||
@@ -617,6 +1019,21 @@ function SheetActions({ pending, onCancel, label: actionLabel }: { pending: bool
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown inside a sheet the caller should not have been able to open. The page
|
||||
* gates the affordance; this exists so that a deep link, or a permission
|
||||
* revoked while the tab was idle, explains itself instead of answering 403 on
|
||||
* submit.
|
||||
*/
|
||||
function PermissionNotice({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div role="status" className="flex gap-3 rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
|
||||
<Lock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -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