Make Piggy part of the product rather than a guest in it
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped

Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 18:22:15 -07:00
parent f0173440e4
commit 18d5f5bfc0
89 changed files with 8523 additions and 2447 deletions
+257 -182
View File
@@ -24,9 +24,11 @@
import { useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { AlertTriangle, ChevronDown } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { compactNumber, get, relativeTime } from '@/lib/api';
import { Badge, Button, Skeleton, cn } from '@/components/ui';
import { Button, EmptyState, Section, Skeleton, Stat, cn } from '@/components/ui';
import { RunStatusBadge, TaskStateBadge } from '@/components/status';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
// -------------------------------------------------------------- the wire
@@ -96,13 +98,21 @@ const EXACT = new Intl.NumberFormat('en-US');
* A month of Piggy costs about a penny and a single turn costs three
* ten-thousandths of one, so a fixed two decimal places would render the entire
* panel as `$0.00` and quietly answer "what is the credit doing?" with
* "nothing". Precision widens as the number shrinks, and never past six places,
* where the underlying figure stops being meaningful anyway.
* "nothing". Precision instead widens as the number shrinks, to **two
* significant figures** and never past six decimal places.
*
* Two figures, not the six the panel used to print: `$0.000483` claims a
* precision the reader cannot use and cannot check, and a column of them reads
* as noise rather than as money. Anyone who does want the exact number has it —
* every figure here carries its raw micro-cent value in a title attribute, and
* that is the audit trail, not the rendering.
*/
function decimalsFor(dollars: number): number {
const size = Math.abs(dollars);
if (size === 0 || size >= 1) return 2;
return size >= 0.01 ? 4 : 6;
// floor(log10) is the position of the leading digit; one place past it is the
// second significant figure.
return Math.min(6, 1 - Math.floor(Math.log10(size)));
}
/*
@@ -139,21 +149,66 @@ function sharedDecimals(...microCents: number[]): number {
}
/**
* A provider's error, as a sentence rather than as its wire format.
* What went wrong, in the words the person at the keyboard already heard.
*
* `agent_runs.error` is deliberately the raw upstream reason — the chat stream
* sanitises what the browser is told and the ledger keeps the truth, which is
* the right division. But this panel then rendered that truth verbatim, so a
* rate limit arrived in the product as
* `429: {"message":"Rate limit reached. Please retry shortly.","type":…,"code":…}`,
* a JSON document from a third party sitting in PIG's own interface. The
* message is pulled out where the body is JSON and the status is kept, because
* "429" is the part an operator acts on; the whole of it stays one hover away.
* `agent_runs.error` is deliberately the raw reason — the chat stream sanitises
* what the browser is told and the ledger keeps the truth, which is the right
* division. But this panel rendered that truth verbatim, so PIG's own audit
* surface showed a turn stopping as
* `turn stopped by the model_calls ceiling: 8 model calls, 5457 tokens, ceiling 8`
* and a rate limit as a JSON document from a third party. Both had already been
* explained to the same reader, in English, in the transcript a moment earlier.
*
* So the prefixes the relay writes are mapped back to the sentences the relay
* emits (`chat-server.ts` → `reportBreach` / `reportInferenceFailure`). Prose is
* left alone — an error that is already a sentence is somebody's considered
* wording and this table is not an improvement on it. The raw string is always
* one hover away in `title`, which is what makes the mapping safe.
*/
const ERROR_SENTENCES: readonly {
match: RegExp;
say: (groups: RegExpExecArray) => string;
}[] = [
{
match: /^turn stopped by the model_calls ceiling: (\d+) model calls/,
say: ([, calls]) =>
`Piggy stopped after ${calls} step${calls === '1' ? '' : 's'}, which is the most one ` +
'question may take, so this answer is incomplete.',
},
{
match: /^turn stopped by the tokens ceiling: \d+ model calls, (\d+) tokens/,
say: ([, tokens]) =>
`Piggy reached the size limit for a single question (${Number(tokens).toLocaleString(
'en-GB',
)} tokens), so this answer is incomplete.`,
},
{
// The AbortError, which is what Stop and a closed tab both leave behind.
// The badge beside it already says "Stopped by you"; this says what it cost.
match: /^This operation was aborted/,
say: () => 'Stopped before Piggy finished the answer.',
},
];
/** The shapes a rate limit arrives in. Matches `isRateLimited` in the relay. */
const RATE_LIMITED = /\b429\b|rate.?limit|rate_limited|too many requests|resourceexhausted/i;
function readableError(error: string): string {
const match = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(error.trim());
if (!match) return error;
const [, status, payload] = match;
const trimmed = error.trim();
for (const { match, say } of ERROR_SENTENCES) {
const found = match.exec(trimmed);
if (found) return say(found);
}
// `429: {"message":…}` and friends: a status code and a provider's JSON body,
// which is the one shape that is never anybody's considered wording.
const wire = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(trimmed);
if (!wire) return error;
if (RATE_LIMITED.test(trimmed)) {
return 'The inference endpoint was rate limiting us, so this turn was turned away. Waiting a few seconds and asking again usually clears it.';
}
const [, status, payload] = wire;
try {
const body = JSON.parse(payload!) as { message?: unknown; error?: unknown };
const message =
@@ -162,6 +217,7 @@ function readableError(error: string): string {
: typeof body.error === 'string'
? body.error
: null;
// The status is kept: "500" is the part an operator acts on.
return message ? `${status}: ${message}` : error;
} catch {
// Not JSON after all. Showing it unchanged beats showing nothing.
@@ -195,78 +251,18 @@ function humanise(value: string): string {
return value.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
}
type Tone = 'neutral' | 'accent' | 'positive' | 'warning' | 'danger' | 'info';
/**
* A run's status is free text from the ledger, so an unrecognised value is
* shown as it is in a neutral badge rather than being forced into one of the
* four we know. A status this panel has never heard of is information.
*/
const RUN_TONES: Record<string, Tone> = {
running: 'info',
// Deliberately not `positive`. Almost every row succeeds, and a column of
// green makes the one aborted turn no easier to find than the twenty that
// were fine — which is the only reason anybody scans this list.
succeeded: 'neutral',
aborted: 'warning',
failed: 'danger',
};
const TASK_TONES: Record<PiggyTaskSummary['state'], Tone> = {
running: 'info',
queued: 'accent',
scheduled: 'neutral',
// Same reasoning as RUN_TONES: colour is for what needs a person.
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
// ------------------------------------------------------------- primitives
function Section({
title,
count,
children,
}: {
title: string;
count?: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="card min-w-0">
<h3>
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className={cn(
'flex min-h-[44px] w-full items-center gap-2 rounded-lg px-4 py-2 text-left',
'text-xs font-semibold uppercase tracking-wide text-muted',
'transition-colors hover:bg-surface-2',
)}
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', open ? '' : '-rotate-90')}
aria-hidden
/>
<span className="flex-1">{title}</span>
{count == null ? null : <span className="nums text-muted">{count}</span>}
</button>
</h3>
{open ? <div className="px-4 pb-4">{children}</div> : null}
</section>
);
}
function Empty({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-xs leading-relaxed text-muted">
{children}
</p>
);
/**
* The copy module owns the sentence; an empty state needs it as a heading and a
* body. Split once here rather than restated, so the panel and Piggy's front
* door cannot end up describing the ledger in two different ways.
*/
function firstSentence(line: string): { title: string; description?: string } {
const at = line.indexOf('. ');
return at === -1
? { title: line }
: { title: line.slice(0, at), description: line.slice(at + 2) };
}
/**
@@ -280,7 +276,7 @@ function Meta({ parts }: { parts: (ReactNode | null)[] }) {
const kept = parts.filter((part): part is ReactNode => part != null && part !== '');
if (kept.length === 0) return null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{kept.map((part, index) => (
<span key={index}>{part}</span>
))}
@@ -288,18 +284,58 @@ function Meta({ parts }: { parts: (ReactNode | null)[] }) {
);
}
/**
* A run's reason, tinted by whether anybody has to do something about it.
*
* `danger` is reserved for a failure a person must resolve. A turn you stopped
* yourself, or one that ran into its own step ceiling, is a fact about the turn
* — so it reads as an inset note rather than as an alarm. A column in which
* every ended turn is red is a column nobody reads.
*/
function RunReason({ status, error }: { status: string; error: string }) {
const alarming = status === 'failed';
return (
<p
className={cn(
'mt-1.5 flex items-start gap-1.5 break-words rounded-md px-2 py-1.5 text-xs leading-relaxed',
alarming ? 'bg-danger/10 text-danger' : 'bg-surface-2 text-muted',
)}
// The whole of it, for an operator who needs the provider's own words.
title={error}
>
{alarming ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden /> : null}
<span className="min-w-0">{readableError(error)}</span>
</p>
);
}
// ------------------------------------------------------------------- rows
/**
* Is the thread's name just the run's label again?
*
* A conversation is titled from its opening question, so on the turn that
* started it the two strings are the same one and the row printed it twice —
* once as the heading and once, clipped, at the end of the meta line. Compared
* by prefix, with the server's own truncation mark stripped, because a title
* cut at 120 characters ends in an ellipsis the label it was cut from does not.
*/
function sameWords(label: string, title: string): boolean {
const trim = (value: string) => value.trim().toLowerCase().replace(/(\.\.\.|…)$/, '');
const one = trim(label);
const other = trim(title);
return one.startsWith(other) || other.startsWith(one);
}
function RunRow({ run }: { run: PiggyRunSummary }) {
const tone = RUN_TONES[run.status] ?? 'neutral';
const duration = formatDuration(run.durationMs);
const tokens =
run.inputTokens == null && run.outputTokens == null
? null
: `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;
return (
<li className="border-t border-border py-3 first:border-t-0 first:pt-1">
const body = (
<>
<div className="flex items-start justify-between gap-2">
{/* Clamped rather than truncated to one line: two lines is enough to
tell two similar questions apart, and a turn's whole prompt can be a
@@ -311,9 +347,7 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
>
{run.label}
</p>
<Badge tone={tone} className="shrink-0 capitalize">
{run.status}
</Badge>
<RunStatusBadge status={run.status} className="shrink-0" />
</div>
{run.summary ? (
@@ -325,16 +359,7 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
</p>
) : null}
{run.error ? (
<p
className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger"
// The whole of it, for an operator who needs the provider's own words.
title={run.error}
>
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{readableError(run.error)}</span>
</p>
) : null}
{run.error ? <RunReason status={run.status} error={run.error} /> : null}
<Meta
parts={[
@@ -350,28 +375,52 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
run.kind === 'task' && run.taskKind ? humanise(run.taskKind) : null,
// Present only when the run was somebody else's — see the server type.
run.principal ? run.principal.name : null,
/*
* Only the caller's own conversations resolve to a link — the server
* refuses to name anybody else's — so an admin reading the workspace
* ledger sees the run without a doorway into a private transcript.
*/
run.conversation ? (
<Link
run.conversation && !sameWords(run.label, run.conversation.title) ? (
<span
key="conversation"
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
// `inline-block` is load-bearing: `max-width` and `overflow` do
// nothing on a non-replaced inline box, so the truncation here was
// inert and a run whose title is a question with a UUID in it
// rendered 624px wide inside a 320px rail — clipped mid-word by
// the column rather than ellipsised.
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 hover:underline"
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 group-hover/run:underline"
title={run.conversation.title}
>
{run.conversation.title}
</Link>
</span>
) : null,
]}
/>
</>
);
/*
* Only the caller's own conversations resolve to a link — the server refuses
* to name anybody else's — so an admin reading the workspace ledger sees the
* run without a doorway into a private transcript.
*
* The whole row is the target, not the thread name at the end of the meta
* line. That name was a 14px-tall link at the bottom of a hundred-pixel row,
* which is a 44px rule broken by the one control in this panel that goes
* anywhere; and the row already reads as a unit, so the visible affordance
* was in the wrong place as well as the wrong size.
*/
return (
<li className="border-t border-border first:border-t-0">
{run.conversation ? (
<Link
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
aria-label={`Open the conversation “${run.conversation.title}`}
className={cn(
'group/run -mx-2 block min-h-11 rounded-md px-2 py-3',
'transition-colors duration-1 ease-enter hover:bg-surface-2',
)}
>
{body}
</Link>
) : (
<div className="py-3">{body}</div>
)}
</li>
);
}
@@ -379,14 +428,12 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
function TaskRow({ task }: { task: PiggyTaskSummary }) {
const outstanding = task.state === 'queued' || task.state === 'scheduled' || task.state === 'running';
return (
<li className="border-t border-border py-3 first:border-t-0 first:pt-1">
<li className="border-t border-border py-3 first:border-t-0">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
{humanise(task.kind)}
</p>
<Badge tone={TASK_TONES[task.state]} className="shrink-0 capitalize">
{task.state}
</Badge>
<TaskStateBadge state={task.state} className="shrink-0" />
</div>
{task.reason ? (
@@ -398,12 +445,7 @@ function TaskRow({ task }: { task: PiggyTaskSummary }) {
</p>
) : null}
{task.error ? (
<p className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{task.error}</span>
</p>
) : null}
{task.error ? <RunReason status="failed" error={task.error} /> : null}
<Meta
parts={[
@@ -470,15 +512,20 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
? sharedDecimals(spend.todayMicroCents, spend.monthMicroCents)
: 2;
const runsEmpty = firstSentence(piggyLine(piggyCopy.activityEmpty));
/*
* Not a landmark. The workspace already wraps this in an `<aside>` called
* "Piggy activity" on a wide screen, and in a Sheet titled "Activity" on a
* phone, so a complementary landmark of the same name nested inside it gave a
* screen-reader user two doors into one panel.
*/
return (
<aside
aria-label="Piggy activity"
className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}
>
<div className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}>
{/* First, not last: a ledger that could not be read must say so before it
shows anything that looks like a figure. */}
{activity.isError ? (
<div className="card min-w-0 p-4">
<div className="card order-1 min-w-0 p-4 sm:p-5">
<p className="flex items-start gap-2 text-sm text-danger">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span className="min-w-0 break-words">
@@ -498,56 +545,46 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
</div>
) : null}
<div className="card min-w-0 p-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Spend</h2>
<span className="text-[11px] text-muted">US dollars</span>
</div>
{/*
Spend sits under the runs on a narrow screen. What people open this
panel for is what Piggy just did; the month's spend is a figure they
check occasionally and it was pushing the first run below the fold of a
393px sheet. On a wide screen the rail is tall enough for both, and the
figure reads better at the top of the column.
*/}
<Section
title="Spend"
level={3}
action={<span className="text-xs text-muted">US dollars</span>}
className="card order-3 p-4 sm:order-2 sm:p-5"
>
{/*
Nothing here falls back to zero. A figure the panel could not read is
an em dash, never `$0.00`: on a spend surface those two are opposite
claims, and only one of them is true.
*/}
{spend ? (
<div className="mt-2 grid grid-cols-2 gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">Today</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.todayMicroCents)}
>
{spendMoney(spend.todayMicroCents, spendDigits)}
</div>
</div>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">This month</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.monthMicroCents)}
>
{spendMoney(spend.monthMicroCents, spendDigits)}
</div>
</div>
</div>
) : activity.isError ? (
<div className="mt-2 grid grid-cols-2 gap-3">
{['Today', 'This month'].map((label) => (
<div key={label} className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">{label}</div>
<div className="text-lg font-semibold leading-tight text-muted"></div>
</div>
))}
{spend || activity.isError ? (
<div className="grid grid-cols-2 gap-3">
<SpendFigure
label="Today"
microCents={spend ? spend.todayMicroCents : null}
digits={spendDigits}
/>
<SpendFigure
label="This month"
microCents={spend ? spend.monthMicroCents : null}
digits={spendDigits}
/>
</div>
) : (
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)}
{spend ? (
<p className="mt-2 text-[11px] leading-relaxed text-muted">
<p className="mt-2 text-xs leading-relaxed text-muted">
{spend.turns > 0 ? (
<>
<span className="nums">{EXACT.format(spend.turns)}</span> turns this month,
@@ -562,9 +599,15 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
)}
</p>
) : null}
</div>
</Section>
<Section title="Recent runs" count={activity.data ? runs.length : undefined}>
<Section
title="Recent runs"
level={3}
count={activity.data ? runs.length : undefined}
collapsible
className="card order-2 p-4 sm:order-3 sm:p-5"
>
{/*
`activity.data`, not `isPending`: after a failed read the query is
neither pending nor holding rows, and keying the empty state off
@@ -573,19 +616,16 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
*/}
{!activity.data ? (
activity.isError ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : runs.length === 0 ? (
<Empty>
Nothing has run yet. Ask Piggy a question and the turn appears here with its model,
its tokens and what it cost.
</Empty>
<EmptyState size="inline" title={runsEmpty.title} description={runsEmpty.description} />
) : (
<>
<ul className="flex flex-col">
@@ -607,21 +647,28 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
)}
</Section>
<Section title="Queue" count={activity.data ? tasks.length : undefined}>
<Section
title="Queue"
level={3}
count={activity.data ? tasks.length : undefined}
collapsible
className="card order-4 p-4 sm:p-5"
>
{!activity.data ? (
activity.isError ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : tasks.length === 0 ? (
<Empty>
No background work is queued. Enrichment, renewal watches and supplier research are
written here as tasks before Piggy runs them, and stay with their result afterwards.
</Empty>
<EmptyState
size="inline"
title="No background work is queued"
description="Enrichment, renewal watches and supplier research are written here as tasks before Piggy runs them, and stay with their result afterwards."
/>
) : (
<ul className="flex flex-col">
{tasks.map((task) => (
@@ -630,6 +677,34 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
</ul>
)}
</Section>
</aside>
</div>
);
}
/**
* One spend figure, with its exact value where a doubter can find it.
*
* The `title` carries the raw micro-cent integer, which is the audit trail: the
* rendered figure is a rounding of a number stored in millionths of a cent, and
* a money surface that cannot be checked against its own source is a claim
* rather than a record.
*/
function SpendFigure({
label,
microCents,
digits,
}: {
label: string;
microCents: number | null;
digits: number;
}) {
const text = spendMoney(microCents, digits);
return (
<Stat
size="md"
surface="bare"
label={label}
value={microCents == null ? text : <span title={spendTitle(microCents)}>{text}</span>}
/>
);
}
+191 -220
View File
@@ -17,85 +17,25 @@
* The five states come from `ApprovalStep` in lib/piggy-chat, which owns the
* transitions. This file renders them and reports a decision; it decides nothing
* about the change itself.
*
* Colour follows the product's rule rather than this card's own instincts: the
* only state drawn in a status colour is the one that has stopped and is waiting
* for a person. A settled card — applied, rejected — returns to the ordinary
* border, and the confirmation keeps exactly one positive mark. A transcript in
* which every approved write is a green block is a transcript where the one card
* that still needs answering is invisible.
*/
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ChevronRight,
Loader2,
ShieldAlert,
TriangleAlert,
XCircle,
} from 'lucide-react';
import { ArrowRight, CheckCircle2, Loader2, ShieldAlert, TriangleAlert, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
import { Badge, Button, Card, cn } from '@/components/ui';
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
import { ApprovalStateBadge } from '@/components/status';
import { RecordLink, recordHref } from '@/components/RecordLink';
import { Button, Card, Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
export type PiggyApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
// ------------------------------------------------------------------ routing
/**
* Where a record of each kind can be opened.
*
* `/accounts/:id` is the only per-record route PIG has, so an account link opens
* the record and everything else lands on the list that contains it — which at
* least puts the reader in front of the row they just changed. When the other
* detail routes land, each of these becomes a one-line edit; `recordHref` is the
* only place a record id becomes a URL.
*/
const RECORD_ROUTES: Record<string, string> = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
task: '/calendar',
};
function recordHref(record: NonNullable<PiggyProposedChange['record']>): string | null {
const base = RECORD_ROUTES[record.type];
if (!base) return null;
return record.type === 'account' ? `${base}/${record.id}` : base;
}
/** Whether the link opens the record itself or merely the list holding it. */
function opensRecord(type: string): boolean {
return type === 'account';
}
// ------------------------------------------------------------------- naming
/**
* Named for the reader, not for the model.
*
* The generic fallback turns `pig_update_record_fields` into "Update record
* fields", which is close enough that only the tools whose identifiers read
* badly need an entry. The caption exists so two cards proposing different
* writes on the same record are told apart at a glance.
*/
const TOOL_LABELS: Record<string, string> = {
pig_log_activity: 'Log activity',
pig_create_contact: 'Create contact',
pig_update_deal_stage: 'Update deal stage',
pig_update_record_fields: 'Update record fields',
pig_create_task: 'Create task',
};
function toolLabel(tool: string): string {
return (
TOOL_LABELS[tool] ??
tool
.replace(/^pig_/, '')
.replaceAll('_', ' ')
.replace(/^\w/, (letter) => letter.toUpperCase())
);
}
// --------------------------------------------------------------------- card
export function PiggyApprovalCard({
@@ -147,11 +87,29 @@ export function PiggyApprovalCard({
dispatched.current = false;
});
/**
* Where the keyboard goes when the buttons stop existing.
*
* Applying removes the pair the user just pressed, and focus falls to
* `<body>` — 121 Tab presses from the transcript on /piggy, and the outcome
* of the write announced to nobody in particular. Focus moves here instead,
* onto the live region that says what happened, so the answer to "did it
* land?" is both spoken and one Tab from the record link that proves it.
*/
const statusRef = useRef<HTMLDivElement>(null);
const decided = useRef(false);
useEffect(() => {
if (!decided.current || state === 'pending') return;
decided.current = false;
statusRef.current?.focus();
}, [state]);
const answerable = state === 'pending' || state === 'failed';
const decide = (decision: PiggyApprovalDecision) => {
if (!answerable || dispatched.current) return;
dispatched.current = true;
decided.current = true;
setChoice(decision);
onDecide(decision);
};
@@ -167,11 +125,16 @@ export function PiggyApprovalCard({
if (event.repeat) event.preventDefault();
};
const href = change.record ? recordHref(change.record) : null;
const recordLabel = change.record?.label ?? change.record?.id ?? '';
const record = change.record;
const linkable = Boolean(record && recordHref(record.type, record.id));
// The note explains why a card appeared at all, so it retires once the change
// is settled and the question is no longer live.
const showForcedNote = Boolean(change.forcedConfirm) && state !== 'applied' && state !== 'rejected';
const showActions = state === 'pending' || state === 'submitting' || state === 'failed';
// An applied card carries its record inside the confirmation sentence, so the
// footer link would be the same destination twice in two consecutive rows.
const showFooterLink = linkable && state !== 'applied';
const hasFooterRow = showActions || showFooterLink;
return (
<Card
@@ -181,28 +144,28 @@ export function PiggyApprovalCard({
aria-labelledby={headingId}
className={cn(
'w-full overflow-hidden',
// Warning is spent on the one state that has stopped and is waiting for
// a person; `submitting` keeps it because the question is still open
// until the relay answers, and a border that changes twice in a second
// reads as a flicker rather than as progress.
state === 'pending' || state === 'submitting'
? 'border-warning/50'
: state === 'applied'
? 'border-positive/40'
: state === 'failed'
? 'border-danger/50'
: 'border-border bg-surface-2/40',
: state === 'rejected'
? 'border-border bg-surface-2/40'
: 'border-border',
)}
>
<div className="flex items-start gap-2.5 p-3 sm:p-4">
<div className="flex items-start gap-2 p-4 sm:p-5">
<StateIcon state={state} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
{toolLabel(change.tool)}
</p>
<Label>{piggyToolLabel(change.tool)}</Label>
{/* The summary is the headline: everything below it is evidence for
this one sentence, so it is the only thing set at full weight. */}
<h4 id={headingId} className="mt-0.5 break-words text-sm font-semibold leading-snug">
<h4 id={headingId} className="mt-1 break-words text-sm font-semibold leading-snug">
{change.summary}
</h4>
</div>
<StateBadge state={state} choice={choice} />
<ApprovalStateBadge state={state} decision={choice} />
</div>
{showForcedNote ? <ForcedConfirmNote kind={change.kind} /> : null}
@@ -215,41 +178,53 @@ export function PiggyApprovalCard({
only record of what was declined, which is exactly what an audit asks
for.
*/
<details className="group border-t border-border">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-1 px-3 text-xs text-muted hover:text-fg sm:px-4 [&::-webkit-details-marker]:hidden">
<ChevronRight
className="size-3.5 transition-transform group-open:rotate-90"
aria-hidden
/>
What was proposed
</summary>
<Disclosure
summary="What was proposed"
className="border-t border-border"
summaryClassName="px-4 text-xs font-normal text-muted sm:px-5"
>
<FieldList fields={change.fields} settled />
</details>
</Disclosure>
) : (
<div className="border-t border-border">
<FieldList fields={change.fields} settled={false} />
</div>
)}
<div className="flex flex-col border-t border-border p-3 sm:p-4">
<div className="flex flex-col border-t border-border p-4 sm:p-5">
{/*
One live region, mounted for the life of the card. A status element
that appears at the same moment as its text is announced unreliably,
and this is exactly the transition — pending to applied — that a
screen-reader user must not miss. Empty while the card is waiting,
which is why the spacing hangs off the child rather than off a `gap`:
an empty region must not leave a hole above the buttons.
*/}
<div role="status" aria-live="polite" className="[&>*]:mb-3">
<StatusLine state={state} choice={choice} record={change.record} />
</div>
One live region, mounted for the life of the card and never empty: a
status element that appears at the same moment as its text is
announced unreliably, and this is exactly the transition — pending to
applied — that a screen-reader user must not miss. The spacing hangs
off the child rather than off a `gap` so that a settled card, whose
footer holds nothing else, does not end in a band of dead space.
{error ? (
<p className="mb-3 flex items-start gap-2 rounded-lg bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{error}</span>
</p>
) : null}
`tabIndex={-1}` is the target of the focus move above; it is never in
the tab order.
*/}
<div
ref={statusRef}
tabIndex={-1}
role="status"
aria-live="polite"
className={cn('min-w-0 outline-none', hasFooterRow && '[&>*]:mb-3')}
>
{/*
An error is the status. Left to the generic line as well, a failed
card stated the same fact three times — the badge, "The change was
not applied", and the reason — and a card that repeats itself reads
as a card that is guessing.
*/}
{error ? (
<p className="flex items-start gap-2 rounded-md bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{error}</span>
</p>
) : (
<StatusLine state={state} choice={choice} record={record} />
)}
</div>
{/*
The record and the decision share a row: the link is the one thing a
@@ -258,85 +233,75 @@ export function PiggyApprovalCard({
the footer to a single line on a phone. It wraps above them when the
dock is too narrow for both.
*/}
<div className="flex flex-wrap items-center justify-end gap-2">
{href ? (
<Link
to={href}
// `mr-auto` rather than `justify-between` on the row: when the pair
// of buttons wraps to its own line in a narrow dock, the row must
// still hold them at the right edge, and `between` would strand a
// lone wrapped item at the left.
className="mr-auto inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-md text-xs text-muted underline-offset-4 hover:text-fg hover:underline focus-visible:text-fg focus-visible:ring-brand"
title={
opensRecord(change.record?.type ?? '')
? `Open ${recordLabel}`
: `Open the list containing ${recordLabel}`
}
>
<span className="truncate">
{state === 'applied' ? 'Open ' : 'Check '}
{recordLabel || 'the record'}
</span>
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
</Link>
) : null}
{hasFooterRow ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{showFooterLink && record ? (
<RecordLink
type={record.type}
id={record.id}
label={record.label ?? record.id}
verb={answerable || state === 'submitting' ? 'Check' : 'Open'}
// A new tab, always, on this card. The escape hatch was
// destroying the proposal it exists to help verify: a same-tab
// navigation unmounts the transcript and takes the pending card
// with it, so the reader came back to no question at all.
newTab
// `mr-auto` rather than `justify-between` on the row: when the
// pair of buttons wraps to its own line in a narrow dock, the
// row must still hold them at the right edge, and `between`
// would strand a lone wrapped item at the left.
className="mr-auto"
/>
) : null}
{state === 'pending' || state === 'submitting' || state === 'failed' ? (
/*
The decision sits last, after the evidence, and never under the
reader's eye while they are still reading the diff. Reject comes
first so the hand travelling rightwards ends on the deliberate
action rather than passing over it, and Apply carries the only
filled treatment on the card. Nothing is autofocused: the card
arrives mid-stream, and a button that grabs focus while someone is
typing turns their next Enter into a write.
*/
/*
Full width below `sm` so the two buttons split a phone row evenly,
content width above it so they sit as a pair at the right of the
footer — and so that in a 22rem dock the pair wraps to its own line
intact rather than stacking one button above the other.
*/
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button
type="button"
variant="outline"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('reject')}
/*
The app's global focus ring is `ring-accent`, which is the
*subtle* accent — on a white card it is very nearly invisible.
Everywhere else that is a cosmetic loss; here it would leave a
keyboard user unable to see which of Apply and Reject they are
about to press, so both buttons ask for the full-strength
accent instead.
*/
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'reject' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Reject
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('apply')}
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'apply' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{state === 'failed' ? 'Try again' : 'Apply'}
</Button>
</div>
) : null}
</div>
{showActions ? (
/*
The decision sits last, after the evidence, and never under the
reader's eye while they are still reading the diff. Reject comes
first so the hand travelling rightwards ends on the deliberate
action rather than passing over it, and Apply carries the only
filled treatment on the card. Nothing is autofocused: the card
arrives mid-stream, and a button that grabs focus while someone is
typing turns their next Enter into a write.
Full width below `sm` so the two buttons split a phone row evenly,
content width above it so they sit as a pair at the right of the
footer — and so that in a 22rem dock the pair wraps to its own line
intact rather than stacking one button above the other.
*/
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button
type="button"
variant="outline"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('reject')}
className="min-w-[6rem] flex-1 sm:flex-none"
>
{state === 'submitting' && choice === 'reject' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Reject
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('apply')}
className="min-w-[6rem] flex-1 sm:flex-none"
>
{state === 'submitting' && choice === 'apply' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{state === 'failed' ? 'Try again' : 'Apply'}
</Button>
</div>
) : null}
</div>
) : null}
</div>
</Card>
);
@@ -344,10 +309,17 @@ export function PiggyApprovalCard({
// ------------------------------------------------------------------- pieces
/**
* The card's mark, coloured only while the card is waiting.
*
* A settled outcome is a process fact, and process facts get no colour here —
* the single positive mark this card is allowed to spend belongs on the
* confirmation sentence, next to the record the change landed on.
*/
function StateIcon({ state }: { state: PiggyApprovalState }) {
const className = 'mt-0.5 size-4 shrink-0';
if (state === 'applied') {
return <CheckCircle2 className={cn(className, 'text-positive')} aria-hidden />;
return <CheckCircle2 className={cn(className, 'text-muted')} aria-hidden />;
}
if (state === 'rejected') return <XCircle className={cn(className, 'text-muted')} aria-hidden />;
if (state === 'failed') {
@@ -356,26 +328,6 @@ function StateIcon({ state }: { state: PiggyApprovalState }) {
return <ShieldAlert className={cn(className, 'text-warning')} aria-hidden />;
}
function StateBadge({
state,
choice,
}: {
state: PiggyApprovalState;
choice: PiggyApprovalDecision | null;
}) {
if (state === 'applied') return <Badge tone="positive">Applied</Badge>;
if (state === 'rejected') return <Badge tone="neutral">Rejected</Badge>;
if (state === 'failed') return <Badge tone="danger">Not applied</Badge>;
if (state === 'submitting') {
return <Badge tone="neutral">{choice === 'reject' ? 'Rejecting' : 'Applying'}</Badge>;
}
return (
<Badge tone="warning" className="shrink-0">
Needs you
</Badge>
);
}
/**
* Why a card appeared in a mode that promised not to ask.
*
@@ -386,7 +338,10 @@ function StateBadge({
*/
function ForcedConfirmNote({ kind }: { kind: string }) {
return (
<p className="mx-3 flex items-start gap-2 rounded-lg bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-4">
// The bottom margin matches the header's own padding above it: without it
// the note sat flush against the divider under it and read as part of the
// diff rather than as a note about why the card exists.
<p className="mx-4 mb-4 flex items-start gap-2 rounded-md bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-5 sm:mb-5">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">
Auto mode stopped here on purpose. A {kindNoun(kind)} change always needs a person, whatever
@@ -409,7 +364,13 @@ function StatusLine({
choice: PiggyApprovalDecision | null;
record?: PiggyProposedChange['record'];
}) {
if (state === 'pending') return null;
// Pending says the thing the reader most needs to be sure of, and says it in
// the live region so that the card's arrival is heard rather than merely
// drawn. The badge says a person is needed; this says what has happened so
// far, which is nothing.
if (state === 'pending') {
return <p className="text-xs text-muted">Nothing has changed yet. Piggy is waiting for your answer.</p>;
}
if (state === 'submitting') {
return (
@@ -421,9 +382,19 @@ function StatusLine({
}
if (state === 'applied') {
// The one positive mark on the card, spent here rather than on the border
// or the badge, and spent beside the record so the confirmation and the
// proof of it are the same sentence.
return (
<p className="text-xs text-positive">
Applied to PIG{record?.label ? ` on ${record.label}` : ''}.
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-fg">
<CheckCircle2 className="size-3.5 shrink-0 text-positive" aria-hidden />
{/* The trailing space is for the announcement, not the layout: the flex
gap separates the words on screen, and without it a screen reader
reads "Applied to PIG onDEMO — Northwind Robotics". */}
<span>Applied to PIG{record?.label ? ' on ' : '.'}</span>
{record ? (
<RecordLink type={record.type} id={record.id} label={record.label ?? record.id} newTab />
) : null}
</p>
);
}
@@ -433,8 +404,8 @@ function StatusLine({
}
// `failed` covers both a write PIG refused and a turn that ended before the
// decision could be delivered. The reason under this line tells them apart;
// what both have in common is the only thing worth stating up front.
// decision could be delivered. Reached only when no reason came with it — the
// reason replaces this line when there is one.
return <p className="text-xs text-danger">The change was not applied.</p>;
}
@@ -446,7 +417,7 @@ function FieldList({
settled: boolean;
}) {
return (
<dl className="flex flex-col gap-2.5 px-3 pb-3 pt-3 sm:px-4">
<dl className="flex flex-col gap-2 p-4 sm:p-5">
{fields.map((field, index) => (
// Keyed by position as well as label: nothing stops a tool proposing two
// rows with the same label, and a duplicate key drops one of them.
@@ -474,8 +445,8 @@ function FieldRow({
}) {
return (
<div className="min-w-0">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{field.label}</dt>
<dd className="mt-0.5 min-w-0">
<Label as="dt">{field.label}</Label>
<dd className="mt-1 min-w-0">
{field.previous === undefined ? (
<span
className={cn('block break-words text-sm leading-5', settled ? 'text-muted' : 'text-fg')}
@@ -1,9 +1,13 @@
/**
* Piggy's history rail: every conversation this person has had, newest first.
*
* Three decisions here are worth stating, because each replaces something more
* Four decisions here are worth stating, because each replaces something more
* obvious that would have been wrong.
*
* **A thread with nothing in it is not shown.** See `conversations` below: a
* row exists from the moment New is pressed, so the rail was mostly abandoned
* drafts sharing one derived title.
*
* **Recency buckets, not a flat list.** History is scanned, not read — the
* question is "where was that thing I asked on Tuesday", and a wall of relative
* timestamps answers it one row at a time. Today / Yesterday / This week /
@@ -20,7 +24,8 @@
* **No `window.confirm` for the delete.** It blocks the event loop, so an
* answer still streaming into another conversation stalls behind a modal the
* browser drew, and it cannot name the thread being destroyed in a way anyone
* would read. The Dialog primitive does both.
* would read. `AlertDialog` does both, and refuses to be dismissed by a click
* landing somewhere else.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -37,15 +42,17 @@ import { toast } from 'sonner';
import type { PiggyConversationSummary } from '@pig/core';
import { api, get, patch, post, shortDate } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { Button, EmptyState, Input, Skeleton, cn } from '@/components/ui';
import { Button, EmptyState, Input, Label, Skeleton, cn } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -273,6 +280,24 @@ function formatWhen(bucket: Bucket, value: string): string {
return shortDate(date);
}
/**
* A thread's name, short enough to be read as a question.
*
* Titles are derived from the opening prompt and run to the server's
* 120-character cap, so a confirmation headed with one in full is three lines
* of somebody's own question with "?" stuck on the end — a heading nobody
* finishes before they decide. Cut at a word boundary; the row behind the
* dialog still carries the whole of it.
*/
function shortTitle(title: string, max = 64): string {
const trimmed = title.trim();
if (trimmed.length <= max) return trimmed;
const cut = trimmed.slice(0, max);
const space = cut.lastIndexOf(' ');
const kept = space > max * 0.6 ? cut.slice(0, space) : cut;
return `${kept.replace(/[\s.,;:—–-]+$/, '')}`;
}
/** The letter the collapsed rail shows. Punctuation and emoji are skipped. */
function railInitial(title: string): string {
const letter = title.match(/[\p{L}\p{N}]/u);
@@ -310,7 +335,49 @@ export function PiggyConversationList({
const [renamingId, setRenamingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<PiggyConversationSummary | null>(null);
const conversations = query.data ?? NO_CONVERSATIONS;
/**
* The dialog animates out over 240ms, and it is still on screen for all of
* them. Reading `pendingDelete` directly meant the heading became `Delete
* “”?` the instant either button was pressed — the confirmation forgetting
* what it had just asked about, in front of the person who answered it.
*/
const lastPendingDelete = useRef<PiggyConversationSummary | null>(null);
if (pendingDelete) lastPendingDelete.current = pendingDelete;
const deleting = pendingDelete ?? lastPendingDelete.current;
/**
* The ⋯ button the confirmation was opened from.
*
* The overlay primitive restores focus to whatever held it when the dialog
* mounted, which here is the dropdown menu — a node that has been removed
* from the document by the time anyone answers. So the row hands over its own
* button and the dialog is told explicitly where to go back to; without it,
* Escape dropped a keyboard user on `<body>`, a hundred-odd tab stops from
* the row they were working on.
*/
const deleteOpener = useRef<HTMLButtonElement | null>(null);
/** Where focus goes when the row it came from no longer exists. */
const newConversationRef = useRef<HTMLButtonElement>(null);
/**
* Threads with nothing said in them are not history.
*
* A conversation row is created the moment somebody presses New, and again
* whenever a turn is refused before a word is stored — so the demo book holds
* 58 empty threads against 14 real ones, and the rail people scan to find
* Tuesday's question is four-fifths abandoned drafts with the same derived
* title. Filtered here rather than on the server because the rows are real
* and something else may legitimately want them; this is a reading decision.
*
* The open thread is always kept. A conversation created a second ago has no
* messages yet, and it must not disappear from under the person typing in it.
*/
const conversations = useMemo(() => {
const all = query.data ?? NO_CONVERSATIONS;
const said = all.filter((entry) => entry.messageCount > 0 || entry.id === activeId);
return said.length === all.length ? all : said;
}, [activeId, query.data]);
/**
* Recomputed when the list changes rather than on a timer. The boundary only
@@ -377,9 +444,11 @@ export function PiggyConversationList({
<div className="mx-auto my-1.5 h-px w-6 bg-border" aria-hidden />
)
) : (
<h3 className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted">
/* h4, not h3: these name a run of rows inside the rail, and the
workspace's own headings sit above them. */
<Label as="h4" className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3">
{group.label}
</h3>
</Label>
)}
<ul className={cn('flex flex-col', rail ? 'items-center gap-1' : 'gap-px')}>
{group.items.map((conversation) =>
@@ -409,7 +478,10 @@ export function PiggyConversationList({
rename.mutate({ id: conversation.id, title });
}
}}
onRequestDelete={() => setPendingDelete(conversation)}
onRequestDelete={(opener) => {
deleteOpener.current = opener;
setPendingDelete(conversation);
}}
/>
),
)}
@@ -445,6 +517,7 @@ export function PiggyConversationList({
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={newConversationRef}
type="button"
variant={activeId === null ? 'secondary' : 'ghost'}
size="icon"
@@ -458,6 +531,7 @@ export function PiggyConversationList({
</Tooltip>
) : (
<Button
ref={newConversationRef}
type="button"
variant="outline"
className={cn(
@@ -490,36 +564,51 @@ export function PiggyConversationList({
</div>
</nav>
<Dialog
{/*
The destructive choice comes FIRST in the DOM and last on the screen.
A screen reader reads a footer in source order, so the consequence has
to arrive before the way out of it; `AlertDialogAction`'s own `order`
classes put "Keep it" back on the left where the platform puts it. The
title names the thread, because "this conversation" is not something
anyone can check before agreeing to destroy it.
*/}
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete this conversation?</DialogTitle>
<DialogDescription className="break-words">
{pendingDelete?.title} and everything said in it will be removed. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
type="button"
variant="danger"
disabled={remove.isPending}
onClick={() => void confirmDelete()}
>
<AlertDialogContent
onCloseAutoFocus={(event) => {
// After a delete the row's button is gone, and focusing a detached
// node silently lands on <body>. The list's own New conversation
// button is the nearest thing that certainly still exists — and is
// where `confirmDelete` has just sent the reader anyway.
const back = deleteOpener.current?.isConnected
? deleteOpener.current
: newConversationRef.current;
if (!back) return;
event.preventDefault();
back.focus();
}}
>
<AlertDialogHeader>
<AlertDialogTitle className="break-words">
Delete {deleting ? shortTitle(deleting.title) : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
The thread and everything said in it will be removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction disabled={remove.isPending} onClick={() => void confirmDelete()}>
<Trash2 className="size-4" aria-hidden />
Delete conversation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
<AlertDialogCancel>Keep it</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipProvider>
);
}
@@ -547,8 +636,11 @@ function ConversationRow({
onStartRename: () => void;
onCancelRename: () => void;
onCommitRename: (title: string) => void;
onRequestDelete: () => void;
onRequestDelete: (opener: HTMLButtonElement | null) => void;
}) {
/* Handed to the confirmation so it knows where to send focus back to. */
const actionsRef = useRef<HTMLButtonElement>(null);
if (renaming) {
return (
<li className="px-1 py-1">
@@ -584,8 +676,8 @@ function ConversationRow({
onClick={() => onSelect(conversation.id)}
aria-current={active ? 'true' : undefined}
className={cn(
'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left',
'transition-colors duration-1 ease-enter',
active ? 'bg-accent-subtle text-accent-fg' : 'hover:bg-surface-2',
)}
>
@@ -602,7 +694,7 @@ function ConversationRow({
>
{conversation.title}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted">
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted">
{running ? (
<>
<RunningDot />
@@ -630,13 +722,20 @@ function ConversationRow({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={actionsRef}
type="button"
aria-label={`Actions for ${conversation.title}`}
// Clipped, because a conversation's title is its opening question
// and these run to 120 characters — "Actions for List the three
// commitments closest to expiry as a markdown table with columns
// Provider, GPU, Ends, Idle hours, Margin…" is a label nobody
// listens to the end of.
aria-label={`Actions for ${shortTitle(conversation.title)}`}
className={cn(
'absolute right-0.5 top-0.5 flex size-11 items-center justify-center rounded-lg',
'text-muted transition hover:bg-border hover:text-fg',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'data-[state=open]:opacity-100',
'text-muted transition duration-1 ease-enter hover:bg-border hover:text-fg',
// Focus reveals it as well as ringing it: the button is invisible
// until hover, and hover is not how a keyboard reaches it.
'focus-visible:opacity-100 data-[state=open]:opacity-100',
// Hidden until hovered only where hovering is possible. On a touch
// screen there is no hover, so the same rule would hide rename and
// delete for good.
@@ -654,7 +753,7 @@ function ConversationRow({
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 text-danger focus:text-danger"
onSelect={() => onRequestDelete()}
onSelect={() => onRequestDelete(actionsRef.current)}
>
<Trash2 aria-hidden />
Delete
@@ -728,7 +827,7 @@ function RenameField({
}
}}
/>
<p className="px-1 text-[11px] leading-4 text-muted">Enter to save · Escape to cancel</p>
<p className="px-1 text-xs leading-4 text-muted">Enter to save · Escape to cancel</p>
</div>
);
}
@@ -757,8 +856,8 @@ function RailRow({
aria-current={active ? 'true' : undefined}
aria-label={conversation.title}
className={cn(
'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold',
'transition-colors duration-1 ease-enter',
// A solid fill, not the subtle tint the wide list uses. At 44px
// there is no title to carry the selection, so the square itself
// has to be unmistakable — and `accent-subtle` against
+14 -5
View File
@@ -145,7 +145,13 @@ export function PiggyConversation({
<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)}
// `scroll-pb-14` keeps the last 56px of the scrollport out of the
// resting position of anything the browser scrolls to itself — a
// focused follow-up chip, a revealed step, the tail of an answer. The
// jump-to-latest pill floats in that band, and without the padding it
// came to rest on top of the final line of the answer it had just
// brought into view.
className={cn('min-h-0 flex-1 scroll-pb-14 overflow-y-auto overscroll-contain', className)}
role="log"
aria-label="Piggy conversation"
// Announce the finished answer rather than each token: a live region
@@ -194,14 +200,17 @@ export function PiggyConversationScrollButton(): ReactElement | null {
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',
// Right-aligned, not centred. Centred it sat over the middle of the
// measure — which is where the sentence is — and a reader scrolled up
// mid-answer had a disc parked on the words. The right gutter is empty
// in every surface this panel is used in, from the 22rem dock to the
// full page.
'absolute bottom-3 right-3 z-10 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',
'animate-in fade-in zoom-in-95 duration-2 ease-enter',
)}
>
<ArrowDown className="size-4" aria-hidden />
@@ -12,6 +12,8 @@ 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 { piggyModeSummary } from '@/components/piggy/mode-control';
import { usePiggyModelLabel } from '@/components/piggy/model-picker';
import { Badge, Button, cn } from '@/components/ui';
/** How long the copy button admits it worked before returning to its label. */
@@ -34,6 +36,19 @@ export function PiggyMessageActions({
const state = stateLabel(message);
const usage = formatUsage(message);
const modelLabel = usePiggyModelLabel(message.model);
/*
* What this turn was allowed to do, recorded on the turn itself.
*
* PIG's safety argument is that nothing lands until a person presses Apply,
* and until now the transcript held no record of which permission each turn
* ran under — so a header reading "Read only" could sit above two write
* proposals made ten minutes earlier and nothing in the thread contradicted
* it. The mode is a fact about a turn, not about the control, so it belongs
* beside the model that answered.
*/
const mode = message.mode ? piggyModeSummary(message.mode) : null;
const ModeIcon = mode?.icon;
// 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.
@@ -45,7 +60,7 @@ export function PiggyMessageActions({
// 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;
if (!copyable && !retryable && !state && !usage && !message.model && !mode) return null;
const handleCopy = async () => {
// `navigator.clipboard` is absent outside a secure context, which is not a
@@ -75,20 +90,36 @@ export function PiggyMessageActions({
<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 ? (
describes: at 22rem the buttons' reserved width truncated the model
name away entirely, which defeats the point of showing it. It wraps
rather than truncating now, because the mode is on it and "was this
turn allowed to write?" is not a fact a narrow column may drop. */}
{state || mode || modelLabel || usage ? (
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-4 text-muted">
{state ? <Badge className="shrink-0 px-2 font-normal">{state}</Badge> : null}
{mode && ModeIcon ? (
// Neutral, not coloured: a mode is a setting, not an outcome, and a
// footer where every turn is amber is a footer nobody reads. Auto
// takes the same warning tint the mode control gives it, on the
// glyph only — it is the one mode that can write unattended.
<Badge className="shrink-0 px-2 font-normal" title={`Piggy ran this turn in ${mode.label}`}>
<ModeIcon aria-hidden className={cn('size-3', message.mode === 'auto' && 'text-warning')} />
{mode.label}
</Badge>
) : null}
{modelLabel ? (
// Sans, and the catalogue's own name for the model rather than the
// wire id: `nvidia/nemotron-3-super-120b` set in monospace under a
// sales answer was the product talking to itself. The full id stays
// in `title`, so nothing is lost — it is just no longer shouted.
// `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}
// `min-w-0` the name sets the row's minimum width and pushes the
// counts off the side of the dock.
<span className="min-w-0 truncate" title={message.model}>
{modelLabel}
</span>
) : null}
{message.model && usage ? <span aria-hidden>·</span> : null}
{modelLabel && usage ? <span aria-hidden>·</span> : null}
{usage ? (
<span className="shrink-0 tabular-nums" title={exactUsage(message)}>
{usage}
+76 -34
View File
@@ -3,7 +3,7 @@
*
* This is the only control in PIG that decides whether a language model may
* write to the company's book, so it is written to be read rather than to be
* clever. Three things follow from that and are deliberate:
* clever. Four things follow from that and are deliberate:
*
* names — the segments say "Read only", "Ask first" and "Auto", not
* `read_only` / `confirm` / `auto`. The enum is the wire's language
@@ -12,6 +12,10 @@
* selected NOW, and changes as the selection does. A toggle whose
* meaning lives in documentation is a toggle people set once and then
* misremember.
* the keyboard — the arrows move focus and do NOT select. The ARIA pattern
* says they should, and for a preference it would be right; for a
* permission it meant that arrowing across to read what Auto does
* turned Auto on. See `keyboardAt`.
* the exceptions — `auto` still stops at a contract, a commitment, an
* allocation and anything compliance-shaped. That is `requiresApproval`'s
* rule, and if the control does not say so, the first person to choose
@@ -32,7 +36,7 @@ import {
} from '@pig/core';
import { PIGGY_DEFAULT_MODE } from '@/lib/piggy-chat';
import { useOptionalIdentity } from '@/lib/identity';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
// ------------------------------------------------------------------- copy
@@ -132,6 +136,21 @@ export function PiggyModeControl({
const describedBy = useId();
const buttons = useRef(new Map<PiggyMode, HTMLButtonElement>());
/**
* Where the keyboard is, which is not the same as what is chosen.
*
* The ARIA radiogroup pattern normally selects whatever the arrow keys land
* on. That is right for a preference and wrong for a permission: arrowing
* across to read what Auto does was granting an agent unattended write access
* to the book, and the sentence explaining the consequence appeared *because*
* the consequence had already been accepted. Here the arrows move focus, the
* sentence updates to describe what is under the cursor, and Space or Enter
* is what commits. Null means the keyboard is elsewhere and the tabstop
* belongs to the selected segment, so tabbing back in returns to the choice
* in force rather than to wherever the last arrow press stopped.
*/
const [keyboardAt, setKeyboardAt] = useState<PiggyMode | null>(null);
/**
* What is drawn as selected. Not necessarily what the parent holds: a stored
* `auto` outlives the capability that justified it, so someone whose write
@@ -150,40 +169,60 @@ export function PiggyModeControl({
const choices = MODE_OPTIONS.filter((option) => canWrite || option.value === 'read_only');
/** Roving tabstop: the keyboard's position if it has one, else the choice. */
const roving: PiggyMode = keyboardAt ?? selected;
const moveTo = useCallback((next: PiggyMode | undefined) => {
if (!next) return;
setKeyboardAt(next);
buttons.current.get(next)?.focus();
}, []);
const step = useCallback(
(direction: 1 | -1) => {
const index = choices.findIndex((option) => option.value === selected);
const next = choices[(index + direction + choices.length) % choices.length];
if (!next) return;
onChange(next.value);
buttons.current.get(next.value)?.focus();
const index = choices.findIndex((option) => option.value === roving);
moveTo(choices[(index + direction + choices.length) % choices.length]?.value);
},
[choices, onChange, selected],
[choices, moveTo, roving],
);
const active = optionFor(selected);
/**
* The sentence describes what the keyboard is on, not what is chosen — so
* someone arrowing across Auto reads its consequence before deciding, which
* is the whole point of no longer selecting on focus.
*/
const active = optionFor(roving);
/** True while the keyboard is reading a mode that has not been chosen. */
const previewing = roving !== selected;
return (
<div className={cn('flex min-w-0 flex-col', compact ? 'gap-1.5' : 'gap-2')}>
{compact ? null : (
<span className="text-xs font-medium uppercase tracking-wide text-muted">
What Piggy may do
</span>
)}
{compact ? null : <Label>What Piggy may do</Label>}
<div
role="radiogroup"
aria-label="What Piggy may do"
aria-describedby={describedBy}
className="grid grid-cols-3 gap-1 rounded-xl border border-border bg-surface-2 p-1"
className="grid grid-cols-3 gap-1 rounded-lg border border-border bg-surface-2 p-1"
onBlur={(event) => {
// Leaving the group hands the tabstop back to the chosen segment, so
// the next Tab in lands on the mode in force rather than on whichever
// one the reader stopped over last time.
if (!event.currentTarget.contains(event.relatedTarget)) setKeyboardAt(null);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
step(1);
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
step(-1);
} else if (event.key === 'Home') {
event.preventDefault();
moveTo(choices[0]?.value);
} else if (event.key === 'End') {
event.preventDefault();
moveTo(choices[choices.length - 1]?.value);
}
}}
>
@@ -203,18 +242,20 @@ export function PiggyModeControl({
aria-checked={isSelected}
// Roving tabstop: a radio group is one stop in the tab order, and
// the arrow keys move within it.
tabIndex={isSelected ? 0 : -1}
tabIndex={option.value === roving ? 0 : -1}
disabled={disabled}
title={disabled ? NO_WRITE_REASON : option.sentence}
onClick={() => onChange(option.value)}
onClick={() => {
setKeyboardAt(option.value);
onChange(option.value);
}}
className={cn(
'flex min-h-[44px] min-w-0 items-center justify-center rounded-lg',
'font-medium transition-colors touch-manipulation select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent',
'flex min-h-[44px] min-w-0 items-center justify-center rounded-md',
'font-medium transition-colors duration-1 ease-enter touch-manipulation select-none',
// Tight enough that "Read only" survives whole in a dock
// narrower than the 22rem one; the label is what makes this
// control legible, so it is the last thing allowed to truncate.
compact ? 'gap-1 px-1 text-[11px]' : 'gap-1.5 px-2 text-xs sm:text-sm',
compact ? 'gap-1 px-1 text-xs' : 'gap-1.5 px-2 text-xs sm:text-sm',
isSelected
? 'bg-surface text-fg shadow-sm'
: 'text-muted hover:text-fg disabled:hover:text-muted',
@@ -238,32 +279,33 @@ export function PiggyModeControl({
{/*
Announced on change, because the consequence arrives a beat after the
press and a screen-reader user gets no colour to tell them the tone of
the panel changed.
the panel changed. It follows the keyboard rather than the choice, so
arrowing across Auto reads its consequence — which is the only way to
find out, now that arrowing no longer turns it on.
*/}
<div id={describedBy} aria-live="polite" className="min-w-0">
<div id={describedBy} aria-live="polite" className="min-w-0 text-xs leading-snug">
{active.consequential ? (
<p
className={cn(
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10',
compact ? 'px-2 py-1.5 text-[11px]' : 'px-2.5 py-2 text-xs',
'leading-snug text-fg',
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 text-fg',
compact ? 'px-2 py-1.5' : 'px-2.5 py-2',
)}
>
<TriangleAlert aria-hidden className="mt-px h-3.5 w-3.5 shrink-0 text-warning" />
<span>
{previewing ? <span className="font-medium">{active.label}: </span> : null}
{active.sentence} <span className="font-medium">{GUARDED_SENTENCE}</span>
{previewing ? ' Press Enter to choose it.' : null}
</span>
</p>
) : (
<p className={cn('leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
<p className="text-muted">
{previewing ? <span className="font-medium text-fg">{active.label}: </span> : null}
{active.sentence}
{previewing ? ' Press Enter to choose it.' : null}
</p>
)}
{canWrite ? null : (
<p className={cn('mt-1 leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
{NO_WRITE_REASON}
</p>
)}
{canWrite ? null : <p className="mt-1 text-muted">{NO_WRITE_REASON}</p>}
</div>
</div>
);
+42 -16
View File
@@ -22,7 +22,11 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronsUpDown, Sparkles } from 'lucide-react';
// `Cpu`, not `Sparkles`: Sparkles used to mean both "Piggy" and "model", and a
// glyph that means two things means neither. Piggy is `PiggyMark` everywhere
// now, so this control needs a neutral mark of its own — and inference running
// on Prime Intellect's own silicon is the thing this menu is about.
import { ChevronsUpDown, Cpu } from 'lucide-react';
import type { PiggyModelOption } from '@pig/core';
import { fetchPiggyModels } from '@/lib/piggy-chat';
import { useIdentityQuery } from '@/lib/identity';
@@ -35,7 +39,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge, Button, Skeleton, cn } from '@/components/ui';
import { Badge, Button, Label, Skeleton, cn } from '@/components/ui';
// ------------------------------------------------------------------ catalogue
@@ -320,6 +324,28 @@ function shortLabel(label: string): string {
return words.length >= LONG_LABEL_WORDS ? words.slice(-2).join(' ') : label;
}
/**
* The human name for a model id, for surfaces that only have the id.
*
* The transcript footer used to print `nvidia/nemotron-3-super-120b` in
* monospace under every answer — the raw wire value, in the one typeface this
* product reserves for machine text, on the line meant to tell a sales lead
* which model answered them. The catalogue already carries the name the picker
* shows two inches above it, so the footer says the same thing the menu says.
*
* The fallback drops the provider prefix rather than inventing capitalisation:
* a deployment can list a model this browser's cached catalogue has never
* seen, and a guessed name is worse than an honest identifier.
*/
export function usePiggyModelLabel(modelId: string | undefined): string | undefined {
const { models } = usePiggyModels();
if (!modelId) return undefined;
const known = models.find((model) => model.id === modelId);
if (known) return known.label;
const slash = modelId.lastIndexOf('/');
return slash === -1 ? modelId : modelId.slice(slash + 1);
}
export interface PiggyModelPickerProps {
/** The chosen model id, or null to follow the deployment default. */
value: string | null;
@@ -403,7 +429,7 @@ export function PiggyModelPicker({
aria-label="The model list is unavailable"
title={error?.message ?? 'Piggy did not return a model list.'}
>
<Sparkles className="size-4 shrink-0 text-muted" aria-hidden />
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
{compact ? null : <span className="text-muted">Model unavailable</span>}
</Button>
);
@@ -422,12 +448,12 @@ export function PiggyModelPicker({
compact ? 'max-w-[11rem] px-2' : 'max-w-[18rem] px-2.5 text-sm',
)}
>
<Sparkles className="size-4 shrink-0 text-muted" aria-hidden />
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
<span className="truncate text-fg">
{compact ? shortLabel(inForce.label) : inForce.label}
</span>
{!compact && isDeploymentDefault ? (
<span className="shrink-0 text-[11px] text-muted">Default</span>
<span className="shrink-0 text-xs text-muted">Default</span>
) : null}
<ChevronsUpDown className="size-3.5 shrink-0 text-muted" aria-hidden />
</Button>
@@ -444,11 +470,11 @@ export function PiggyModelPicker({
sideOffset={6}
className="z-[60] w-[min(26rem,calc(100vw-1.5rem))] p-1.5"
>
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
Model
<span className="font-normal normal-case tracking-normal">
{models.length} available
</span>
<DropdownMenuLabel className="px-2 pb-1.5 pt-1">
<Label className="flex items-baseline justify-between gap-2">
Model
<span className="normal-case tracking-normal">{models.length} available</span>
</Label>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={inForce.id} onValueChange={handleSelect}>
@@ -465,17 +491,17 @@ export function PiggyModelPicker({
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium text-fg">{model.label}</span>
{model.id === defaultModelId ? (
<Badge tone="neutral" className="px-1.5 py-0 text-[10px]">
<Badge tone="neutral" className="px-1.5 py-0">
Default
</Badge>
) : null}
{model.id === cheapestId ? (
<Badge tone="positive" className="px-1.5 py-0 text-[10px]">
<Badge tone="positive" className="px-1.5 py-0">
Cheapest
</Badge>
) : null}
{topTierIds.has(model.id) ? (
<Badge tone="accent" className="px-1.5 py-0 text-[10px]">
<Badge tone="accent" className="px-1.5 py-0">
Most capable
</Badge>
) : null}
@@ -483,13 +509,13 @@ export function PiggyModelPicker({
{model.hint ? (
<p className="whitespace-normal text-xs leading-snug text-muted">{model.hint}</p>
) : null}
<p className="nums whitespace-normal text-[11px] leading-snug text-muted">
<p className="nums whitespace-normal text-xs leading-snug text-muted">
{formatRates(model)} · {formatContext(model.contextWindow)} context
</p>
</div>
<div className="flex shrink-0 flex-col items-end pl-1 text-right">
<span className="nums text-sm font-medium text-fg">{formatQuote(model)}</span>
<span className="text-[10px] leading-tight text-muted">per 100 questions</span>
<span className="text-xs leading-tight text-muted">per 100 questions</span>
</div>
</DropdownMenuRadioItem>
))}
@@ -497,7 +523,7 @@ export function PiggyModelPicker({
<DropdownMenuSeparator />
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-[11px] leading-snug text-muted">
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-xs leading-snug text-muted">
Every model here is served by Prime Intellect inference on one API key. Prices are
estimated from a measured question about {TYPICAL_INPUT_TOKENS.toLocaleString('en-US')}{' '}
tokens in and {TYPICAL_OUTPUT_TOKENS} out.
+22 -21
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronRight } from 'lucide-react';
import { Brain } from 'lucide-react';
import { cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
/**
* How long after the last reasoning token the panel folds itself away.
@@ -81,10 +82,11 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
if (!started && !streaming) return null;
return (
<details
<Disclosure
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
onOpenChange={setOpen}
className="mb-2 text-xs text-muted"
summaryClassName="pr-2 text-xs"
/*
* The transcript around this is `role="log" aria-live="polite"`, and a
* live region announces its whole subtree. Auto-opening the panel
@@ -93,30 +95,29 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
* overrides the inherited politeness for this subtree only.
*/
aria-live="off"
/*
* On the `<details>` rather than on the summary, because that is where
* the primitive's own props land — and a click anywhere in this panel,
* summary or body, is the user attending to it, which is exactly the
* signal the automatic open and close must stand down for.
*/
onClick={() => {
touched.current = true;
}}
summary={
<span className="flex min-w-0 items-center gap-2">
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
</span>
}
>
<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',
'ml-1 animate-in fade-in border-l border-border py-1 pl-3 duration-2 ease-enter',
// 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.
@@ -126,7 +127,7 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
<p className="whitespace-pre-wrap leading-5">{text}</p>
</div>
) : null}
</details>
</Disclosure>
);
}
+16 -8
View File
@@ -21,7 +21,7 @@ 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';
import { Label, cn } from '@/components/ui';
/** Fenced blocks carry their language as `language-sql` on the `code` element. */
const LANGUAGE_CLASS = /language-([\w-]+)/;
@@ -60,12 +60,18 @@ const MARKDOWN_COMPONENTS: Components = {
* 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>,
h1: ({ children }) => <h1 className="pt-2 text-base font-semibold tracking-tight">{children}</h1>,
// h1 and h2 share the section-heading step deliberately. The scale has one
// size for "this owns the block below it", and `##` is what a model reaches
// for first — set a step down from `#` it read as a bolded sentence rather
// than as the heading of the table under it.
h2: ({ children }) => <h2 className="pt-2 text-base font-semibold">{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>,
h6: ({ children }) => (
<h6 className="pt-1 text-xs font-medium uppercase tracking-[0.06em] 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>,
@@ -115,7 +121,7 @@ const MARKDOWN_COMPONENTS: Components = {
<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>
<table className="w-max min-w-full border-collapse text-left text-sm leading-5">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
@@ -123,10 +129,12 @@ const MARKDOWN_COMPONENTS: Components = {
// 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>,
// The column heading is the product's one micro label, so a renewals table in
// an answer and a renewals table on /contracts are read at the same size.
th: ({ children, style, align }) => (
<th className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted" style={alignStyle(style, align)}>
<Label as="th" className="whitespace-nowrap px-3 py-2 align-bottom" style={alignStyle(style, align)}>
{children}
</th>
</Label>
),
td: ({ children, style, align }) => (
<td className="nums px-3 py-2 align-top" style={alignStyle(style, align)}>
@@ -167,7 +175,7 @@ function CodeFence({ className, children }: ComponentProps<'code'> & ExtraProps)
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>
<div className="border-b border-border px-3 py-1.5 font-mono text-xs lowercase text-muted">{language}</div>
) : null}
<pre className="scroll-x p-3 text-xs leading-5">
<code className="font-mono">{codeText(children)}</code>
+234 -192
View File
@@ -13,12 +13,14 @@
* 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 type { ReactNode } from 'react';
import { CheckCircle2, CircleSlash, Loader2, XCircle } from 'lucide-react';
import { money, unitPrice } from '@/lib/api';
import type { ToolStep } from '@/lib/piggy-chat';
import { cn } from '@/components/ui';
import { isPiggyWriteTool, piggyToolLabel } from '@/lib/piggy-tool-labels';
import { RecordLink } from '@/components/RecordLink';
import { Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
import { usePiggyConversationReveal } from './conversation';
/**
@@ -36,24 +38,19 @@ const RAW_PAYLOAD_MAX_CHARS = 20_000;
// ------------------------------------------------------------------ routing
/**
* Where a record of each kind can be opened.
* The record kinds a payload can name, and the noun each is called by.
*
* Contacts point at /accounts because PIG has no contacts route — the accounts
* page carries both views — and everything else points at its list.
* The URLs themselves live in `components/RecordLink`, which is the one place a
* record id becomes a URL and the one place that knows an account chip opens
* the record while everything else lands on the list holding it. This table is
* what remains: the vocabulary a summary sentence is written in.
*
* 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 a per-record contact link would point at an
* account that does not exist.
*/
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> = {
const RECORD_LABELS = {
account: 'account',
contact: 'contact',
demand_deal: 'demand deal',
@@ -61,153 +58,175 @@ const RECORD_LABELS: Record<RecordKind, string> = {
contract: 'contract',
commitment: 'capacity commitment',
allocation: 'allocation',
};
} as const;
interface RecordLink {
type RecordKind = keyof typeof RECORD_LABELS;
interface EvidenceRecord {
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[];
links: EvidenceRecord[];
/** Records read but not linked, so the cap is admitted rather than hidden. */
hiddenLinkCount: number;
/**
* What a WRITE tool's call actually came to.
*
* A write that the user declined returns normally — the tool did its job,
* which was to ask — so its step is `succeeded` and it was drawn with the
* same green check as a lookup, above a card reading "Rejected. Nothing was
* changed." On the surface whose whole promise is that a person decides, the
* evidence row was contradicting the decision. `state` alone cannot tell
* these apart; the payload's own `status` can.
*/
outcome?: PigWriteStatus;
}
/** The three ways a write tool's call can end. Mirrors `PigWriteDetails`. */
type PigWriteStatus = 'applied' | 'declined' | 'refused';
const NO_EVIDENCE: Evidence = { headline: null, links: [], hiddenLinkCount: 0 };
/**
* A write that has stopped and is asking, drawn by the approval card instead.
*
* A write tool holds its own call open across the whole approval — so for as
* long as the question is on screen, this step is `running` and renders a
* spinning row saying "Log activity" directly above a card that says the same
* thing with a diff and two buttons. One of the two is the decision surface and
* the other is a spinner that reads as work in progress on a turn where nothing
* is progressing. The step returns the moment the decision resolves it.
*
* The one case this over-reaches is auto mode, where a write runs without ever
* asking: its row is withheld for the second or so the call takes, then appears
* complete with its duration. That is the cheaper of the two mistakes — the
* duplicate row is on the default mode and on the product's most important
* card, the silent second is on a mode that has already said it will not ask.
*/
export function isParkedWriteStep(step: ToolStep): boolean {
return step.state === 'running' && isPiggyWriteTool(step.name);
}
export function PiggyToolStep({ step }: { step: ToolStep }) {
const reveal = usePiggyConversationReveal();
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);
};
if (isParkedWriteStep(step)) return null;
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>
<Disclosure
// 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. `onExpand` fires inside the click, before the
// browser lays the expansion out, which is the only moment early enough
// to get in front of the follow.
onExpand={reveal}
className="rounded-lg border border-border text-xs"
// The chevron is nudged onto the first line's optical centre, the same
// half-step the state icon beside it takes, so a two-line headline does
// not leave the two glyphs at different heights.
summaryClassName="items-start px-3 text-xs font-normal [&>svg]:mt-0.5"
contentClassName="flex flex-col gap-3 border-t border-border p-3"
summary={
<span className="flex min-w-0 items-start gap-2">
<StepIcon state={step.state} outcome={evidence.outcome} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{piggyToolLabel(step.name)}
</span>
{step.durationMs === undefined ? null : (
<span className="shrink-0 tabular-nums text-muted">
{formatDuration(step.durationMs)}
</span>
)}
</span>
{evidence.headline ? (
// Clamped shut, whole when open: a calendar headline runs to
// several sentences, and a chip that tall stops being a chip.
<span
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}
</span>
) : null}
</span>
</span>
}
>
{input ? (
<EvidenceSection title="Input">
<RawBlock text={input} />
</EvidenceSection>
) : null}
<EvidenceSection 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 ? (
<RecordChips links={evidence.links} hidden={evidence.hiddenLinkCount} />
) : null}
{payload ? (
<Disclosure
onExpand={reveal}
summary="Raw payload"
summaryClassName="text-xs font-normal text-muted"
>
<RawBlock text={payload} />
</Disclosure>
) : (
<p className="text-muted">The tool returned no payload.</p>
)}
<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>
)}
</EvidenceSection>
</Disclosure>
);
}
function StepIcon({ state }: { state: ToolStep['state'] }) {
const label = state === 'running' ? 'Running' : state === 'succeeded' ? 'Succeeded' : 'Failed';
/**
* What became of one call, in a glyph and a word.
*
* `outcome` overrides `state` for the write tools, and it has to: a proposal
* the user declined is a call that RETURNED, so its step is `succeeded` and it
* was drawn "Succeeded" with a positive check directly above the card saying
* nothing had changed. Declined and refused are neither successes nor failures
* — nothing went wrong and nothing was written — so they take the neutral mark
* the decided colour table gives every other process outcome.
*/
function StepIcon({ state, outcome }: { state: ToolStep['state']; outcome?: PigWriteStatus }) {
const unwritten = state === 'succeeded' && (outcome === 'declined' || outcome === 'refused');
const label = unwritten
? 'Not saved'
: 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 />
) : unwritten ? (
<CircleSlash className="size-4 text-muted" aria-hidden />
) : state === 'succeeded' ? (
<CheckCircle2 className="size-4 text-positive" aria-hidden />
) : (
@@ -218,11 +237,19 @@ function StepIcon({ state }: { state: ToolStep['state'] }) {
);
}
/** Labelled without a heading: a transcript full of `h4`s wrecks heading navigation. */
function Section({ title, children }: { title: string; children: ReactNode }) {
/**
* A named group inside a step, labelled without a heading.
*
* Deliberately not the shared `Section`: this renders inside `role="log"`,
* where every step would contribute an `h3` or `h4` and a transcript of forty
* of them turns heading navigation — the way a screen-reader user skims a
* page — into a list of "Input, Output, Input, Output". The label itself is the
* shared one, so it measures the same as every other micro label in PIG.
*/
function EvidenceSection({ 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>
<Label className="mb-1">{title}</Label>
{children}
</section>
);
@@ -230,30 +257,40 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
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">
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-xs leading-5 text-muted">
{text}
</pre>
);
}
function RecordLinks({ links, hidden }: { links: RecordLink[]; hidden: number }) {
/**
* The records the answer rests on, each openable.
*
* Same tail, same link, same honesty about where it lands as the approval
* card's escape hatch — this is the other half of the promise that an answer
* can be checked against its rows.
*
* `newTab`, for the same reason the approval card has it, and the reason is
* `RecordLink`'s own: "turn it on where leaving would destroy unsubmitted state
* — the pending approval card, and any link inside a streaming transcript."
* This is a link inside a streaming transcript. Measured with a proposal on
* screen, following one of these chips in-tab unmounted the transcript, and
* Back returned to the empty starter state — no card, no answer, and no notice
* that a decision had been abandoned. The chip that exists so an answer can be
* checked was destroying the thing being checked.
*/
function RecordChips({ links, hidden }: { links: EvidenceRecord[]; 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>
<RecordLink
type={link.kind}
id={link.id}
label={link.label}
newTab
className="border border-border px-2 hover:bg-surface-2 hover:no-underline"
/>
</li>
))}
{hidden > 0 ? (
@@ -279,6 +316,14 @@ function summariseResult(result: unknown): Evidence {
const payload = asRecord(result);
if (!payload) return NO_EVIDENCE;
// A write tool first: its payload is `{ tool, kind, status, recordId?,
// reason? }` and carries no `headline`, no subject and no collections, so it
// fell all the way through to `composeHeadline(null, [])` — an empty string.
// The measured result was a row reading "Succeeded / Log activity / 1.4s"
// with nothing under it, above a card saying the change had been rejected.
const written = writeOutcome(payload);
if (written) return written;
// 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
@@ -314,6 +359,39 @@ function summariseResult(result: unknown): Evidence {
};
}
/**
* A write tool's own account of itself.
*
* The three statuses come straight from `PigWriteDetails` in the agent, and the
* sentence deliberately says the same thing the approval card two elements
* below says. Two surfaces describing one decision have to agree; before this
* they did not, and the one that disagreed was the one wearing a green check.
*
* `reason` is quoted rather than paraphrased for `refused`, because the reason
* is a permission the person holds ("you cannot change a contract") and only
* the server knows which one it was.
*/
function writeOutcome(payload: Record<string, unknown>): Evidence | null {
const status = asString(payload.status);
if (status !== 'applied' && status !== 'declined' && status !== 'refused') return null;
// `tool` and `kind` are the shape's fingerprint: a read payload could carry a
// `status` column off a record row (a contract's status is "executed"), and
// that must not be read as a write outcome.
if (!asString(payload.tool)) return null;
const reason = asString(payload.reason);
const headline =
status === 'applied'
? 'Saved to PIG.'
: status === 'declined'
? 'Not saved. You declined this change.'
: reason
? `Not saved. ${reason}`
: 'Not saved.';
return { headline, links: [], hiddenLinkCount: 0, outcome: status };
}
/**
* The rows behind a headline, where the tool kept their ids.
*
@@ -324,8 +402,8 @@ function summariseResult(result: unknown): Evidence {
* 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[] = [];
function readHeadlineLinks(payload: Record<string, unknown>): EvidenceRecord[] {
const links: EvidenceRecord[] = [];
// A search hit names its own type, because a search spans five tables.
for (const row of asArray(payload.results)) {
const record = asRecord(row);
@@ -439,10 +517,10 @@ const COLLECTIONS: readonly Collection[] = [
function readCollections(payload: Record<string, unknown>): {
counts: string[];
links: RecordLink[];
links: EvidenceRecord[];
} {
const counts: string[] = [];
const links: RecordLink[] = [];
const links: EvidenceRecord[] = [];
for (const collection of COLLECTIONS) {
const rows = payload[collection.key];
@@ -550,42 +628,6 @@ function formatPayload(value: unknown): string | null {
: 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.
*
@@ -638,5 +680,5 @@ function asArray(value: unknown): 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;
return key !== null && key in RECORD_LABELS ? (key as RecordKind) : null;
}
@@ -151,7 +151,10 @@ export function PiggyModeButton({
disabled={disabled}
className={cn(
'min-w-0 gap-1.5 font-medium',
compact ? 'h-11 px-2 text-[11px]' : 'h-11 px-2.5 text-xs',
// `text-xs` in both branches: 11px is the micro-LABEL size, and this
// is a control's own word, not a label naming one. It was the last
// non-Label 11px string in the product. Only the padding gives.
compact ? 'h-11 px-2 text-xs' : 'h-11 px-2.5 text-xs',
)}
aria-label={`What Piggy may do: ${label}`}
>
@@ -16,12 +16,13 @@
* the transcript does not get to overrule them.
*/
import { useMemo, useState } from 'react';
import { CheckCircle2, CircleSlash, FileText, TriangleAlert } from 'lucide-react';
import { CheckCircle2, CircleAlert, CircleSlash, FileText, Loader2, TriangleAlert } from 'lucide-react';
import type { PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, TranscriptMessage } from '@/lib/piggy-chat';
import { compactNumber } from '@/lib/api';
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
import { PiggyActivityPanel, spendMoney, spendTitle } from '@/components/piggy/activity-panel';
import { Badge, cn } from '@/components/ui';
import { Badge, EmptyState, Section, Stat, cn } from '@/components/ui';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function PiggyWorkspaceRail({
@@ -47,24 +48,13 @@ export function PiggyWorkspaceRail({
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
>
<div className="shrink-0 border-b border-border p-2">
{/* The primitive's own palette is shadcn's, where `bg-muted` is a
surface. In PIG `muted` is the muted TEXT colour, so an unstyled
TabsList paints a mid-grey slab with unreadable labels on it. Every
other Tabs in the app carries the same three overrides; they are the
house pattern rather than a local fix. */}
<TabsList className="grid w-full grid-cols-2 border border-border bg-surface p-1">
<TabsTrigger
value="chat"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
This chat
</TabsTrigger>
<TabsTrigger
value="activity"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
Activity
</TabsTrigger>
{/* No palette overrides and no height override: PIG's own treatment and
the 44px floor are in the primitive now. The rail's triggers were
36px, which is the one place in Piggy the touch rule was actually
being broken. */}
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chat">This chat</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
</div>
{/* `mt-0` undoes the primitive's default gap: the tab strip already has a
@@ -137,38 +127,41 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
if (!summary.turns) {
return (
<div className="p-4 text-sm leading-6 text-muted">
<p className="font-medium text-fg">Nothing asked yet.</p>
<p className="mt-1">
Every record Piggy reads and every change it proposes will be listed here as the
conversation goes on, so an answer can be checked against the rows behind it.
</p>
</div>
<EmptyState
title="Nothing asked yet"
description="Every record Piggy reads and every change it proposes is listed here, so an answer can be checked against the rows behind it."
/>
);
}
return (
<div className="flex flex-col gap-4 p-3">
<dl className="grid grid-cols-2 gap-2">
<Figure label="Answers" value={String(summary.turns)} />
<Figure
<div className="flex flex-col gap-6 p-3">
<div className="grid grid-cols-2 gap-2">
<Stat size="sm" surface="inset" label="Answers" value={String(summary.turns)} />
<Stat
size="sm"
surface="inset"
label="Tool calls"
value={String(summary.tools.reduce((total, tool) => total + tool.runs, 0))}
/>
<Figure
<Stat
size="sm"
surface="inset"
label="Tokens"
value={`${compactNumber(summary.inputTokens)} / ${compactNumber(summary.outputTokens)}`}
hint="in / out"
/>
<Figure
label="Spend"
value={spendMoney(summary.costMicroCents)}
title={spendTitle(summary.costMicroCents)}
/>
</dl>
{/* The exact figure hangs off a wrapper because the tile itself takes no
`title`: sub-cent spend is rounded for reading and must still be
recoverable to the micro-cent, which is the number an operator
reconciles against. */}
<div className="min-w-0" title={spendTitle(summary.costMicroCents)}>
<Stat size="sm" surface="inset" label="Spend" value={spendMoney(summary.costMicroCents)} />
</div>
</div>
{summary.approvals.length ? (
<Section title="Changes">
<Section title="Changes" tone="micro" level={3}>
<ul className="flex flex-col gap-1.5">
{summary.approvals.map((approval) => (
<li key={approval.change.id}>
@@ -183,7 +176,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
proposed, and calling `pig_log_activity` a record read would be a small
lie on the one panel whose job is the audit trail. */}
{summary.tools.length ? (
<Section title="Tools used">
<Section title="Tools used" tone="micro" level={3}>
<ul className="flex flex-col gap-1">
{summary.tools.map((tool) => (
<li
@@ -192,7 +185,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
>
<FileText aria-hidden className="size-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-fg" title={tool.name}>
{toolLabel(tool.name)}
{piggyToolLabel(tool.name)}
</span>
{tool.failures ? (
<Badge tone="danger">{tool.failures} failed</Badge>
@@ -201,7 +194,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
</li>
))}
</ul>
<p className="mt-2 text-[11px] leading-4 text-muted">
<p className="mt-2 text-xs leading-5 text-muted">
Open a step in the transcript to see what each of these returned.
</p>
</Section>
@@ -210,39 +203,6 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
);
}
function Figure({
label,
value,
hint,
title,
}: {
label: string;
value: string;
hint?: string;
title?: string;
}) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</dt>
<dd className="nums mt-0.5 truncate text-sm font-semibold text-fg" title={title}>
{value}
{hint ? <span className="ml-1 text-[11px] font-normal text-muted">{hint}</span> : null}
</dd>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="min-w-0">
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted">
{title}
</h3>
{children}
</section>
);
}
/**
* A proposed change, at rail width.
*
@@ -258,7 +218,7 @@ function ChangeRow({ approval }: { approval: ApprovalStep }) {
<Icon aria-hidden className={cn('mt-0.5 size-3.5 shrink-0', state.className)} />
<div className="min-w-0 flex-1">
<p className="text-xs leading-5 text-fg">{approval.change.summary}</p>
<p className="mt-0.5 text-[11px] leading-4 text-muted">
<p className="mt-0.5 text-xs leading-5 text-muted">
{state.label}
{recordLabel(approval.change) ? ` · ${recordLabel(approval.change)}` : ''}
</p>
@@ -267,13 +227,21 @@ function ChangeRow({ approval }: { approval: ApprovalStep }) {
);
}
/**
* The same table the approval card is drawn from, and for the same reason:
* colour marks what needs a person, not what happened. This is an index of
* changes, so a column in which every applied write is green leaves the one row
* still waiting for an answer indistinguishable from the four above it.
*/
const CHANGE_STATES: Record<
ApprovalStep['state'],
{ label: string; icon: typeof CheckCircle2; className: string }
> = {
pending: { label: 'Waiting for you', icon: TriangleAlert, className: 'text-warning' },
submitting: { label: 'Sending your decision', icon: TriangleAlert, className: 'text-warning' },
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-positive' },
// A circle for "act on this", a triangle for "this went wrong" — the same two
// shapes `components/status.tsx` spends, so the scan works without hue.
pending: { label: 'Waiting for you', icon: CircleAlert, className: 'text-warning' },
submitting: { label: 'Sending your decision', icon: Loader2, className: 'animate-spin text-muted' },
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-muted' },
rejected: { label: 'Rejected', icon: CircleSlash, className: 'text-muted' },
failed: { label: 'Not applied', icon: TriangleAlert, className: 'text-danger' },
};
@@ -283,16 +251,3 @@ function recordLabel(change: PiggyProposedChange): string | null {
return change.record.label ?? change.record.type.replaceAll('_', ' ');
}
/**
* `pig_get_workspace_summary` → "Workspace summary".
*
* Deliberately mechanical rather than a second copy of the label table in
* `piggy/tool.tsx`: that one exists to name a step in the transcript, where the
* exact wording matters and a missing entry is visible. Here the name is a
* grouping key in a list of counts, and a table kept in two files is a table
* that disagrees with itself the first time a tool is renamed.
*/
function toolLabel(name: string): string {
const words = name.replace(/^pig_/, '').replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : name;
}
@@ -1,11 +1,21 @@
/**
* The first thing anyone sees after signing in.
* The blank transcript, on every Piggy surface.
*
* There used to be two of these. The workspace had this one; the dock, the
* sheet and the phone drawer had a second, narrower one that offered three
* read openers, showed a Sparkles glyph and never once mentioned that Piggy
* can write — the product's headline capability, missing from the surface most
* people keep open all day. One agent gets one front door, so this is now it,
* and `narrow` is what the 22rem column asks for instead of a second file.
*
* It has one job that the old blank transcript did not have: Piggy can write
* now, and nobody will discover that by typing into a box. So the openers are
* in two columns — what it can find out, and what it can get done — and the
* second column says plainly that a change is proposed and waits for a person.
*
* Every sentence here comes from `lib/piggy-copy`. It is written once because
* it was written three times and had already drifted.
*
* The read openers come from `piggySuggestions`, which picks them by the one
* read tool this context resolves to, so every line is one Piggy can ground.
* The write openers are held here because there is no equivalent table for them
@@ -23,9 +33,10 @@
*/
import { ArrowRight, PenLine, Search } from 'lucide-react';
import type { PiggyChatContext, PiggyMode } from '@pig/core';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { piggySuggestions } from '@/lib/piggy-suggestions';
import { PiggyMark } from '@/components/PiggyMark';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
/**
* Openers that end in a change to the book.
@@ -69,7 +80,11 @@ export function PiggyWorkspaceStarters({
* the write tools still withheld.
*/
onAskWithChange: (text: string) => void;
/** The middle column is under ~40rem: stack the two groups. */
/**
* The surface is under ~26rem — the dock, the phone drawer, the workspace's
* middle column on a phone. Stacks the two groups, halves the openers and
* takes the short form of every sentence.
*/
narrow?: boolean;
}) {
/*
@@ -89,41 +104,53 @@ export function PiggyWorkspaceStarters({
return (
// `flex-1` rather than `h-full`: the conversation viewport's content element
// is sized by its children, so a percentage height resolves to nothing.
// Centred where there is room to spare, and tight where there is not: at
// 393x852 the six-line version needs every one of these 40 pixels to land
// whole above the composer.
// Centred where there is room to spare, and airless where there is not: on
// a narrow surface this front door has to land whole above the composer at
// 393x852 and in the dock's 22rem column, and it is measured to.
<div
className={cn(
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
narrow ? 'gap-4 py-1' : 'gap-6 py-6',
narrow ? 'gap-3' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
<PiggyMark className={cn('text-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
{/* The agent's mark in the agent's colour. Accent is identity in this
product and never meaning, and this is the one place on the front
door where the identity is the subject. */}
<PiggyMark className={cn('text-accent-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
<h2
className={cn(
'font-semibold tracking-tight',
narrow ? 'mt-2' : 'mt-3',
narrow ? 'text-base' : 'text-lg sm:text-xl',
// Sentence case at the section-heading step in the dock, at the
// page-title step on the full workspace. Nothing between the two:
// the 18px it used to sit at belongs to no step in the scale.
narrow ? 'text-base' : 'text-xl',
)}
>
Ask across the book and now, act on the answer.
{piggyLine(piggyCopy.headline, narrow)}
</h2>
<p className={cn('mt-1.5 max-w-xl text-muted', narrow ? 'text-xs leading-5' : 'text-sm leading-6')}>
{/* Narrow keeps the boundary and drops the mechanism: the headline has
already said a change waits for approval, and the note under the
write openers says it again where it is about to matter. Repeating
it a third time in a 22rem column costs three lines the openers
need to land above the composer. */}
<p className={cn('max-w-xl text-sm text-muted', narrow ? 'mt-1 leading-5' : 'mt-1.5 leading-6')}>
{narrow
? 'Piggy reads your PIG records through scoped tools. Switched to Ask first, it drafts changes for you to approve.'
: 'Piggy reads your PIG records through scoped tools, with no shell, filesystem or browser. Switched to Ask first, it also drafts changes: each one arrives as a card you read and approve, and nothing reaches the book until you do.'}
? piggyLine(piggyCopy.capability, true)
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
</p>
</div>
<div className={cn('grid gap-4', narrow ? 'grid-cols-1' : 'sm:grid-cols-2')}>
<div className={cn('grid', narrow ? 'grid-cols-1 gap-3' : 'gap-4 sm:grid-cols-2')}>
<StarterGroup
narrow={narrow}
icon={<Search aria-hidden className="size-3.5" />}
title="Look something up"
title={piggyLine(piggyCopy.readGroupTitle, narrow)}
/* Dropped on a phone, where the two columns are stacked and every
line costs: the hero above has just said the same thing, and the
note that has to survive is the one about writing. */
note={narrow ? null : 'Answered from your records, with the rows it read attached.'}
note={narrow ? null : piggyLine(piggyCopy.readGroupNote, narrow)}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
@@ -133,15 +160,17 @@ export function PiggyWorkspaceStarters({
</StarterGroup>
<StarterGroup
narrow={narrow}
icon={<PenLine aria-hidden className="size-3.5" />}
title="Get something done"
note={
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
note={piggyLine(
canWrite
? mode === 'read_only'
? 'These switch Piggy to Ask first: it proposes the change, you press Apply. It asks which record if your line does not say.'
: 'Piggy shows you exactly what it would write, and asks which record if your line does not say.'
: 'Your access does not allow changing records, so Piggy can only read.'
}
? piggyCopy.writeGroupNote.readOnly
: piggyCopy.writeGroupNote.askFirst
: piggyCopy.writeGroupNote.noAccess,
narrow,
)}
>
{writes.map((suggestion) => (
<StarterButton
@@ -162,21 +191,31 @@ function StarterGroup({
icon,
title,
note,
narrow,
children,
}: {
icon: React.ReactNode;
title: string;
note: string | null;
/** Measured, not guessed: see the note on the drawer's height below. */
narrow: boolean;
children: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
/*
* The gaps close on a narrow surface rather than the content thinning.
* Measured at 393x852 the phone drawer gives this front door 458px and it
* wanted 498, and the 40px it was over came out of white space rather than
* out of an opener — a column of two questions is the whole point of the
* second column, and one of them is not a choice.
*/
<section className={cn('flex min-w-0 flex-col', narrow ? 'gap-1.5' : 'gap-2')}>
<Label as="h3" className="flex items-center gap-1.5">
{icon}
{title}
</h3>
<div className="flex flex-col gap-1.5">{children}</div>
{note ? <p className="text-[11px] leading-4 text-muted">{note}</p> : null}
</Label>
<div className={cn('flex flex-col', narrow ? 'gap-1' : 'gap-1.5')}>{children}</div>
{note ? <p className="text-xs leading-4 text-muted">{note}</p> : null}
</section>
);
}
@@ -13,11 +13,6 @@
* imported because the browser cannot import from the API package, and every
* field is optional-tolerant on read for the same reason: a row written by an
* older build must reopen as a slightly plainer message, never as a blank pane.
*
* Standing caveat, stated where it will be found: NOTHING WRITES THESE ROWS YET.
* `PiggyConversationService.appendMessage` exists and is tested, and the chat
* relay does not call it — see the report. So today every stored conversation
* reopens empty, and this module is the half of the loop that is ready.
*/
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';
@@ -16,20 +16,21 @@
* conversation 230px and keeps the threads reachable in one click.
* < 1024 one column. Both rails become sheets on header buttons, the
* composer keeps the floor of the box, and nothing is stacked above
* the transcript except a header that stays two rows tall.
* the transcript except the header — two rows on a phone held
* upright, one on any screen under 560px tall, because there the
* second row is a fifth of everything the reader came for.
*
* The transcript, the composer and the approval cards are `PiggyChatPanel` —
* the same component the dock and the phone drawer use — so this file is a
* layout and a set of decisions about conversations, not a second chat client.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
AlertTriangle,
History,
Info,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
@@ -38,7 +39,8 @@ import {
} from 'lucide-react';
import type { PiggyChatContext } from '@pig/core';
import { get, post } from '@/lib/api';
import { useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
import { useHasVerticalRoom, useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyContext } from '@/lib/piggy-context';
import type { PiggyConversation, TranscriptMessage } from '@/lib/piggy-chat';
import { PiggyChatPanel, PiggyUnavailable, usePiggyStatus } from '@/components/PiggyChat';
@@ -71,6 +73,12 @@ const ACTIVITY_COLUMN_BREAKPOINT = 1536;
/** Where the history rail is worth showing expanded by default. */
const WIDE_HISTORY_BREAKPOINT = 1280;
/**
* Tailwind `sm`. Above it the title row has room for the mode and model
* controls beside the thread's name; below it, it has not.
*/
const SHARED_TITLE_ROW_BREAKPOINT = 640;
const HISTORY_STORAGE_KEY = 'pig.piggy.workspace.history';
const ACTIVITY_STORAGE_KEY = 'pig.piggy.workspace.activity';
@@ -141,13 +149,25 @@ export function PiggyWorkspace() {
queryKey: conversationKey(activeId ?? ''),
queryFn: () => get<StoredPiggyConversation>(`/api/piggy/conversations/${activeId}`),
enabled: Boolean(activeId),
// The transcript is immutable history plus whatever this tab has since
// added, so refetching it under a live conversation would replace what is
// on screen with what the server had before this turn started.
staleTime: Infinity,
/*
* Always read fresh. The relay appends every turn to this conversation, so
* a cached copy taken before the last three questions is a transcript with
* three turns missing — which is what "come back to a thread you were just
* in" looks like. Refetching under a live conversation is safe because the
* hook stops adopting the seed the moment this session sends anything (see
* `touched` in usePiggyConversation).
*/
staleTime: 0,
retry: false,
});
// Stable while the fetch's payload is, so the thread's seed effect is not
// handed a new array on every parent render.
const storedTranscript = useMemo(
() => (detail.data ? toTranscript(detail.data.messages) : undefined),
[detail.data],
);
if (status.isLoading) {
return (
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
@@ -175,26 +195,33 @@ export function PiggyWorkspace() {
);
return (
/*
* The thread is FIRST in the DOM and second on screen.
*
* Measured from a cold load of /piggy: reaching the composer cost 72 Tab
* presses, 38 of them the conversation rail — nineteen rows, each with its
* own action button — and "Skip to content" landed immediately BEFORE the
* rail rather than after it, so the one affordance built to fix this walked
* straight into the thing that caused it. It grows with use: 123
* conversations exist in this workspace already.
*
* Reordering rather than removing tab stops, because every one of those
* stops is a control somebody needs: the rename and delete actions are
* reachable by keyboard only through the per-row button, and taking it out
* of tab order would trade a long walk for a dead end. `order` is the same
* instrument the delete dialog already uses to put the destructive action
* first in the DOM and second on screen — declared reading order and
* declared visual order are allowed to differ, and this is what for.
*
* It also puts the transcript before the navigation for a screen reader,
* which is the arrangement skip links exist to approximate.
*/
<div className="flex h-full min-h-0 w-full overflow-hidden">
{isMobile ? null : (
<aside
className={cn(
'flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
// The rail sets its own 3.75rem when collapsed; only the expanded
// width is the parent's to decide.
historyExpanded && 'w-[17rem]',
)}
aria-label="Piggy conversations"
>
{list}
</aside>
)}
<PiggyWorkspaceThread
key={activeId ?? `new-${newThread}`}
conversationId={activeId}
title={detail.data?.title ?? null}
initialMessages={detail.data ? toTranscript(detail.data.messages) : undefined}
initialMessages={storedTranscript}
loading={Boolean(activeId) && detail.isLoading}
loadError={detail.isError ? detail.error : null}
autoSend={pendingAsk?.id === activeId ? pendingAsk.message : undefined}
@@ -211,13 +238,35 @@ export function PiggyWorkspace() {
activityInColumn={hasActivityColumn}
/>
{isMobile ? null : (
<aside
className={cn(
// `-order-1` puts it back on the left. See the note above.
'-order-1 flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
// The rail sets its own 3.75rem when collapsed; only the expanded
// width is the parent's to decide.
historyExpanded && 'w-[17rem]',
)}
aria-label="Piggy conversations"
>
{list}
</aside>
)}
{/* Both workspace sheets are 20rem — the same width as the activity
column beside the transcript. Two overlays on one screen at 19 and
21rem is a difference nobody can name and everybody can see. */}
<Sheet open={historySheet} onOpenChange={setHistorySheet}>
<SheetContent side="left" className="flex w-[19rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="sr-only">
<SheetContent side="left" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
{/* Visible, not `sr-only`. A sheet that slides in over the
transcript with no name on it asks the reader to work out what
they opened from the contents. `pr-14` keeps the sentence clear
of the dismiss control the primitive pins to the corner. */}
<SheetHeader band className="pr-14">
<SheetTitle>Conversations</SheetTitle>
<SheetDescription>Your Piggy history. Pick one to carry on.</SheetDescription>
<SheetDescription>Pick one up from where it stopped.</SheetDescription>
</SheetHeader>
{isMobile ? list : null}
<div className="min-h-0 flex-1">{isMobile ? list : null}</div>
</SheetContent>
</Sheet>
@@ -265,6 +314,31 @@ function PiggyWorkspaceThread({
activityInColumn: boolean;
}) {
const isMobile = useIsMobile();
/*
* Whether this viewport can afford a second header row.
*
* `useIsMobile` is width-only, so a phone in landscape and a phone with the
* keyboard up both got the two-row header built for a 393x852 portrait
* screen — and were left with a measured 40px of transcript on the pane the
* whole page exists to show. Below 560px tall the controls fold back onto
* the title row, where they still fit because a landscape phone is wide.
*/
const hasHeaderRoom = useHasVerticalRoom();
/**
* Whether the title row is wide enough to carry the controls as well.
*
* Folding them up is only an improvement where there is width to fold into:
* a landscape phone has 852px and loses nothing, a portrait phone with the
* keyboard up has 393px and would crush the thread's name to four
* characters to save one row. That screen keeps the second row.
*
* Measured, and left alone deliberately: the controls `flex-wrap`, so forcing
* them onto a 393px title row beside three 44px icon buttons does not fold
* anything — it wraps them and rebuilds the same two rows with a crushed
* title as well. The transcript budget on that screen is reclaimed from the
* composer and this header's own padding instead; see below and `PiggyChat`.
*/
const canShareTitleRow = useMediaQuery(`(min-width: ${SHARED_TITLE_ROW_BREAKPOINT}px)`);
const { conversation, controls } = usePiggyChatSession({
context: WORKSPACE_CONTEXT,
initialMessages,
@@ -385,10 +459,26 @@ function PiggyWorkspaceThread({
const controlsRow = (
<PiggyControls controls={controls} compact disabled={busy} />
);
/** The controls take a row of their own only on a tall, narrow screen. */
const controlsOnSecondRow = isMobile && (hasHeaderRoom || !canShareTitleRow);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="shrink-0 border-b border-border bg-surface px-2 py-2 sm:px-3">
{/* `lg:px-4` puts the first glyph about 30px from the pane edge, which is
the CRM's own `lg:px-8` content inset once the icon button's optical
padding is counted. The workspace is allowed a toolbar; it is not
allowed to start 8px from an edge every other page starts 32px from. */}
<header
className={cn(
'shrink-0 border-b border-border bg-surface px-2 sm:px-3 lg:px-4',
// Tighter where the transcript is counted in tens of pixels. The
// controls are still 44px; only the air around them gives. `py-0.5`
// rather than `py-1` because a 393x390 keyboard-up screen has 390px
// to divide between a 56px app bar, this header, the composer and the
// thread — and four of those pixels are 1% of the transcript.
hasHeaderRoom ? 'py-2' : 'py-0',
)}
>
<div className="flex min-w-0 items-center gap-2">
{showHistoryToggle ? (
<IconButton
@@ -408,15 +498,15 @@ function PiggyWorkspaceThread({
conversation" would put the sidebar's button's own words in the
title bar. The workspace is called Piggy until the first
question names the thread. */}
<h1 className="truncate text-sm font-semibold tracking-tight">{title ?? 'Piggy'}</h1>
<p className="hidden truncate text-[11px] leading-4 text-muted sm:block">
{conversationId
? 'Running on Prime Agent, with your PIG records and nothing else.'
: 'New conversation. It is filed under your history as soon as you ask.'}
<h1 className="truncate text-base font-semibold leading-tight tracking-tight">
{title ?? 'Piggy'}
</h1>
<p className="hidden truncate text-xs leading-5 text-muted sm:block">
{piggyLine(conversationId ? piggyCopy.threadSaved : piggyCopy.threadNew, isMobile)}
</p>
</div>
{isMobile ? null : controlsRow}
{controlsOnSecondRow ? null : controlsRow}
{showHistoryToggle ? null : (
<IconButton label="Start a new conversation" onClick={onNew}>
@@ -439,12 +529,21 @@ function PiggyWorkspaceThread({
</div>
{/* On a phone the controls take the second row rather than shrinking:
"Ask first" and a model name cannot share 393px with a title. */}
{isMobile ? <div className="mt-2">{controlsRow}</div> : null}
"Ask first" and a model name cannot share 393px with a title. On a
short viewport they do share it, because 52px of chrome is a fifth
of the transcript there. */}
{controlsOnSecondRow ? (
<div className={hasHeaderRoom ? 'mt-2' : 'mt-0.5'}>{controlsRow}</div>
) : null}
</header>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-bg">
{/* No background of its own. The transcript well IS the canvas: the
shell's `.app-canvas` wash sits behind everything, and painting a
flat `bg-bg` over it made the one full-bleed surface in the product
the only place the canvas could not be seen — three planes
(rail, canvas, card) collapsing into two. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{loading ? (
<ThreadSkeleton />
) : loadError ? (
@@ -472,10 +571,15 @@ function PiggyWorkspaceThread({
on this page is a badge reading "piggy". The turn still carries
the context — the conversation was created with it. */
className="min-h-0 flex-1"
/* Focus follows the thread across the remount that filing it
causes. The first question of a new conversation creates the
stored row, which puts an id in the URL, which rebuilds this
subtree under a new key — and the caret the user had just
typed into landed on `<body>`, so the obvious next thing to do
was to reach for the mouse. */
autoFocusComposer={Boolean(autoSend)}
emptyState={
<div className="flex min-h-0 flex-1 flex-col gap-3">
{conversationId ? <ResumedNotice /> : null}
<PiggyWorkspaceStarters
<PiggyWorkspaceStarters
context={WORKSPACE_CONTEXT}
mode={controls.mode}
canWrite={controls.canWrite}
@@ -485,8 +589,7 @@ function PiggyWorkspaceThread({
even with both rails out at 1280 the middle keeps ~700px,
which is two 340px cards. Only the phone stacks them. */
narrow={isMobile}
/>
</div>
/>
}
/>
)}
@@ -503,10 +606,10 @@ function PiggyWorkspaceThread({
</div>
<Sheet open={activitySheet} onOpenChange={setActivitySheet}>
<SheetContent side="right" className="flex w-[21rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="border-b border-border px-4 py-3 pr-14 text-left">
<SheetTitle className="text-sm">Activity</SheetTitle>
<SheetDescription className="text-xs">
<SheetContent side="right" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
<SheetHeader band className="pr-14">
<SheetTitle>Activity</SheetTitle>
<SheetDescription>
What this conversation has touched, and what the workspace has run.
</SheetDescription>
</SheetHeader>
@@ -534,27 +637,6 @@ function askingConversation(
return { ...conversation, send: ask };
}
/**
* A stored conversation that opens with nothing in it.
*
* Which is every stored conversation today: the transcript tables exist and
* `appendMessage` is tested, and the chat relay does not call it yet — so a
* thread reopened tomorrow is a title and no words. Saying so is the only
* honest option; showing the front door's openers with no explanation would
* read as history that had been lost.
*/
function ResumedNotice() {
return (
<p className="mx-auto flex w-full max-w-3xl items-start gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs leading-5 text-muted">
<Info aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span>
Nothing is stored in this thread yet Piggy does not write transcripts to your history in
this build. Ask below and it carries on from here.
</span>
</p>
);
}
function ThreadSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>