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
+55 -17
View File
@@ -1,11 +1,16 @@
/**
* The account tile at the top of the sidebar.
*
* It carries the Piggy mark in the user's own accent, because that accent is
* the one piece of the interface they chose and the workspace identity is
* where they will look for it. The swatch row in the menu is the same
* `setAccent` the Settings page calls — not a copy of the palette, and not a
* second place a colour could be defined.
* It used to carry the Piggy mark, which made the pig face mean two things at
* once: the agent, and your organisation. Piggy is now one mark with one
* meaning everywhere in the product, so this tile carries a monogram instead —
* the workspace's initials on the trigger, the signed-in person's on the menu
* label above their own email. Both sit in the user's chosen accent, because
* that accent is the one piece of the interface they picked and identity is
* where they will look for it.
*
* The swatch row in the menu is the same `setAccent` the Settings page calls —
* not a copy of the palette, and not a second place a colour could be defined.
*
* PIG is single-workspace today, so this is a switcher with one entry. It is
* still a menu rather than a label: it is where identity, appearance and
@@ -18,7 +23,6 @@ import type { ThemeMode } from '@pig/core';
import { getSupabase } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { useTheme } from '@/lib/theme';
import { PiggyMark } from './PiggyMark';
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar';
import {
DropdownMenu,
@@ -28,10 +32,48 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { cn } from './ui';
import { Label, cn } from './ui';
const WORKSPACE_NAME = 'Prime Intellect Growth';
/**
* Two letters at most.
*
* Three initials in a 32px square is a monogram nobody can read, and a name
* with one word still has to fill the chip rather than sit in the corner of
* it. Falls back to the first character of whatever it was given, because an
* empty chip beside a name reads as a failed avatar load.
*/
function monogram(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();
return `${words[0]![0]!}${words[1]![0]!}`.toUpperCase();
}
/**
* The chip both monograms sit in, so they cannot drift apart.
*
* Rounded square for an organisation, circle for a person — the convention
* every product this one sits beside already uses, and the fastest way to say
* which of the two rows in this menu is your workspace and which is you. Both
* are decorative: the name they stand for is always printed next to them.
*/
function Monogram({ text, shape }: { text: string; shape: 'workspace' | 'person' }) {
return (
<span
aria-hidden
className={cn(
'flex size-8 shrink-0 items-center justify-center bg-accent-subtle',
'text-xs font-semibold tracking-[0.04em] text-accent-fg',
shape === 'workspace' ? 'rounded-lg' : 'rounded-full',
)}
>
{text}
</span>
);
}
const MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
@@ -63,9 +105,7 @@ export function AccountSwitcher() {
className="data-[state=open]:bg-sidebar-accent"
aria-label={`${WORKSPACE_NAME} — account and appearance`}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<Monogram text={monogram(WORKSPACE_NAME)} shape="workspace" />
<span className="flex min-w-0 flex-1 flex-col text-left leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold text-fg">{WORKSPACE_NAME}</span>
<span className="truncate text-xs font-normal text-muted">{identity.name}</span>
@@ -81,9 +121,7 @@ export function AccountSwitcher() {
sideOffset={8}
>
<DropdownMenuLabel className="flex min-w-0 items-center gap-2 py-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<Monogram text={monogram(identity.name)} shape="person" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">{identity.name}</span>
<span className="truncate text-xs font-normal text-muted">{identity.email}</span>
@@ -92,8 +130,8 @@ export function AccountSwitcher() {
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Accent
<DropdownMenuLabel className="pb-1 pt-2">
<Label>Accent</Label>
</DropdownMenuLabel>
<div className="flex flex-wrap gap-1.5 px-2 pb-2">
{accents.map((option) => (
@@ -124,8 +162,8 @@ export function AccountSwitcher() {
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Appearance
<DropdownMenuLabel className="pb-1 pt-2">
<Label>Appearance</Label>
</DropdownMenuLabel>
{MODES.map((option) => (
<DropdownMenuItem
+50 -45
View File
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
Bot,
Check,
CircleCheck,
CircleDashed,
@@ -26,13 +25,17 @@ import {
cn,
EmptyState,
Input,
Label as MicroLabel,
Section,
Skeleton,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import { FormField } from '@/components/ui/form-field';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationSettings } from './IntegrationSettings';
import { PiggyMark } from './PiggyMark';
/**
* What the server can honestly say about Piggy, all of it derived from the
@@ -96,37 +99,34 @@ export function AdminSettings() {
return (
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
<div className="relative flex items-start gap-3">
<div className="border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-accent-on">
<ShieldCheck aria-hidden />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">Platform control plane</h2>
<Badge tone="warning">Admin only</Badge>
</div>
<p className="mt-1 max-w-2xl text-sm text-muted">
Configure intelligence, inventory sync, workspace entry, and team authority.
</p>
</div>
<Section
level={2}
className="min-w-0"
title="Platform control plane"
description="Configure intelligence, inventory sync, workspace entry, and team authority."
action={<Badge tone="warning">Admin only</Badge>}
/>
</div>
</div>
<Tabs defaultValue="runtime" className="p-4 sm:p-6">
<TabsList className="scroll-x flex h-auto w-full justify-start bg-surface-2 p-1 sm:w-auto sm:inline-flex">
<TabsTrigger value="runtime" className="tap flex-1 sm:flex-none">Runtime</TabsTrigger>
<TabsTrigger value="invites" className="tap flex-1 sm:flex-none">Invites</TabsTrigger>
<TabsTrigger value="access" className="tap flex-1 sm:flex-none">Access</TabsTrigger>
<TabsTrigger value="integrations" className="tap flex-1 sm:flex-none">Integrations</TabsTrigger>
<TabsList className="scroll-x flex w-full justify-start sm:inline-flex sm:w-auto">
<TabsTrigger value="runtime" className="flex-1 sm:flex-none">Runtime</TabsTrigger>
<TabsTrigger value="invites" className="flex-1 sm:flex-none">Invites</TabsTrigger>
<TabsTrigger value="access" className="flex-1 sm:flex-none">Access</TabsTrigger>
<TabsTrigger value="integrations" className="flex-1 sm:flex-none">Integrations</TabsTrigger>
</TabsList>
<TabsContent value="runtime" className="mt-5">
<TabsContent value="runtime">
{isLoading || !data ? <p className="text-sm text-muted">Loading runtime settings</p> : <RuntimeForm key={data.updatedAt} settings={data} />}
</TabsContent>
<TabsContent value="invites" className="mt-5"><InviteManager /></TabsContent>
<TabsContent value="access" className="mt-5"><MemberManager /></TabsContent>
<TabsContent value="integrations" className="mt-5"><IntegrationSettings /></TabsContent>
<TabsContent value="invites"><InviteManager /></TabsContent>
<TabsContent value="access"><MemberManager /></TabsContent>
<TabsContent value="integrations"><IntegrationSettings /></TabsContent>
</Tabs>
</section>
);
@@ -165,24 +165,22 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
<Card>
<CardHeader>
<div className="flex items-center gap-2"><RefreshCw className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
<div className="flex items-center gap-2"><RefreshCw className="text-muted" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
<p className="break-all text-xs text-muted">Compute endpoint: {settings.primeComputeBase}</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={settings.primeApiKey.configured ? 'positive' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
<Badge tone={settings.primeApiKey.configured ? 'neutral' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
{settings.primeApiKey.source ? <Badge>{settings.primeApiKey.source} source</Badge> : null}
{settings.primeApiKey.updatedAt ? <span className="text-xs text-muted">updated {relativeTime(settings.primeApiKey.updatedAt)}</span> : null}
</div>
<label className="flex flex-col gap-1.5" htmlFor="prime-api-key">
<span className="text-sm font-medium">Replace Prime API key</span>
<FormField label="Replace Prime API key" hint={settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}>
<Input id="prime-api-key" type="password" autoComplete="new-password" value={primeApiKey} onChange={(event) => { setPrimeApiKey(event.target.value); setClearKey(false); }} placeholder="Enter a new key; existing material is never shown" disabled={!settings.primeApiKey.encryptionReady} />
<span className="text-xs text-muted">{settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}</span>
</label>
</FormField>
{settings.primeApiKey.source === 'database' ? <Button type="button" variant={clearKey ? 'danger' : 'outline'} size="sm" onClick={() => { setClearKey((value) => !value); setPrimeApiKey(''); }}>{clearKey ? 'Credential will be cleared' : 'Clear stored credential'}</Button> : null}
<div className="grid gap-3 sm:grid-cols-[1fr_9rem] sm:items-end">
<ToggleRow id="prime-sync" label="Inventory sync" description="Continuously refresh Prime availability and pricing." checked={syncEnabled} onCheckedChange={setSyncEnabled} />
<label className="flex flex-col gap-1.5" htmlFor="sync-interval"><span className="text-sm font-medium">Every (minutes)</span><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></label>
<FormField label="Every (minutes)"><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></FormField>
</div>
</CardContent>
</Card>
@@ -234,7 +232,7 @@ function PiggyCard({
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Bot className="text-accent-fg" aria-hidden />
<PiggyMark className="size-5 text-muted" />
<CardTitle className="text-base">Piggy intelligence</CardTitle>
</div>
<Button type="button" size="sm" variant="outline" onClick={recheck} disabled={rechecking}>
@@ -335,15 +333,21 @@ function PiggyCard({
);
}
/*
* `positive` is deliberately the neutral treatment. A control plane that paints
* a green panel every time everything is fine teaches an operator to stop
* reading the panel, and the one state that matters here is the one that needs
* them. Colour marks a problem; working is quiet.
*/
const VERDICT_SURFACE = {
positive: 'border-positive bg-positive/10',
positive: 'border-border bg-surface-2',
warning: 'border-warning bg-warning/10',
danger: 'border-danger bg-danger/10',
neutral: 'border-border bg-surface-2',
} as const;
const VERDICT_TEXT = {
positive: 'text-positive',
positive: 'text-fg',
warning: 'text-warning',
danger: 'text-danger',
neutral: 'text-fg',
@@ -420,7 +424,7 @@ function PiggyFact({ state, label, detail }: { state: 'ok' | 'bad' | 'unknown';
<Icon
className={cn(
'mt-0.5 size-4 shrink-0',
state === 'ok' ? 'text-positive' : state === 'bad' ? 'text-danger' : 'text-muted',
state === 'bad' ? 'text-danger' : 'text-muted',
)}
aria-hidden
/>
@@ -474,17 +478,17 @@ function InviteManager() {
const revoke = useMutation({ mutationFn: (id: string) => api(`/api/admin/invites/${id}`, { method: 'DELETE' }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }) });
return <div className="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]">
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
<label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Email, optional</span><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></label>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Team</span><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Role</span><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></label></div>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Uses</span><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Expires, optional</span><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></label></div>
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-muted" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
<FormField label="Email, optional"><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></FormField>
<div className="grid grid-cols-2 gap-3"><FormField label="Team"><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField><FormField label="Role"><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField></div>
<div className="grid grid-cols-2 gap-3"><FormField label="Uses"><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></FormField><FormField label="Expires, optional"><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></FormField></div>
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
</form></CardContent></Card>
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader>{/* A failed ledger read must not render as "no invites issued": an admin who
believes the workspace is empty issues a second code to someone who
already has one. */}
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <EmptyState size="inline" title="No invites issued yet" /> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
</div>;
}
@@ -500,12 +504,13 @@ function MemberManager() {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Users className="text-accent-fg" aria-hidden />
<div>
<h3 className="font-semibold">Team and role administration</h3>
<p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p>
</div>
<div className="flex items-start gap-2">
<Users className="mt-0.5 shrink-0 text-muted" aria-hidden />
<Section
className="min-w-0"
title="Team and role administration"
description="Roles are team-scoped. Platform administration is a separate grant."
/>
</div>
{query.isPending ? (
<div className="flex flex-col gap-3" aria-busy>
@@ -555,5 +560,5 @@ function MemberAccess({ member }: { member: Member }) {
const [isPlatformAdmin, setIsPlatformAdmin] = useState(member.isPlatformAdmin);
const [roles, setRoles] = useState<Partial<Record<Team, TeamRole>>>(() => Object.fromEntries(member.memberships.map(({ team, role }) => [team, role])));
const save = useMutation({ mutationFn: () => patch(`/api/admin/members/${member.id}/access`, { isPlatformAdmin, memberships: TEAMS.flatMap((team) => roles[team] ? [{ team, role: roles[team] }] : []) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-members'] }) });
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1"><span className="text-xs font-medium text-muted">{TEAM_LABELS[team]}</span><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1.5"><MicroLabel as="span">{TEAM_LABELS[team]}</MicroLabel><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
}
+88 -57
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -9,15 +9,15 @@ import {
import { AlertTriangle, Clock3, LoaderCircle, RotateCcw, ShieldCheck } from 'lucide-react';
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
import { z } from 'zod';
import { Badge, Button, Input } from '@/components/ui';
import { Badge, Button, EmptyState, Input, Label, Section, Stat } from '@/components/ui';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
} from '@/components/ui/form';
import {
Select,
@@ -36,6 +36,7 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { UtilisationBar } from '@/components/ui/utilisation-bar';
import { ApiError, compactNumber, dateRange, get, percent, post, shortDate, unitPrice } from '@/lib/api';
import { toast } from 'sonner';
@@ -192,6 +193,7 @@ export function AllocationSheet({
onChanged?(): void;
}) {
const queryClient = useQueryClient();
const releaseReasonId = useId();
const [releaseReason, setReleaseReason] = useState('');
const [releaseError, setReleaseError] = useState<string | null>(null);
const form = useForm<AllocationForm>({
@@ -345,20 +347,19 @@ export function AllocationSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
<SheetHeader band className="sm:px-6">
<SheetTitle>Reserve capacity</SheetTitle>
<SheetDescription>
Join committed supply to a demand deal. Availability is re-checked by the server when you save.
</SheetDescription>
</SheetHeader>
<Separator />
<Form {...form}>
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit((values) => save.mutate(values))}
>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-5 py-5 sm:gap-6 sm:px-6">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
{(['allocation', 'hold'] as const).map((value) => (
<button
@@ -379,16 +380,16 @@ export function AllocationSheet({
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name="capacityCommitmentId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Capacity commitment</FormLabel>
<FieldLabel>Capacity commitment</FieldLabel>
<Select value={field.value} onValueChange={chooseCommitment}>
<FormControl>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue placeholder={availabilityLoading ? 'Loading capacity…' : 'Select capacity'} />
</SelectTrigger>
</FormControl>
@@ -412,10 +413,10 @@ export function AllocationSheet({
name="demandDealId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Demand deal</FormLabel>
<FieldLabel>Demand deal</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="h-11"><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
@@ -438,7 +439,7 @@ export function AllocationSheet({
{selected ? (
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
) : options.length === 0 && !availabilityLoading ? (
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
<div role="status" className="rounded-md bg-surface-2 p-4 text-sm text-muted">
{/* Two different dead ends. Told to re-run a matcher they
never ran, someone with an empty book has nowhere to go —
the answer there is to record what capacity was bought. */}
@@ -448,14 +449,11 @@ export function AllocationSheet({
</div>
) : null}
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold">Commercial reservation</h3>
<p className="mt-1 text-xs leading-relaxed text-muted">
GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Section
title="Commercial reservation"
description="GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes."
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField control={form.control} name="gpuHours" label="GPU-hours" inputMode="decimal" placeholder="2048" />
<TextField control={form.control} name="price" label={kind === 'hold' ? 'Expected $/GPU-hr' : 'Sell $/GPU-hr'} inputMode="decimal" placeholder={kind === 'hold' ? 'Optional' : '2.75'} />
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
@@ -471,29 +469,33 @@ export function AllocationSheet({
name="notes"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Reservation notes</FormLabel>
<FieldLabel>Reservation notes</FieldLabel>
<FormControl><Textarea {...field} className="min-h-24 resize-y" placeholder="Commercial assumptions, caveats, or approval context." /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
</Section>
{selected ? (
<section className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold">Reservations on this commitment</h3>
<p className="mt-1 text-xs text-muted">Live holds reserve capacity but remain separate from sold allocations.</p>
</div>
<Section
title="Reservations on this commitment"
description="Live holds reserve capacity but remain separate from sold allocations."
>
{reserving.length === 0 ? (
<p className="rounded-lg bg-surface-2 p-4 text-sm text-muted">No live reserving allocations.</p>
<EmptyState
size="inline"
className="rounded-md bg-surface-2"
title="No live reserving allocations"
description="Nothing is held against this block, so every unsold hour is still sellable."
/>
) : (
<div className="flex flex-col gap-2">
{reserving.map((allocation) => {
const deal = allocation.demandDealId ? dealsById.get(allocation.demandDealId) : undefined;
return (
<div key={allocation.id} className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
<div key={allocation.id} className="flex flex-col gap-3 rounded-md bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{deal?.deal.name ?? 'Internal allocation'}</p>
@@ -511,14 +513,23 @@ export function AllocationSheet({
</div>
);
})}
<label className="flex flex-col gap-1 text-xs font-medium text-muted">
Release reason <span className="font-normal">Optional; recorded in the audit trail</span>
<Input value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder="Deal changed, hold lapsed…" />
</label>
<div className="flex flex-col gap-1.5">
<Label as="label" htmlFor={releaseReasonId}>Release reason</Label>
<Input
id={releaseReasonId}
aria-describedby={`${releaseReasonId}-hint`}
value={releaseReason}
onChange={(event) => setReleaseReason(event.target.value)}
placeholder="Deal changed, hold lapsed…"
/>
<p id={`${releaseReasonId}-hint`} className="text-xs text-muted">
Optional; recorded in the audit trail.
</p>
</div>
</div>
)}
{releaseError ? <ServerError message={releaseError} /> : null}
</section>
</Section>
) : null}
{save.isError ? <ServerError message={errorMessage(save.error)} /> : null}
@@ -539,16 +550,33 @@ export function AllocationSheet({
);
}
/**
* A field label on PIG's micro-label spec, wired to react-hook-form's id.
*
* `FormLabel` wears shadcn's 14px sentence-case label. The moment anything else
* on this sheet adopted `Label`, the same sheet carried two label styles — the
* commitment panel's figures small-caps, the form fields above them not — which
* is exactly the drift the primitive exists to stop. `useFormField` supplies the
* generated id and the error state, so nothing about the wiring is re-invented,
* only the type.
*/
function FieldLabel({ children }: { children: ReactNode }) {
const { formItemId, error } = useFormField();
return (
<Label as="label" htmlFor={formItemId} className={error ? 'text-danger' : undefined}>
{children}
</Label>
);
}
function CommitmentContext({ row, detail, match, quotedPrice }: { row: AvailabilityRow; detail?: CommitmentRow; match?: MatchRow; quotedPrice: number | null }) {
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
const breakEvenDollars = row.breakEvenPriceCents == null ? null : row.breakEvenPriceCents / 100;
const delta = quotedPrice != null && Number.isFinite(quotedPrice) && quotedPrice >= 0 && breakEvenDollars != null
? quotedPrice - breakEvenDollars
: null;
const shape = detail?.commitment.shape;
return (
<section className="rounded-xl border border-border bg-surface-2 p-4">
<section className="min-w-0 rounded-xl border border-border bg-surface-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words font-semibold leading-snug">{row.name}</p>
@@ -556,40 +584,43 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
</div>
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours available`}>
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
<div><p className="text-muted">Held</p><p className="nums mt-0.5 font-medium">{compactNumber(row.heldGpuHours)} hrs</p></div>
<div><p className="text-muted">Available</p><p className="nums mt-0.5 font-medium">{compactNumber(row.availableGpuHours)} hrs</p></div>
<UtilisationBar
className="mt-4"
sold={row.soldGpuHours}
held={row.heldGpuHours}
total={row.totalGpuHours}
label={row.name}
/>
<div className="mt-2 grid grid-cols-3 gap-2">
<Stat size="sm" surface="bare" label="Sold" value={`${compactNumber(row.soldGpuHours)} hrs`} />
<Stat size="sm" surface="bare" label="Held" value={`${compactNumber(row.heldGpuHours)} hrs`} />
<Stat size="sm" surface="bare" label="Available" value={`${compactNumber(row.availableGpuHours)} hrs`} />
</div>
<Separator className="my-4" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-muted">Contract window</dt><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
<dl className="grid grid-cols-2 items-baseline gap-x-4 gap-y-2 text-sm">
<Label as="dt">Contract window</Label><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
<Label as="dt">Capacity shape</Label><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
<Label as="dt">Our cost</Label><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<Label as="dt">Remaining-block break even</Label><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><Label as="dt">Recorded oversubscription</Label><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
{delta != null ? <><Label as="dt">Quote vs break even</Label><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
</dl>
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-sm text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
<p className="mt-4 text-xs leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
</section>
);
}
function TextField<T extends FieldValues>({ control, name, label, className, ...props }: { control: Control<T>; name: FieldPath<T>; label: string; className?: string } & Omit<React.ComponentProps<typeof Input>, 'name' | 'value' | 'defaultValue'>) {
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FormLabel>{label}</FormLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FieldLabel>{label}</FieldLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
}
function SelectField<T extends FieldValues>({ control, name, label, options }: { control: Control<T>; name: FieldPath<T>; label: string; options: readonly string[] }) {
return <FormField control={control} name={name} render={({ field }) => <FormItem><FormLabel>{label}</FormLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger className="h-11"><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
return <FormField control={control} name={name} render={({ field }) => <FormItem><FieldLabel>{label}</FieldLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
}
function ServerError({ message }: { message: string }) {
return <div role="alert" className="flex gap-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
return <div role="alert" className="flex gap-3 rounded-md border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
}
function errorMessage(error: unknown): string {
+6 -3
View File
@@ -97,14 +97,17 @@ export function AppHeader() {
aria-haspopup="dialog"
aria-expanded={commandOpen}
className={cn(
'hidden h-9 min-w-0 items-center gap-2 rounded-md border border-input bg-surface-2 px-2.5',
'text-left text-sm text-muted shadow-sm transition-colors hover:text-fg md:flex md:w-56 lg:w-72',
// A control, so 12px radius and the 44px floor the rest of the
// product holds — it reads as a field but it is a button, and a
// button that is 36px tall is one this system does not have.
'hidden h-11 min-w-0 items-center gap-2 rounded-lg border border-border bg-surface-2 px-3',
'text-left text-sm text-muted transition-colors duration-1 ease-enter hover:text-fg md:flex md:w-56 lg:w-72',
)}
onClick={() => setCommandOpen(true)}
>
<Search className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
<kbd className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-xs">
K
</kbd>
</button>
+4 -4
View File
@@ -13,7 +13,7 @@ import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { AccountSwitcher } from './AccountSwitcher';
import { Button } from './ui';
import { Button, Label } from './ui';
import {
Sidebar,
SidebarContent,
@@ -43,9 +43,9 @@ export function AppSidebar() {
device where neither is discoverable. */}
{isMobile ? (
<div className="flex items-center justify-between pl-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Navigate
</span>
{/* Same spec as every other group heading in the rail — this one
had its own copy of the retired 10px/600/0.16em values. */}
<Label>Navigate</Label>
<Button
type="button"
variant="ghost"
+6 -3
View File
@@ -40,7 +40,7 @@ export function AudioControl({ className }: { className?: string }) {
aria-pressed={enabled}
aria-label={label}
title={label}
className={cn('h-9 w-9 min-h-0 min-w-0', enabled ? 'text-fg' : 'text-muted')}
className={cn(enabled ? 'text-fg' : 'text-muted')}
>
{enabled ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
</Button>
@@ -52,9 +52,12 @@ export function AudioControl({ className }: { className?: string }) {
variant="ghost"
size="icon"
aria-label="Choose a track"
className="-ml-1.5 h-9 w-5 min-h-0 min-w-0 text-muted"
// The track list used to hang off a 20px-wide caret, which is the
// smallest target in the product and sits on the sign-in screen,
// where a mis-tap mutes the music instead of opening the list.
className="-ml-2 text-muted"
>
<ChevronDown className="size-3" />
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
+30 -23
View File
@@ -11,7 +11,18 @@ import {
} from 'lucide-react';
import { NOTIFICATION_KINDS, type NotificationKind } from '@pig/core';
import { api, get, post } from '@/lib/api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Label as MicroLabel,
} from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
@@ -263,8 +274,7 @@ function ChannelLinkManager({
) : (
<form className="flex flex-col gap-4" onSubmit={submit}>
{provider === 'slack' ? (
<label className="flex flex-col gap-1.5" htmlFor="slack-workspace">
<span className="text-sm font-medium">Workspace ID</span>
<FormField label="Workspace ID">
<Input
id="slack-workspace"
value={workspace}
@@ -272,10 +282,10 @@ function ChannelLinkManager({
placeholder="T0123456789"
required
/>
</label>
</FormField>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Relay workspace</span>
<div className="flex min-w-0 flex-col gap-1.5">
<MicroLabel>Relay workspace</MicroLabel>
<div className="flex min-h-11 items-center rounded-lg border border-border bg-surface-2 px-3 text-sm text-muted">
{workspaceId}
</div>
@@ -283,10 +293,7 @@ function ChannelLinkManager({
)}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-id`}>
<span className="text-sm font-medium">
{provider === 'slack' ? 'Channel ID' : 'Channel UUID'}
</span>
<FormField label={provider === 'slack' ? 'Channel ID' : 'Channel UUID'}>
<Input
id={`${provider}-channel-id`}
value={channelId}
@@ -294,22 +301,20 @@ function ChannelLinkManager({
placeholder={provider === 'slack' ? 'C0123456789' : '00000000-0000-…'}
required
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-name`}>
<span className="text-sm font-medium">Display name</span>
</FormField>
<FormField label="Display name">
<Input
id={`${provider}-channel-name`}
value={channelName}
onChange={(event) => setChannelName(event.target.value)}
placeholder="gpu-sales"
/>
</label>
</FormField>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${provider}-account`}>Account</Label>
<FormField label="Account">
<Select value={accountId} onValueChange={setAccountId} required>
<SelectTrigger id={`${provider}-account`} className="h-11">
<SelectTrigger id={`${provider}-account`}>
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
@@ -322,10 +327,10 @@ function ChannelLinkManager({
</SelectGroup>
</SelectContent>
</Select>
</div>
</FormField>
<fieldset className="flex flex-col gap-1.5">
<legend className="text-sm font-medium">Notify this channel</legend>
<MicroLabel as="legend">Notify this channel</MicroLabel>
<div className="grid gap-2 sm:grid-cols-2">
{NOTIFICATION_KINDS.map((kind) => {
const checked = notifyOn.includes(kind);
@@ -367,12 +372,14 @@ function ChannelLinkManager({
)}
<div className="flex flex-col gap-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted">Linked channels</h4>
<MicroLabel as="h4">Linked channels</MicroLabel>
{linksLoading ? <p className="text-sm text-muted">Loading links</p> : null}
{!linksLoading && links.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted">
No {provider === 'slack' ? 'Slack' : 'Buzz'} channels linked yet.
</p>
<EmptyState
size="inline"
className="rounded-xl border border-dashed border-border"
title={`No ${provider === 'slack' ? 'Slack' : 'Buzz'} channels linked yet`}
/>
) : null}
{links.map(({ link, accountName }) => (
<div
+13 -7
View File
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Link2, LoaderCircle, Unplug } from 'lucide-react';
import { api, get, post } from '@/lib/api';
import { Badge, Button } from '@/components/ui';
import { Badge, Button, EmptyState } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import {
Select,
SelectContent,
@@ -87,7 +88,12 @@ export function NotionImportSource({
});
if (status.data && !status.data.configured) {
return <div className="rounded-xl border border-dashed border-border p-4"><p className="text-sm font-medium">Notion is not configured</p><p className="mt-1 text-xs text-muted">An operator must set the Notion OAuth environment variables and encryption key on the API server.</p></div>;
return <EmptyState
size="inline"
className="rounded-xl border border-dashed border-border"
title="Notion is not configured"
description="An operator must set the Notion OAuth environment variables and encryption key on the API server."
/>;
}
return (
@@ -97,13 +103,13 @@ export function NotionImportSource({
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-border bg-surface"><Database className="size-5" aria-hidden /></span>
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><p className="font-medium">Notion database</p>{status.data?.connected ? <Badge tone="positive">Connected</Badge> : null}</div><p className="mt-0.5 text-xs text-muted">Choose a shared data source, then map it through the same dry run as a spreadsheet.</p></div>
</div>
{!status.data?.connected ? <Button className="min-h-11" type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
{!status.data?.connected ? <Button type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
</div>
{status.data?.connected ? <div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto_auto] lg:items-end">
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Workspace<Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Database<Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger className="h-11"><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<Button className="min-h-11" type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
<Button className="min-h-11" type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
<FormField label="Workspace"><Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField>
<FormField label="Database"><Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField>
<Button type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
<Button type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
</div> : null}
{connect.error || dataSources.error || materialize.error || disconnect.error ? <p role="alert" className="mt-3 text-sm text-danger">{errorMessage(connect.error ?? dataSources.error ?? materialize.error ?? disconnect.error)}</p> : null}
</div>
+407 -105
View File
@@ -1,9 +1,17 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bot, CircleStop, Database, Loader2, MessageCircleMore, Send, Sparkles, XCircle } from 'lucide-react';
import { CircleStop, Database, Loader2, Send, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision } from '@pig/core';
import { get } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { PIGGY_ASK_LABEL, piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import {
PIGGY_MESSAGE_MAX_LENGTH,
@@ -17,7 +25,7 @@ import {
type PiggyStatus,
type TranscriptMessage,
} from '@/lib/piggy-chat';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } from '@/lib/piggy-suggestions';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps } from '@/lib/piggy-suggestions';
import { PiggyApprovalCard } from './piggy/approval-card';
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
import { PiggyMessageActions } from './piggy/message-actions';
@@ -25,6 +33,8 @@ import { PiggyReasoning } from './piggy/reasoning';
import { PiggyResponse } from './piggy/response';
import { PiggyToolStep } from './piggy/tool';
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './piggy/workspace/controls';
import { PiggyWorkspaceStarters } from './piggy/workspace/starters';
import { PiggyMark } from './PiggyMark';
import { Button, Badge, EmptyState, cn } from './ui';
import {
Drawer,
@@ -50,10 +60,78 @@ import { Textarea } from './ui/textarea';
*/
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;
/**
* How narrow a Piggy surface has to be before it takes the tight treatment.
*
* 28rem, and measured on the panel rather than the window, because the four
* surfaces this panel is drawn in disagree with the viewport: the dock is 22rem
* inside a 1440px window, the phone drawer is 393px inside a 393px one. It used
* to be a prop that only the dock passed, so the narrowest surface in the
* product — the drawer a phone gets — was drawing the roomy layout.
*/
const COMPACT_WIDTH = 448;
/** `max-h-36` on the field, in pixels, for the auto-grow below to cap at. */
const COMPOSER_MAX_HEIGHT = 144;
// ------------------------------------------------------------- one front door
/**
* A question a page asked on the user's behalf.
*
* `id` is new on every press so that pressing the same button twice puts the
* question back in the composer, rather than the second press doing nothing
* because the request happened to be identical.
*/
export interface PiggyAsk {
id: number;
context?: PiggyChatContext;
prompt?: string;
}
let nextAskId = 1;
const askListeners = new Set<(ask: PiggyAsk) => void>();
/**
* Ask Piggy something from anywhere, on whichever surface Piggy is showing.
*
* PIG used to have two front doors on the same row of pixels: the pig in the
* header opened the dock, and a speech bubble labelled "Ask Piggy" beside it
* opened a *different* Piggy in a sheet on top of it. Two surfaces, two
* transcripts, one agent — and no sentence that told them apart.
*
* So a page button no longer opens anything. It publishes the question, and
* the one Piggy this viewport has room for takes it: the docked column above
* 1280px, the sheet or drawer below it. The question is seeded into the
* composer and never sent, because the user asked for a starting point, not
* for a turn to be spent on their behalf.
*/
export function requestPiggyAsk(request: Omit<PiggyAsk, 'id'>): void {
const ask: PiggyAsk = { ...request, id: nextAskId++ };
for (const listener of askListeners) listener(ask);
}
/** Subscribe to those requests for as long as this component is mounted. */
export function usePiggyAskRequests(handler: (ask: PiggyAsk) => void): void {
const latest = useRef(handler);
// Written in a layout effect rather than during render: a handler captured
// during a render React later discards would answer with stale state.
useLayoutEffect(() => {
latest.current = handler;
});
useEffect(() => {
const listener = (ask: PiggyAsk) => latest.current(ask);
askListeners.add(listener);
return () => {
askListeners.delete(listener);
};
}, []);
}
export function PiggyAskButton({
context,
prompt,
label = 'Ask Piggy',
label = PIGGY_ASK_LABEL,
variant = 'outline',
}: {
context?: PiggyChatContext;
@@ -61,7 +139,6 @@ export function PiggyAskButton({
label?: string;
variant?: React.ComponentProps<typeof Button>['variant'];
}) {
const [open, setOpen] = useState(false);
const status = usePiggyStatus();
const unavailable = status.data && !status.data.canUse;
// An explicit prop always wins. Every existing call site passes the record
@@ -69,24 +146,16 @@ export function PiggyAskButton({
// that must never displace it.
const ambient = usePiggyCurrentContext();
return (
<>
<Button
type="button"
variant={variant}
disabled={Boolean(unavailable)}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
onClick={() => setOpen(true)}
>
<MessageCircleMore aria-hidden />
{label}
</Button>
<ResponsivePiggyChat
open={open}
onOpenChange={setOpen}
context={context ?? ambient}
initialPrompt={prompt}
/>
</>
<Button
type="button"
variant={variant}
disabled={Boolean(unavailable)}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
onClick={() => requestPiggyAsk({ context: context ?? ambient, prompt })}
>
<PiggyMark className="size-4" />
{label}
</Button>
);
}
@@ -99,7 +168,7 @@ export function PiggyAskButton({
export function PiggyUnavailable({ status }: { status: PiggyStatus | undefined }) {
return (
<EmptyState
icon={<Bot />}
icon={<PiggyMark className="size-6" />}
title="Piggy is unavailable"
description={
status?.enabled
@@ -115,11 +184,14 @@ export function ResponsivePiggyChat({
onOpenChange,
context,
initialPrompt,
seed,
}: {
open: boolean;
onOpenChange(open: boolean): void;
context?: PiggyChatContext;
initialPrompt?: string;
/** A later question from a page's Ask Piggy button. See `PiggyChatPanel`. */
seed?: PiggySeed;
}) {
// The same breakpoint the shell switches navigation at. It used to be `md`,
// which meant a 900px tablet got the desktop side sheet sliding in behind
@@ -131,15 +203,28 @@ export function ResponsivePiggyChat({
// destroyed the conversation, the draft and any answer still streaming — and
// with the controls inside, the mode went with it.
const { conversation, controls } = usePiggyChatSession({ context, initialPrompt });
const subtitle = context
? `Working from ${contextLabel(context)}`
: 'Working from your PIG workspace';
if (desktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
<SheetHeader className="border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]">
<SheetTitle>Ask Piggy</SheetTitle>
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
{/* `band` rather than a hand-rolled top strip: the padding, the rule
and the safe-area inset are the overlay primitive's job, and this
file was one of four keeping its own copy of them. */}
<SheetHeader band>
<SheetTitle>{PIGGY_ASK_LABEL}</SheetTitle>
<SheetDescription>{subtitle}</SheetDescription>
</SheetHeader>
<PiggyChatPanel conversation={conversation} controls={controls} context={context} autoFocusComposer className="min-h-0 flex-1" />
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
seed={seed}
autoFocusComposer
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
);
@@ -147,41 +232,59 @@ export function ResponsivePiggyChat({
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[92dvh]">
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
<DrawerTitle>Ask Piggy</DrawerTitle>
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
<DrawerHeader className="border-b border-border px-5 py-4 text-left">
<DrawerTitle>{PIGGY_ASK_LABEL}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
{/* No autofocus on the phone: focusing the composer raises the keyboard
over most of the drawer before the user has read anything. */}
<PiggyChatPanel conversation={conversation} controls={controls} context={context} className="min-h-0 flex-1" />
over most of the drawer before the user has read anything. A seeded
question fills the composer here too, but silently — see the seed
effect in `PiggyChatPanel`. */}
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
seed={seed}
className="min-h-0 flex-1"
/>
</DrawerContent>
</Drawer>
);
}
/** A question handed to the composer from outside. See `requestPiggyAsk`. */
export interface PiggySeed {
/** Changes on every request, so the same question can be seeded twice. */
id: number;
text?: string;
}
/**
* The transcript and composer. Width-agnostic on purpose — it is used at a
* full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
*
* `compact` is for the dock only. At 22rem the ordinary spacing does not fail,
* it just crowds: the assistant avatar takes a tenth of the line, a user
* bubble at 88% leaves no gutter to read the alignment from, and the
* suggestion buttons wrap to three lines each.
* It measures itself rather than being told how much room it has. At 22rem the
* ordinary spacing does not fail, it just crowds: the assistant avatar takes a
* tenth of the line, a user bubble at 88% leaves no gutter to read the
* alignment from, and the suggestion chips wrap to three lines each. That used
* to be a `compact` prop, which only the dock ever passed — so the phone
* drawer, the narrowest surface in the product, drew the roomy layout while a
* 22rem column inside a 1440px window drew the tight one. The surface's own
* width is the honest question, and it is the one asked here.
*/
export function PiggyChatPanel({
context,
initialPrompt = '',
className,
compact = false,
conversation,
controls,
emptyState,
seed,
autoFocusComposer = false,
}: {
context?: PiggyChatContext;
initialPrompt?: string;
className?: string;
compact?: boolean;
/**
* A conversation owned by something that outlives this panel. The overlays
* pass one because they unmount their children on close; the dock and the
@@ -199,8 +302,13 @@ export function PiggyChatPanel({
* simply shows no controls — the surface above it has them.
*/
controls?: PiggyControlsState;
/** Replaces the default openers. The workspace has a bigger front door. */
/**
* Replaces the default openers. The workspace passes its own so it can wire
* the escalation to the thread that owns the mode.
*/
emptyState?: ReactNode;
/** A question from a page's Ask Piggy button, seeded into the composer. */
seed?: PiggySeed;
autoFocusComposer?: boolean;
}) {
// Called unconditionally — hooks must be — and then ignored when a
@@ -209,14 +317,34 @@ export function PiggyChatPanel({
const active = conversation ?? own;
const { messages, draft, setDraft, running, send, stop, retry, approve } = active;
const composerRef = useRef<HTMLTextAreaElement | null>(null);
// The dock keeps one. Nothing fits two on a line at 22rem, so the second is a
// whole extra row of chrome taken off the shortest transcript of the three.
const followUps = messages.length
? piggyFollowUps(context, userQuestions(messages)).slice(0, compact ? 1 : PIGGY_FOLLOW_UP_COUNT)
: [];
const rootRef = useRef<HTMLDivElement | null>(null);
const compact = useIsNarrow(rootRef);
// Never on a phone, however the composer was filled: focusing a textarea
// raises the keyboard over the drawer the user has not read yet.
const isMobile = useIsMobile();
useEffect(() => {
if (!autoFocusComposer) return;
/*
* A pending approval owns the conversation until it is answered.
*
* The chips offer a different question, and offering one under a card that
* is asking whether to write to the book invites the user to walk away from
* a decision that is still open.
*/
const awaitingApproval = messages.some((message) =>
message.approvals?.some((approval) => approval.state === 'pending' || approval.state === 'submitting'),
);
// One chip on a narrow surface. Nothing fits two on a line at 22rem, so the
// second is a whole extra row of chrome taken off the shortest transcript in
// the product.
const followUps =
messages.length && !awaitingApproval
? piggyFollowUps(context, userQuestions(messages)).slice(
0,
compact ? 1 : PIGGY_FOLLOW_UP_COUNT,
)
: [];
const focusComposer = useCallback(() => {
const composer = composerRef.current;
if (!composer) return;
// Radix moves focus to the first tabbable element in the sheet — its own
@@ -229,10 +357,100 @@ export function PiggyChatPanel({
composer.setSelectionRange(composer.value.length, composer.value.length);
});
return () => cancelAnimationFrame(frame);
}, [autoFocusComposer]);
}, []);
useEffect(() => {
if (!autoFocusComposer) return;
return focusComposer();
}, [autoFocusComposer, focusComposer]);
/*
* A write opener pressed while Piggy is in Read only.
*
* The mode has to be committed before the turn leaves, because `send` reads
* it off the conversation — so the text is parked here and sent by the effect
* below once the conversation is actually holding the new mode. Sending in
* the same tick would ask Piggy to change something with the write tools
* still withheld, and the answer would be a polite refusal.
*/
const [escalating, setEscalating] = useState<string | null>(null);
const conversationMode = active.mode;
const askWithChange = useCallback(
(text: string) => {
if (!controls?.canWrite) return;
if (conversationMode === 'read_only') {
controls.setMode('confirm');
setEscalating(text);
return;
}
send(text);
},
[controls, conversationMode, send],
);
useEffect(() => {
if (escalating === null) return;
if (conversationMode === 'read_only') return;
setEscalating(null);
send(escalating);
}, [escalating, conversationMode, send]);
/*
* The composer grows with what is in it, up to `COMPOSER_MAX_HEIGHT`.
*
* It never did, and a seeded question made that impossible to ignore: a page
* button hands the composer a whole sentence, the caret lands at the end, and
* a 44px box scrolled to the caret shows the user the middle of a question
* they have not read yet — with the first line sliced through the middle of
* its letters. Growing is also what the `max-h-36` already on the field was
* plainly written for.
*/
useLayoutEffect(() => {
const composer = composerRef.current;
if (!composer) return;
const fit = () => {
// Collapse first: `scrollHeight` on an already-tall box reports the
// height it currently has, so without this the field can only ever grow.
composer.style.height = 'auto';
composer.style.height = `${Math.min(composer.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
};
fit();
/*
* Again next frame, and again on every resize.
*
* An inline height measured once at mount is a height that goes stale, and
* measurably did: on a 393px-wide phone the first pass read 62px for an
* empty single-row field that measures 42px a frame later, so the composer
* settled 20px taller than its own content for the life of the mount — and
* a phone rotated after mount kept the height it was given in the other
* orientation. The listener costs nothing and the read is cheap; a stale
* measurement on the control the whole company types into is not.
*/
const frame = requestAnimationFrame(fit);
window.addEventListener('resize', fit);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', fit);
};
// `compact` too: it flips when the surface's measured width crosses the
// narrow threshold, which is the other way this field's line count changes
// without a keystroke.
}, [draft, compact]);
const seedId = seed?.id;
const seedText = seed?.text;
useEffect(() => {
if (seedId == null) return;
// Replaces the draft rather than appending to it: the user pressed a
// button asking for this exact question, and a half-typed line joined to a
// canned one is a sentence neither of them wrote.
if (seedText) setDraft(seedText);
if (isMobile) return;
return focusComposer();
}, [seedId, seedText, setDraft, focusComposer, isMobile]);
return (
<div className={cn('flex min-h-0 flex-col', className)}>
<div ref={rootRef} className={cn('flex min-h-0 flex-col', className)}>
{/* The viewport owns the scrolling, the log role and the follow-the-tail
behaviour. There is deliberately no scroll effect left in this file:
the `scrollIntoView` it replaced fired once per streamed token, which
@@ -251,14 +469,46 @@ export function PiggyChatPanel({
*/
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain py-5',
compact ? 'px-3' : 'px-4 sm:px-5',
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain',
// Measured: at 393x852 the workspace's front door overran its own
// scrollport by 32px, which cut the last note in half — a sliced
// line of type reads as a rendering fault rather than as something
// to scroll to. Sixteen of those pixels are here.
compact ? 'px-3 py-3' : 'px-4 py-5 sm:px-5',
)}
>
{emptyState ?? <PiggyStarters compact={compact} context={context} onAsk={send} />}
{/* One front door. The dock used to draw its own openers — three
read questions under a Sparkles glyph, with no mention that
Piggy can write, which is the product's headline capability
missing from the surface people keep open all day. */}
{emptyState ?? (
<PiggyWorkspaceStarters
context={context}
mode={controls?.mode ?? conversationMode}
canWrite={controls?.canWrite ?? false}
onAsk={send}
onAskWithChange={askWithChange}
narrow={compact}
/>
)}
</div>
) : (
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
<PiggyConversation
/*
* `busy` is dropped the moment a proposal is parked, not when the
* turn ends. A write tool holds its call open across the whole
* approval, so `running` stays true for as long as the card is on
* screen — and `aria-busy` on the live-region ROOT is precisely the
* flag an assistive technology consults before deciding whether to
* speak what arrived inside it. Left set, the one announcement the
* product's entire safety argument rests on ("Piggy wants to change
* this; nothing has happened yet") was the announcement being
* suppressed. The stream is still running; it is running *waiting for
* this person*, which is not the state the attribute means.
*/
busy={running && !awaitingApproval}
className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}
>
<div
// The column is capped at a reading measure rather than filling the
// page: at 1440 the workspace panel is over a thousand pixels wide,
@@ -281,7 +531,21 @@ export function PiggyChatPanel({
</PiggyConversation>
)}
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); send(); }}>
{/*
The composer's short-viewport diet.
Measured on a stored thread: at 852x393 this form was 173px of a 393px
screen and at 393x390 it was 171px of 390 — 44% of the viewport, on the
page whose point is the transcript above it. Nothing in it was wrong;
there was simply no gate on a screen with no room. So under
`(max-height: 500px)` the two pieces that are conveniences give way —
the follow-up suggestions and the standing safety sentence — and the
padding halves, leaving the textarea, the Send button and the counter,
which are the parts a person on a landscape phone actually came for.
The sentence is a standing reminder, not a warning about this turn; the
approval card states the stakes at the moment they exist.
*/}
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4', '[@media(max-height:500px)]:p-2')} onSubmit={(event) => { event.preventDefault(); send(); }}>
{/* The same measure the transcript is set to. Without it the composer
ran the full width of the workspace pane while every answer above it
stopped at 48rem, so the box you type into and the column you read
@@ -292,7 +556,7 @@ export function PiggyChatPanel({
// than every surface but the full page, and a chip sliced off by the
// panel edge reads as a rendering fault — where a second line reads
// as a second suggestion.
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
<section className="mb-2 flex flex-wrap gap-1.5 [@media(max-height:500px)]:hidden" aria-label="Suggested questions">
{followUps.map((suggestion) => (
<button
key={suggestion}
@@ -301,16 +565,23 @@ export function PiggyChatPanel({
// anything mid-stream, and a row that vanishes and returns
// moves the composer under the user's thumb.
disabled={running}
// Each chip is one line whatever the width, so the row can only
// ever be as tall as the number of suggestions.
// Wraps rather than truncating, and 12px-radius rather than a
// pill, because a chip cut off mid-word — "How much of this
// block is still un…" — is a question nobody can decide
// whether they want asked. A second line costs less than a
// suggestion nobody presses.
title={suggestion}
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
className={cn(
'flex min-h-11 max-w-full items-center rounded-lg border border-border px-3 py-2',
'text-left text-xs leading-4 text-muted transition-colors duration-1 ease-enter',
'hover:bg-surface-2 hover:text-fg disabled:opacity-50',
)}
onClick={() => send(suggestion)}
>
{suggestion}
</button>
))}
</div>
</section>
) : null}
{/* Above the textarea, not below it: these decide what the next turn may
do, and they are read at the moment the send button is looked at.
@@ -344,26 +615,59 @@ export function PiggyChatPanel({
}
}}
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
className="min-h-11 max-h-36 resize-none"
placeholder="Ask about capacity, margin, paper or next actions…"
/*
* One row at rest, not the element default of two.
*
* The effect above sizes the field to its own `scrollHeight`, and
* `scrollHeight` counts `rows` — so an empty composer was 64px, a
* blank second line the user has not typed, beside a 44px Send
* button it therefore never lined up with. On a 390px keyboard-up
* screen those 20px are 5% of the viewport taken from the
* transcript, which is what made this worth chasing; on every
* other surface it is simply the composer finally matching the
* 44px floor it already declares. It still grows to `max-h-36`
* from the first keystroke, so nothing about typing changes.
*/
rows={1}
// `overflow-y-auto` so the field scrolls once it has grown to its
// cap rather than hiding the rest of a long paste.
className="min-h-11 max-h-36 resize-none overflow-y-auto"
placeholder={piggyLine(piggyCopy.composerPlaceholder, compact)}
aria-label="Message Piggy"
/>
{running ? (
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><CircleStop aria-hidden /></Button>
<Button
type="button"
size="icon"
variant="outline"
aria-label="Stop Piggy"
// The Stop button is replaced by Send the instant the turn
// ends, so focus was landing on `<body>` — on the workspace,
// 121 Tab presses from the composer the user was about to type
// the next question into.
onClick={() => {
stop();
focusComposer();
}}
>
<CircleStop aria-hidden />
</Button>
) : (
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
)}
</div>
<div className="mt-2 flex items-baseline gap-2 text-[11px] leading-4 text-muted">
<div className="mt-2 flex items-baseline gap-2 text-xs leading-4 text-muted [@media(max-height:500px)]:mt-0">
{/* No longer "Read-only session": Piggy writes now, and what it may
do this turn is stated by the mode control above rather than by a
line of copy that would have to be kept in step with it. What is
left is the part that is true in every mode. */}
<p className="flex-1 text-center">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
<p className="flex-1 text-center [@media(max-height:500px)]:hidden">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
{/* No live region: this changes on every keystroke, and the cap is
already announced from the textarea's own `maxLength`. */}
already announced from the textarea's own `maxLength`.
`ml-auto` keeps it right-aligned on a short viewport, where the
sentence that was pushing it there is gone. */}
{draft.length >= COUNTER_VISIBLE_FROM ? (
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
<p className={cn('ml-auto shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
</p>
) : null}
@@ -375,43 +679,26 @@ export function PiggyChatPanel({
}
/**
* The blank transcript.
* How wide the panel actually is, as a boolean.
*
* The openers come from `piggySuggestions`, which chooses them by the one read
* tool this context resolves to rather than by what the page is called — so
* every line offered here is one Piggy can actually ground. The dock takes
* three of them: at 22rem each opener wraps to two lines, and a fourth turns a
* quick way in into a page of text to read before typing.
* A media query cannot answer this: the dock is 22rem inside a 1440px window,
* and asking the window would give the roomy layout to a column that has no
* room. Measured in a layout effect so the first paint is already correct —
* a panel that renders roomy and reflows to tight one frame later is a panel
* that visibly twitches every time an overlay opens.
*/
function PiggyStarters({
context,
compact,
onAsk,
}: {
context?: PiggyChatContext;
compact: boolean;
onAsk: (text: string) => void;
}) {
const suggestions = piggySuggestions(context);
return (
// `flex-1`, not `h-full`: the conversation's content element is sized by its
// children, so a percentage height here resolves to nothing.
<div className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center text-center">
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
{/* The old line ended "and this chat cannot write CRM records", which
stopped being true the moment the mode control appeared under it. What
is still true is the boundary: PIG's own tools, and nothing else. */}
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools no shell, no filesystem, no browser. Set to Ask first, it also proposes changes for you to approve.</p>
<div className="mt-4 grid w-full gap-2">
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
// Sends rather than fills the composer. Filling it looked like
// nothing had happened, so the chip read as a dead control.
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
))}
</div>
</div>
);
function useIsNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
const [narrow, setNarrow] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => setNarrow(element.getBoundingClientRect().width < COMPACT_WIDTH);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return narrow;
}
/** What the user has already asked, so a follow-up chip cannot offer back a
@@ -448,13 +735,24 @@ function ChatMessage({
</div>
{/* The question is still on screen after a failed send, so the user's
words are never lost — but the bubble alone reads as sent. */}
{message.failed ? <p className="mt-1 text-[11px] leading-4 text-muted">Not sent</p> : null}
{message.failed ? <p className="mt-1 text-xs leading-4 text-muted">Not sent</p> : null}
</div>
);
}
return (
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
{/* The signature on every answer the company will read this year, and
until now it was a stock robot glyph — the one place Piggy's identity
is seen most, wearing somebody else's face. Round, because this is an
avatar and avatars are round in this product. */}
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-full bg-accent-subtle text-accent-fg',
compact ? 'size-7' : 'size-9',
)}
>
<PiggyMark className={compact ? 'size-4' : 'size-5'} />
</div>
{/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
on, and it is repeated here on purpose: the footer marks itself, so
without this the only way to find Copy is to sweep the pointer across
@@ -468,11 +766,15 @@ function ChatMessage({
show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
<PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
{message.tools?.length ? (
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
// `aria-live="off"`: these rows change several times a second while a
// turn runs, and they sit inside the transcript's own live region, so
// without it a screen reader reads out every tool starting and
// finishing before the answer the user asked for arrives.
<section className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity" aria-live="off">
{message.tools.map((tool) => (
<PiggyToolStep key={tool.id} step={tool} />
))}
</div>
</section>
) : null}
{message.content ? <PiggyResponse content={message.content} /> : null}
{/* Below the answer, because the answer is where Piggy says what it
+90 -14
View File
@@ -16,15 +16,22 @@
* runtime is disabled, so a dock that renders its composer without asking
* first is a permanent third of the window that fails on first use.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PanelRightClose, Sparkles } from 'lucide-react';
import { PanelRightClose } from 'lucide-react';
import { useHasDockRoom } from '@/hooks/use-media-query';
import { PIGGY_ASK_LABEL } from '@/lib/piggy-copy';
import { useLayout } from '@/lib/layout';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import type { PiggyChatContext } from '@/lib/piggy-chat';
import { usePiggyChatSession } from './piggy/workspace/controls';
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
import {
PiggyChatPanel,
ResponsivePiggyChat,
usePiggyAskRequests,
usePiggyStatus,
type PiggyAsk,
} from './PiggyChat';
import { PiggyMark } from './PiggyMark';
import { Button, EmptyState, Skeleton, cn } from './ui';
@@ -41,8 +48,30 @@ export function PiggyDock() {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
const ambient = usePiggyCurrentContext();
const { pathname } = useLocation();
const onWorkspace = pathname === PIGGY_WORKSPACE_PATH;
/*
* A question pressed on the page, taken by the column rather than by a
* second Piggy in a sheet on top of it.
*
* The ask carries its own context because a record button knows something
* the route does not: pressing Ask Piggy on a contract row must put the dock
* on that contract, not on the page it happens to be listed under.
*
* Cleared on navigation. A dock still pinned to a contract while you read an
* account is a dock whose badge is lying about what it is looking at, and
* the cost — a record-scoped thread ending when you leave the record — is
* the same cost the ambient key already pays for every record page.
*/
const [ask, setAsk] = useState<PiggyAsk | null>(null);
usePiggyAskRequests((next) => {
if (hasRoom) setAsk(next);
});
useEffect(() => setAsk(null), [pathname]);
const context = ask?.context ?? ambient;
// The remembered `dockOpen` is deliberately left alone: the column comes back
// by itself on the next page, so visiting the workspace does not silently
@@ -86,7 +115,7 @@ export function PiggyDock() {
</div>
) : !status.data?.canUse ? (
<EmptyState
icon={<Sparkles />}
icon={<PiggyMark className="size-6" />}
title="Piggy is unavailable"
description={
status.data?.enabled
@@ -106,6 +135,7 @@ export function PiggyDock() {
<DockThread
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
context={context}
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
/>
)}
</aside>
@@ -120,41 +150,67 @@ export function PiggyDock() {
* docked thread is thrown away and started again, and the mode and model have
* to be bound to whichever conversation that key produced.
*/
function DockThread({ context }: { context: PiggyChatContext }) {
function DockThread({
context,
seed,
}: {
context: PiggyChatContext;
seed?: { id: number; text?: string };
}) {
const { conversation, controls } = usePiggyChatSession({ context });
return (
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
compact
seed={seed}
className="min-h-0 flex-1"
/>
);
}
/**
* The header control for Piggy.
* The header control for Piggy, and the only thing that opens the agent.
*
* Below `xl` there is no column to toggle, so the same button opens the sheet
* or drawer instead — one affordance in one place, whatever the viewport can
* accommodate.
* accommodate. A page's "Ask Piggy" button no longer opens a surface of its
* own: it publishes its question through `requestPiggyAsk` and this control
* decides where Piggy is, which is what stops a pig face and a speech bubble
* on the same row of pixels from opening two different agents.
*/
export function PiggyDockToggle({ className }: { className?: string }) {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const ambient = usePiggyCurrentContext();
const [overlayOpen, setOverlayOpen] = useState(false);
/** The question that opened the overlay, when a page asked one. */
const [ask, setAsk] = useState<PiggyAsk | null>(null);
const unavailable = status.data && !status.data.canUse;
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
usePiggyAskRequests((next) => {
if (unavailable) return;
if (hasRoom) {
// The column takes it from here — `PiggyDock` is subscribed too, and it
// is the surface that holds the thread. All this has to do is make sure
// the column the user is about to be answered in is actually on screen.
setDockOpen(true);
return;
}
setAsk(next);
setOverlayOpen(true);
});
// Nothing for it to open: the whole page is Piggy. Left in the header as a
// dead control it would be the only button in PIG that does nothing when
// pressed — and pressed on the workspace it would toggle a column that
// `PiggyDock` refuses to draw.
if (onWorkspace) return null;
const overlayContext = ask?.context ?? ambient;
return (
<>
<Button
@@ -171,15 +227,35 @@ export function PiggyDockToggle({ className }: { className?: string }) {
? dockOpen
? 'Close the Piggy panel'
: 'Open the Piggy panel'
: 'Ask Piggy'
: PIGGY_ASK_LABEL
}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : 'Piggy'}
onClick={() => (hasRoom ? setDockOpen(!dockOpen) : setOverlayOpen(true))}
onClick={() => {
if (hasRoom) {
setDockOpen(!dockOpen);
return;
}
// Pressing the header control asks about the page, not about the
// record a button last pointed at: the ask is spent.
setAsk(null);
setOverlayOpen(true);
}}
>
<PiggyMark className="size-5" />
</Button>
{hasRoom ? null : (
<ResponsivePiggyChat open={overlayOpen} onOpenChange={setOverlayOpen} context={context} />
<ResponsivePiggyChat
// Keyed on the record the question is about, so a question pressed on
// a contract opens a thread about that contract — and pressing the
// same button again returns to the thread already in progress rather
// than discarding it.
key={overlayContext.type === 'page' ? 'page' : JSON.stringify(overlayContext)}
open={overlayOpen}
onOpenChange={setOverlayOpen}
context={overlayContext}
initialPrompt={ask?.prompt}
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
/>
)}
</>
);
+145
View File
@@ -0,0 +1,145 @@
/**
* A named record, one click from wherever it was named.
*
* Extracted from the approval card, where it was the escape hatch on a pending
* write: before deciding, open the account and check the note is not already
* there. It is here because Piggy's answers need the same thing and do not have
* it — an answer naming "DEMO — 128× H100 reserved, 6 months" and "$399,972.42"
* is inert text, while the Overview renders that same record as a row with
* "Match →". Closing that gap is what turns the transcript from an island that
* knows your data into a surface you can act from.
*
* Two honesty rules, both load-bearing:
*
* The title never promises a record the link cannot open. `/accounts/:id` is
* the only per-record route PIG has, so everything else lands on the list
* that contains the row. "Open the list containing Northwind Robotics" is a
* worse sentence than "Open Northwind Robotics" and a true one.
*
* A record with no route renders as plain text rather than as a dead link.
* Losing the name would be worse — an answer would silently stop mentioning
* the thing it reasoned about — and a chip that goes nowhere is the failure
* `tool.tsx` already names: proving nothing is worse than claiming nothing.
*
* `newTab` exists because the approval card's own escape hatch was destroying
* the proposal it existed to help verify: a same-tab navigation unmounts the
* transcript, and the pending card with it.
*/
import { ArrowUpRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/components/ui';
/**
* Where a record of each kind can be opened.
*
* When the other detail routes land, each of these becomes a one-line edit and
* `opensRecord` grows an entry; this is the only place a record id becomes a
* URL. The keys are wider than `PiggyRecordType` on purpose — a proposed change
* carries `record.type` as free text, and `allocation` and `task` are both
* things a write tool can produce.
*/
export const RECORD_ROUTES: Record<string, string> = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
task: '/calendar',
};
/** The URL for a record, or null when PIG has nowhere to send the reader. */
export function recordHref(type: string, id: string): string | null {
const base = RECORD_ROUTES[type];
if (!base) return null;
return type === 'account' ? `${base}/${id}` : base;
}
/** Whether the link opens the record itself or merely the list holding it. */
export function opensRecord(type: string): boolean {
return type === 'account';
}
export interface RecordLinkProps {
/** Record type as the payload reported it — free text, not a closed union. */
type: string;
id: string;
/** The record's name. Falls back to a neutral noun rather than showing a uuid. */
label?: string;
/**
* Open in a new tab. Off by default: this is the extracted behaviour, and a
* link inside a page that steals the tab is normal. Turn it on where leaving
* would destroy unsubmitted state — the pending approval card, and any link
* inside a streaming transcript.
*/
newTab?: boolean;
/**
* A word before the name: "Check", "Open". Omitted by default so a link
* inside a sentence reads as the record's name and nothing else.
*/
verb?: string;
className?: string;
}
export function RecordLink({ type, id, label, newTab = false, verb, className }: RecordLinkProps) {
const href = recordHref(type, id);
const name = label?.trim() || 'the record';
/*
* `min-h-11` even though this is a text link: it sits in the approval card's
* footer beside two 44px buttons and inside transcript prose, and both are
* places a thumb lands. The 44px floor is the primitive set's oldest rule and
* an inline link is not an exemption from it.
*
* `text-xs` is the approval footer's size and the default here; a caller
* inside 14px prose passes `className="text-sm"` and `cn()` resolves it,
* rather than this growing a size prop for two values.
*/
const shared =
'inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-lg text-xs ' +
'text-muted underline-offset-4 transition-colors duration-1 ease-enter ' +
'hover:text-fg hover:underline focus-visible:text-fg focus-visible:outline-none ' +
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 ' +
'focus-visible:ring-offset-bg';
if (!href) {
return (
<span className={cn('inline-flex max-w-full items-center text-xs text-muted', className)}>
<span className="truncate">{name}</span>
</span>
);
}
const destination = opensRecord(type)
? `Open ${name}`
: `Open the list containing ${name}`;
return (
<Link
to={href}
className={cn(shared, className)}
title={newTab ? `${destination} in a new tab` : destination}
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<span className="truncate">
{verb ? `${verb} ` : ''}
{name}
</span>
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
{/* The title attribute is not announced reliably, and where this link
lands is the one thing a screen-reader user must be told before they
follow it out of a pending approval: that it opens a new tab, and —
for every kind but `account`, which is the only per-record route PIG
has — that it opens the list rather than the row. The honest wording
existed already but lived only in `title`, which is neither visible
nor announced. */}
{opensRecord(type) && !newTab ? null : (
<span className="sr-only">
{opensRecord(type) ? '' : ' (opens the list containing it)'}
{newTab ? ' (opens in a new tab)' : ''}
</span>
)}
</Link>
);
}
+52 -37
View File
@@ -22,7 +22,7 @@ import { LoaderCircle, Lock } from 'lucide-react';
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Badge, Input } from '@/components/ui';
import { Badge, Input, Section } from '@/components/ui';
import { Button } from '@/components/ui/button';
import {
Form,
@@ -363,7 +363,9 @@ const activityFormSchema = z.object({
});
type ActivityForm = z.infer<typeof activityFormSchema>;
const label = (value: string) => value.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
// `ai` is cased because the enum is `applied_ai_startup`, and an option
// reading "Applied ai startup" in a form about GPU capacity looks like a typo.
const label = (value: string) => value.replace(/_/g, ' ').replace(/\bai\b/g, 'AI').replace(/^./, (letter) => letter.toUpperCase());
const blankToNull = (value: string) => value.trim() || null;
const optionalNumber = (value: string) => value === '' ? null : Number(value);
const cents = (value: string) => value === '' ? null : Math.round(Number(value) * 100);
@@ -478,19 +480,19 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp
<TextField control={form.control} name="website" label="Website" placeholder="https://acme.ai" className="sm:col-span-2" />
<TextAreaField control={form.control} name="description" label="Relationship context" placeholder="What they build, what they buy or sell, and why the relationship matters." className="sm:col-span-2" />
</FieldGrid>
<Section title="Commercial geography" description="Headquarters and legal jurisdiction are separate because export controls and data residency attach differently.">
<FormSection title="Commercial geography" description="Headquarters and legal jurisdiction are separate because export controls and data residency attach differently.">
<FieldGrid>
<TextField control={form.control} name="country" label="Headquarters country" />
<TextField control={form.control} name="region" label="Region" />
<TextField control={form.control} name="jurisdiction" label="Legal jurisdiction" className="sm:col-span-2" />
</FieldGrid>
</Section>
<Section title="Ultimate ownership" description="Only enter ownership you can substantiate; the compliance engine must not infer it from headquarters.">
</FormSection>
<FormSection title="Ultimate ownership" description="Only enter ownership you can substantiate; the compliance engine must not infer it from headquarters.">
<FieldGrid>
<TextField control={form.control} name="ultimateParentName" label="Ultimate parent" />
<TextField control={form.control} name="ultimateParentCountry" label="Parent country" />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save account' : 'Create account'} />
</form>
@@ -569,14 +571,14 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco
<TextField control={form.control} name="phone" label="Phone" />
<SwitchField control={form.control} name="isDecisionMaker" label="Decision maker" description="They can materially approve or block this relationship." />
</FieldGrid>
<Section title="Public profiles">
<FormSection title="Public profiles">
<FieldGrid>
<TextField control={form.control} name="linkedinUrl" label="LinkedIn URL" className="sm:col-span-2" />
<TextField control={form.control} name="twitterHandle" label="X / Twitter handle" />
<TextField control={form.control} name="githubHandle" label="GitHub handle" />
<TextField control={form.control} name="websiteUrl" label="Website URL" className="sm:col-span-2" />
</FieldGrid>
</Section>
</FormSection>
<TextAreaField control={form.control} name="confidenceNote" label="Provenance note" description="Use this when the relationship or details need qualification." />
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save contact' : 'Create contact'} />
@@ -640,7 +642,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional className="sm:col-span-2" options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
<TextAreaField control={form.control} name="description" label="Deal context" className="sm:col-span-2" />
</FieldGrid>
<Section title="Commercials" description="Money is converted to integer cents at the API boundary; probability stays independent of stage.">
<FormSection title="Commercials" description="Money is converted to integer cents at the API boundary; probability stays independent of stage.">
<FieldGrid>
<TextField control={form.control} name="acv" label="ACV" inputMode="decimal" prefix="$" />
<TextField control={form.control} name="tcv" label="TCV" inputMode="decimal" prefix="$" />
@@ -649,15 +651,15 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
<TextField control={form.control} name="probability" label="Probability (%)" inputMode="decimal" />
<TextField control={form.control} name="expectedCloseDate" label="Expected close" type="date" />
</FieldGrid>
</Section>
<Section title="Paper and continuity" description="Legal clears early in this market. These flags remain visible after the deal advances.">
</FormSection>
<FormSection title="Paper and continuity" description="Legal clears early in this market. These flags remain visible after the deal advances.">
<FieldGrid>
<SwitchField control={form.control} name="msaExecuted" label="MSA executed" />
<SwitchField control={form.control} name="dpaExecuted" label="DPA executed" />
<SelectField control={form.control} name="parentDealId" label="Parent deal" optional className="sm:col-span-2" options={parentOptions.map((row) => ({ value: row.deal.id, label: row.deal.name }))} />
<TextAreaField control={form.control} name="closedReason" label="Closed reason" description="Record why a deal was won or lost; leave blank while it is open." className="sm:col-span-2" />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save demand deal' : 'Create demand deal'} />
</form>
@@ -714,7 +716,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
<SelectField control={form.control} name="stage" label="Stage" options={SUPPLY_STAGES.map((value) => ({ value, label: SUPPLY_STAGE_LABELS[value] }))} />
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: row.contact.fullName }))} />
</FieldGrid>
<Section title="Capacity on offer" description="These terms describe the opportunity, not booked inventory. A commitment is created only after paper is executed.">
<FormSection title="Capacity on offer" description="These terms describe the opportunity, not booked inventory. A commitment is created only after paper is executed.">
<FieldGrid>
<TextField control={form.control} name="gpuType" label="GPU type" placeholder="H100_80GB" />
<TextField control={form.control} name="gpuCount" label="GPU count" inputMode="numeric" />
@@ -723,15 +725,15 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
<TextField control={form.control} name="termMonths" label="Term (months)" inputMode="numeric" />
<TextField control={form.control} name="availableFrom" label="Available from" type="date" />
</FieldGrid>
</Section>
<Section title="Two-key diligence" description="Technical fitness and financial clearance are independent decisions. Record each verdict in its own voice.">
</FormSection>
<FormSection title="Two-key diligence" description="Technical fitness and financial clearance are independent decisions. Record each verdict in its own voice.">
<FieldGrid>
<TextField control={form.control} name="technicalVerdict" label="Technical verdict" placeholder="Passed, conditional, blocked…" />
<TextField control={form.control} name="financialVerdict" label="Financial verdict" placeholder="Passed, conditional, blocked…" />
<TextAreaField control={form.control} name="technicalNotes" label="Technical notes" />
<TextAreaField control={form.control} name="financialNotes" label="Financial notes" />
</FieldGrid>
</Section>
</FormSection>
<TextAreaField control={form.control} name="rejectionReason" label="Rejection reason" description="Rejections teach the sourcing team. Leave blank unless the deal is rejected." />
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save supply deal' : 'Create supply deal'} />
@@ -835,7 +837,7 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<SelectField control={form.control} name="securityTier" label="Security tier" options={SECURITY_TIERS.map((value) => ({ value, label: label(value) }))} />
<SwitchField control={form.control} name="isContiguous" label="Contiguous block" description="Not one GPU count split across halls." />
</FieldGrid>
<Section title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
<FormSection title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
<FieldGrid>
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
@@ -844,8 +846,8 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<TextField control={form.control} name="currency" label="Currency" maxLength={3} />
<TextField control={form.control} name="oversubscriptionPct" label="Oversubscription allowance (%)" inputMode="decimal" description="Leave blank unless the contract permits selling above the envelope." />
</FieldGrid>
</Section>
<Section title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
</FormSection>
<FormSection title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
<FieldGrid>
<TextField control={form.control} name="minimumSpend" label="Minimum spend" inputMode="decimal" prefix="$" />
<TextField control={form.control} name="takeOrPayFloorPct" label="Take-or-pay floor (%)" inputMode="decimal" />
@@ -855,7 +857,7 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<SwitchField control={form.control} name="isAutoRenew" label="Auto-renews" description="Renewal alerting depends on this being honest." />
<TextAreaField control={form.control} name="notes" label="Commercial notes" className="sm:col-span-2" description="Caveats a seller would need before promising this capacity." />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Record commitment" />
</form>
@@ -970,12 +972,12 @@ export function LogActivitySheet({ open, onOpenChange, identity, defaultAccountI
<TextField control={form.control} name="subject" label="Subject" placeholder="Pricing call on the Q4 renewal" className="sm:col-span-2" />
<TextAreaField control={form.control} name="body" label="Detail" description={ACTIVITY_TYPE_HINTS[type]} className="sm:col-span-2" />
</FieldGrid>
<Section title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
<FormSection title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
<FieldGrid>
<SelectField control={form.control} name="relatedDeal" label="Related deal" optional options={dealOptions} />
<SelectField control={form.control} name="contactId" label="Contact involved" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Log activity" />
</form>
@@ -988,12 +990,18 @@ function RecordSheet({ open, onOpenChange, category, title, description, childre
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 pr-14 text-left sm:px-6 sm:pr-14">
{/*
`band` is the primitive's standard top band — the overlay header
padding, the rule against the body and the top safe-area inset, which
this sheet was hand-rolling at its own values. `pr-14` stays: the
dismiss control is absolutely positioned over this row, and a long
title running under it is the one thing the band cannot know about.
*/}
<SheetHeader band className="pr-14">
<Badge className="mb-1 w-fit" tone="neutral">{category}</Badge>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<Separator />
{children}
</SheetContent>
</Sheet>
@@ -1009,8 +1017,8 @@ function SheetActions({ pending, onCancel, label: actionLabel, disabled = false
<>
<Separator />
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>Cancel</Button>
<Button type="submit" className="h-11" disabled={pending || disabled}>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={pending || disabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : null}
{pending ? 'Saving…' : actionLabel}
</Button>
@@ -1027,7 +1035,7 @@ function SheetActions({ pending, onCancel, label: actionLabel, disabled = false
*/
function PermissionNotice({ children }: { children: React.ReactNode }) {
return (
<div role="status" className="flex gap-3 rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
<div role="status" className="flex gap-3 rounded-md bg-surface-2 p-4 text-sm text-muted">
<Lock className="mt-0.5 size-4 shrink-0" aria-hidden />
<p>{children}</p>
</div>
@@ -1038,15 +1046,22 @@ function FieldGrid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
/**
* A titled group of fields inside a record sheet.
*
* The heading, its size and its description are the primitive's now. This was
* a 14px/600 heading over a 12px `text-muted-foreground` note — a sixth
* section heading, and the only place in the product still reaching for
* shadcn's colour name — so a sheet's groups read a size smaller than the
* panels on the page behind it. The rule and the top inset stay here: they
* separate one group from the next, which is this sheet's layout, not the
* heading's job.
*/
function FormSection({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">{title}</h3>
{description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null}
</div>
{children}
</section>
<div className="border-t border-border pt-6">
<Section title={title} description={description}>{children}</Section>
</div>
);
}
@@ -1082,7 +1097,7 @@ function SelectField<T extends FieldValues>({ control, name, label: fieldLabel,
<FormItem className={className}>
<FormLabel>{fieldLabel}</FormLabel>
<Select value={String(field.value || (optional ? 'none' : ''))} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl><SelectTrigger className="h-11"><SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} /></SelectTrigger></FormControl>
<FormControl><SelectTrigger><SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} /></SelectTrigger></FormControl>
<SelectContent><SelectGroup>
{optional ? <SelectItem value="none">None</SelectItem> : null}
{options.map((option) => <SelectItem key={option.value} value={option.value} disabled={option.disabled}>{option.label}</SelectItem>)}
@@ -1097,7 +1112,7 @@ function SelectField<T extends FieldValues>({ control, name, label: fieldLabel,
function SwitchField<T extends FieldValues>({ control, name, label: fieldLabel, description }: { control: Control<T>; name: FieldPath<T>; label: string; description?: string }) {
return (
<FormField control={control} name={name} render={({ field }) => (
<FormItem className="flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border p-3">
<FormItem className="flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border border-border p-3">
<div className="flex flex-col gap-1"><FormLabel>{fieldLabel}</FormLabel>{description ? <FormDescription>{description}</FormDescription> : null}</div>
<FormControl><Switch checked={Boolean(field.value)} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
+37 -4
View File
@@ -58,6 +58,19 @@ export function Shell() {
} as React.CSSProperties
}
>
{/*
First in the DOM, so the first Tab of a fresh page offers it. Without
it a keyboard reader crosses the header, the whole sidebar and the
Piggy dock — measured at over a hundred stops on /piggy — before
reaching the thing they came for.
*/}
<a
href="#page-content"
className="sr-only text-sm font-medium text-fg focus:not-sr-only focus:absolute focus:left-2 focus:top-[max(0.5rem,var(--safe-top))] focus:z-50 focus:flex focus:min-h-11 focus:items-center focus:rounded-lg focus:bg-surface focus:px-4 focus:shadow-lg focus:ring-2 focus:ring-brand"
>
Skip to content
</a>
<AppHeader />
<div className="flex w-full min-w-0 flex-1">
@@ -69,11 +82,16 @@ export function Shell() {
// their own `md:pb-0` on top of this; keeping `lg` here means they
// still have their padding between md and lg, where the tab bar is
// very much still on screen.
className="pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0"
className="pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0"
>
<div
id="page-content"
tabIndex={-1}
className={cn(
'mx-auto w-full min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-8',
// The one page canvas inset in the product: 16 / 24 / 32 across,
// 24 / 32 down. Pages set their own rhythm inside it and nothing
// else may set a page margin.
'mx-auto w-full min-w-0 px-4 py-6 focus-visible:outline-none sm:px-6 lg:px-8 lg:py-8',
// With Piggy docked the middle pane is already a column in a
// three-column layout; capping it at 7xl and centring it again
// strands the content between two gutters it does not need.
@@ -95,6 +113,20 @@ export function Shell() {
/**
* The phone tab bar. Unchanged in look and behaviour — it is the thing this
* product is best at and the rebuild had no business touching it.
*
* With one exception, and it was a real defect. `WorkspaceRoute` and
* `SidebarInset` both give the bar's 72px padding reserve back under
* `(max-height: 500px)` — a landscape phone, or a phone with the keyboard up,
* cannot spend a fifth of its screen on a bar it can reach by turning the
* handset back — and both files' comments said the bar itself stood down at the
* same height. It did not: it stayed `fixed` at the bottom with nothing holding
* content clear of it, so at 852x393 it painted over the Piggy composer's
* safety line and the lower 23px of the 44px Send button. Confirmed by
* hit-testing, not by a screenshot: `elementFromPoint` at the Send button's
* centre returned a tab-bar link.
*
* Standing down is safe because navigation does not go with it — `SidebarTrigger`
* is in the header on every viewport below `lg` and opens the same nav sheet.
*/
function MobileTabBar({ items }: { items: NavItem[] }) {
return (
@@ -102,6 +134,7 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
'supports-[backdrop-filter]:bg-surface/80',
'[@media(max-height:500px)]:hidden',
)}
style={{ paddingBottom: 'var(--safe-bottom)' }}
aria-label="Primary"
@@ -112,13 +145,13 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
key={item.to}
to={item.to}
end={item.to === '/'}
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-xs font-medium text-muted"
>
{({ isActive }) => (
<>
<span
className={cn(
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors duration-1 ease-enter',
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
)}
>
@@ -6,7 +6,7 @@
* here — so the UI's job is to be legible, and disabling the option would only
* hide a refusal the server is going to make anyway with a better message.
*/
import { useState, type ReactNode } from 'react';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -18,6 +18,7 @@ import {
} from '@pig/core';
import { api } from '@/lib/api';
import { Button, Input } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import {
Dialog,
DialogContent,
@@ -91,7 +92,7 @@ export function AddResourceDialog({
create.mutate();
}}
>
<Field label="Share link" htmlFor="learn-url">
<FormField label="Share link">
<Input
id="learn-url"
value={url}
@@ -100,24 +101,24 @@ export function AddResourceDialog({
autoComplete="off"
spellCheck={false}
/>
</Field>
<Field label="Title" htmlFor="learn-title">
</FormField>
<FormField label="Title">
<Input
id="learn-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</Field>
<Field label="Summary" htmlFor="learn-summary">
</FormField>
<FormField label="Summary">
<Input
id="learn-summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="What someone learns from it"
/>
</Field>
</FormField>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<Field label="Track" htmlFor="learn-track">
<FormField label="Track">
<NativeSelect
id="learn-track"
value={track}
@@ -127,8 +128,8 @@ export function AddResourceDialog({
label: LEARN_TRACK_LABELS[value],
}))}
/>
</Field>
<Field label="Visibility" htmlFor="learn-visibility">
</FormField>
<FormField label="Visibility">
<NativeSelect
id="learn-visibility"
value={visibility}
@@ -138,9 +139,9 @@ export function AddResourceDialog({
label: value === 'code' ? 'Anyone with the code' : 'Members only',
}))}
/>
</Field>
</FormField>
</div>
<Field label="Length in minutes" htmlFor="learn-minutes">
<FormField label="Length in minutes">
<Input
id="learn-minutes"
value={minutes}
@@ -148,7 +149,7 @@ export function AddResourceDialog({
inputMode="decimal"
placeholder="Optional"
/>
</Field>
</FormField>
<div className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
@@ -167,39 +168,22 @@ export function AddResourceDialog({
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
</div>
);
}
function NativeSelect({
id,
value,
onChange,
options,
...field
}: {
id: string;
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
id={id}
{...field}
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-11 w-full min-w-0 rounded-lg border border-border bg-surface px-3 text-base text-fg focus-visible:border-accent"
@@ -57,7 +57,7 @@ export function ArchiveControl({
aria-label={confirming ? `Confirm archiving ${title}` : `Archive ${title}`}
className={cn(
'tap inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium',
'bg-surface/90 backdrop-blur-sm transition-colors disabled:opacity-50',
'bg-surface/90 backdrop-blur-sm transition-colors duration-1 ease-enter disabled:opacity-50',
confirming ? 'text-danger hover:bg-danger/10' : 'text-muted hover:bg-surface-2 hover:text-fg',
className,
)}
@@ -137,7 +137,7 @@ export function LearnPoster({
className={cn(
'inline-flex items-center justify-center rounded-full border border-border',
'bg-surface/85 text-fg shadow-sm backdrop-blur-sm',
'transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105',
'transition-transform duration-1 ease-enter group-hover:scale-105 group-focus-visible:scale-105',
compact ? 'size-9' : 'size-14',
)}
aria-hidden
@@ -41,7 +41,7 @@ export function LearnTrackPanel({
return (
<Card className="flex min-w-0 flex-col gap-5 p-5 sm:p-6">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
<h2 className="text-base font-semibold leading-tight">{heading}</h2>
<p className="min-w-0 max-w-2xl text-sm leading-6 text-muted">{description}</p>
</div>
@@ -24,7 +24,7 @@ export function LearnVideoCard({
}) {
const playback = learnPlayback(resource);
return (
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md">
<button
type="button"
onClick={() => onPlay(resource)}
@@ -9,7 +9,7 @@
*/
import { ChevronRight } from 'lucide-react';
import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui';
import { Badge, Card, Label } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl';
import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster';
@@ -35,7 +35,7 @@ export function LearnWalkthroughList({
const playback = learnPlayback(resource);
return (
<li key={resource.id} className="min-w-0">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md sm:flex-row">
<button
type="button"
onClick={() => onPlay(resource)}
@@ -54,9 +54,7 @@ export function LearnWalkthroughList({
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="nums text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-accent-fg">
Step {index + 1}
</p>
<Label className="nums">Step {index + 1}</Label>
<h3 className="min-w-0 break-words font-semibold leading-snug">
{resource.title}
</h3>
+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>
+481
View File
@@ -0,0 +1,481 @@
/**
* Every status a PIG record can be in, drawn once.
*
* The audit found this file's contents scattered across five others: account
* side and relationship state in `Account.tsx` and again in `Growth.tsx` with
* different tones for the same value, contract status in `Contracts.tsx` and
* again in `Account.tsx` disagreeing about whether "expired" is red, run and
* task state in `activity-panel.tsx`, renewal urgency in `Contracts.tsx`. Two
* implementations of the same badge do not merely duplicate; they contradict,
* and a reader who learns that green means good on one page and nothing on the
* next has learned that colour here is decoration.
*
* So the tones follow one rule, taken from the design direction, and the rule
* is stricter than what any of the five did:
*
* positive a business figure that is good capacity actually running, a
* deal won, an expansion open
* warning something needs a person NOW a notice window already open
* danger a loss, or a failure a person must resolve an account gone, a
* run that failed, an authorisation that has lapsed
* info a neutral time or system fact running, queued, out for signature
* neutral done, fine, nothing to do
*
* The consequence, and it is the point: **process outcomes get no colour.**
* "Executed", "Succeeded", "Closed lost", "Terminated" are all neutral. A
* ledger where every success is green is a ledger nobody scans, and the one
* row that needs a person is invisible in a column of colour.
*
* Colour is never the only signal. Every badge here carries a word, and the
* states that need a person or record a failure carry a mark as well, so the
* scan works without hue: a **circle** for "act on this", a **triangle** for
* "this went wrong". Two different shapes, not two colours of the same one
* lucide's `AlertTriangle` is an alias of `TriangleAlert`, so the obvious pair
* drew the identical glyph twice and the distinction existed only in the hue it
* was supposed to be independent of. Nothing else gets an icon: a badge set
* where every chip has a glyph is a badge set with no emphasis left to spend.
*/
import { CircleAlert, TriangleAlert } from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
SUPPLY_STAGE_LABELS,
type AccountSide,
type ContractStatus,
type CustomerRelationshipState,
type DemandStage,
type GrowthFacet,
type SupplyStage,
} from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { shortDate } from '@/lib/api';
/** The tones `Badge` understands. `accent` is deliberately absent: the accent
* is PIG's identity colour and never carries meaning. */
export type StatusTone = 'neutral' | 'positive' | 'warning' | 'danger' | 'info';
/**
* The two marks, sized to the 12px badge text.
*
* Lucide ships icons at 24px and `Badge` does not size its children, so an
* unsized glyph in a badge renders twice the height of the word beside it
* which is what `Contracts.tsx` and `Growth.tsx` were both doing.
*/
function ActMark() {
return <CircleAlert className="size-3.5" aria-hidden />;
}
function FailMark() {
return <TriangleAlert className="size-3.5" aria-hidden />;
}
/** Underscored enum value to a readable word, for the values with no label map. */
function humanise(value: string): string {
const words = value.replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : value;
}
// --------------------------------------------------------------- account side
/**
* Which side of the book an account sits on.
*
* All three are neutral. Side is identity, not status nothing about being a
* supplier is good or bad or needs anybody and the words already tell them
* apart. `Account.tsx` and `Accounts.tsx` were both painting supply blue and
* both sides accent, which spent two of the five tones on a fact that changes
* nothing a reader would do.
*/
export const SIDE_TONES: Record<AccountSide, StatusTone> = {
supply: 'neutral',
demand: 'neutral',
both: 'neutral',
};
export const SIDE_LABELS: Record<AccountSide, string> = {
supply: 'Buy-side',
demand: 'Sell-side',
both: 'Both sides',
};
export function SideBadge({ side, className }: { side: AccountSide; className?: string }) {
return (
<Badge tone={SIDE_TONES[side]} className={cn('shrink-0', className)}>
{SIDE_LABELS[side]}
</Badge>
);
}
// -------------------------------------------------------- customer lifecycle
/**
* Where a customer relationship stands.
*
* `deployed` is the only good one: capacity is actually running, which is the
* figure the business is built on. `former_customer` is a loss and says so.
* `prospect` and `contracted` are stages on the way, and a stage is not news.
*/
export const RELATIONSHIP_TONES: Record<CustomerRelationshipState, StatusTone> = {
prospect: 'neutral',
contracted: 'neutral',
deployed: 'positive',
former_customer: 'danger',
};
export const RELATIONSHIP_LABELS: Record<CustomerRelationshipState, string> = {
prospect: 'Prospect',
contracted: 'Contracted',
deployed: 'Deployed',
former_customer: 'Former customer',
};
export function RelationshipBadge({
state,
className,
}: {
state: CustomerRelationshipState;
className?: string;
}) {
return (
<Badge tone={RELATIONSHIP_TONES[state]} className={className}>
{RELATIONSHIP_LABELS[state]}
</Badge>
);
}
// ---------------------------------------------------------------- growth facet
/**
* Why an account is on the Growth page.
*
* The old version coloured all six, which made the page a mosaic. Here a
* deadline and a risk are the only things that get a person's attention, and
* the hygiene facets a coverage gap, stale data are grey. Stale data was
* `warning` before, competing for the eye with `at_risk` on the same row.
*/
export const FACET_TONES: Record<GrowthFacet, StatusTone> = {
expansion_candidate: 'positive',
renewal_due: 'warning',
at_risk: 'danger',
idle_supply_match: 'info',
coverage_gap: 'neutral',
data_stale: 'neutral',
};
export const FACET_LABELS: Record<GrowthFacet, string> = {
expansion_candidate: 'Expansion candidate',
renewal_due: 'Renewal due',
at_risk: 'At risk',
idle_supply_match: 'Idle supply match',
coverage_gap: 'Coverage gap',
data_stale: 'Data stale',
};
export function FacetBadge({ facet, className }: { facet: GrowthFacet; className?: string }) {
return (
<Badge tone={FACET_TONES[facet]} className={className}>
{facet === 'renewal_due' ? <ActMark /> : facet === 'at_risk' ? <FailMark /> : null}
{FACET_LABELS[facet]}
</Badge>
);
}
// -------------------------------------------------------------------- deals
/**
* Pipeline stage, both sides.
*
* Won and live are business-good; everything in flight is process and stays
* grey, which is the change from the accent-coloured pipeline the pages drew
* before. `churned` is red where `closed_lost` is not: losing a relationship
* is a loss worth marking, losing one deal out of a pipeline of them is the
* ordinary shape of the job.
*/
export const DEMAND_STAGE_TONES: Record<DemandStage, StatusTone> = {
qualification: 'neutral',
legal: 'neutral',
scoping: 'neutral',
proposal: 'neutral',
procurement: 'neutral',
poc: 'neutral',
deployment: 'neutral',
expansion: 'neutral',
closed_won: 'positive',
closed_lost: 'neutral',
};
export const SUPPLY_STAGE_TONES: Record<SupplyStage, StatusTone> = {
sourced: 'neutral',
qualifying: 'neutral',
technical_diligence: 'neutral',
financial_diligence: 'neutral',
pricing: 'neutral',
contracting: 'neutral',
onboarding: 'neutral',
live: 'positive',
renewal: 'warning',
churned: 'danger',
rejected: 'neutral',
};
export type DealStageBadgeProps =
| { side: 'demand'; stage: DemandStage; className?: string }
| { side: 'supply'; stage: SupplyStage; className?: string };
/**
* Not named in the direction's component table, but the audit counted deal
* stage among the domains drawn several ways, and `Account.tsx` holds two
* private tone functions for it. It belongs with the rest of the vocabulary.
*/
export function DealStageBadge(props: DealStageBadgeProps) {
const { tone, label } =
props.side === 'demand'
? { tone: DEMAND_STAGE_TONES[props.stage], label: DEMAND_STAGE_LABELS[props.stage] }
: { tone: SUPPLY_STAGE_TONES[props.stage], label: SUPPLY_STAGE_LABELS[props.stage] };
return (
<Badge tone={tone} className={props.className}>
{props.side === 'supply' && props.stage === 'renewal' ? <ActMark /> : null}
{label}
</Badge>
);
}
// ---------------------------------------------------------------- contracts
/**
* Paper state.
*
* `executed` is neutral, which is the tone change most likely to be questioned.
* It is a process outcome the paper is signed, nothing follows from it today
* and on the contracts list most rows are executed, so colouring it green
* paints the whole table and leaves the expiring one indistinguishable.
* `out_for_signature` is `info` rather than `warning` because it is waiting on
* the counterparty, not on us.
*/
export const CONTRACT_STATUS_TONES: Record<ContractStatus, StatusTone> = {
draft: 'neutral',
in_review: 'neutral',
in_negotiation: 'neutral',
out_for_signature: 'info',
executed: 'neutral',
expired: 'danger',
terminated: 'neutral',
};
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
draft: 'Draft',
in_review: 'In review',
in_negotiation: 'Negotiating',
out_for_signature: 'For signature',
executed: 'Executed',
expired: 'Expired',
terminated: 'Terminated',
};
export function ContractStatusBadge({
status,
className,
}: {
status: ContractStatus;
className?: string;
}) {
return (
<Badge tone={CONTRACT_STATUS_TONES[status]} className={className}>
{status === 'expired' ? <FailMark /> : null}
{CONTRACT_STATUS_LABELS[status]}
</Badge>
);
}
// ------------------------------------------------------------------ renewals
/** Mirrors `RenewalState` in `apps/api/src/services/contracts.ts`, which the
* web app cannot import. Expiry minus notice days against today. */
export type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired';
export const RENEWAL_TONES: Record<RenewalState, StatusTone> = {
not_applicable: 'neutral',
scheduled: 'neutral',
due: 'warning',
expired: 'danger',
};
/**
* Renewal urgency, which is the one status in the product that is worth money
* on a deadline: a notice window that quietly opened last week is the most
* expensive thing in this book to miss.
*
* Only the two states that need a person are drawn as badges. A contract with
* no alarm, or one whose notice is months away, is fine print this appears in
* a column, and forty grey chips down a table are forty things to look past
* before finding the two amber ones. That restraint is `Contracts.tsx`'s
* original design and it is kept deliberately rather than regularised away.
*/
export function RenewalBadge({
state,
noticeAt,
className,
}: {
state: RenewalState;
/** The computed notice date, shown when it is still ahead. */
noticeAt?: string | Date | null;
className?: string;
}) {
if (state === 'due') {
return (
<Badge tone="warning" className={className}>
<ActMark />
Notice due
</Badge>
);
}
if (state === 'expired') {
return (
<Badge tone="danger" className={className}>
<FailMark />
Expired
</Badge>
);
}
return (
<span className={cn('text-xs text-muted', className)}>
{state === 'not_applicable'
? 'No alarm'
: noticeAt
? `Notice ${shortDate(noticeAt)}`
: 'Notice scheduled'}
</span>
);
}
// ------------------------------------------------------------ agent activity
/**
* A run's status is free text on the wire `agent_runs.status` is a `text`
* column so an unrecognised value is shown as it arrived, in neutral, rather
* than forced into one of the four we know. A status this panel has never heard
* of is information, not an error.
*
* `aborted` reads "Stopped by you" because that is what it means: the reader
* pressed Stop, or navigated away. "Aborted" describes the process; the person
* wants to know whether it was them.
*/
export const RUN_STATUS_TONES: Record<string, StatusTone> = {
running: 'info',
awaiting_approval: 'warning',
succeeded: 'neutral',
aborted: 'neutral',
failed: 'danger',
};
export const RUN_STATUS_LABELS: Record<string, string> = {
running: 'Running',
awaiting_approval: 'Needs you',
succeeded: 'Succeeded',
aborted: 'Stopped by you',
failed: 'Failed',
};
export function RunStatusBadge({ status, className }: { status: string; className?: string }) {
const tone = RUN_STATUS_TONES[status] ?? 'neutral';
return (
<Badge tone={tone} className={className}>
{status === 'failed' ? <FailMark /> : status === 'awaiting_approval' ? <ActMark /> : null}
{RUN_STATUS_LABELS[status] ?? humanise(status)}
</Badge>
);
}
/** Mirrors `PiggyTaskSummary['state']`: the three live states the queue derives
* plus `AGENT_TASK_OUTCOMES`. */
export type TaskState =
| 'running'
| 'queued'
| 'scheduled'
| 'succeeded'
| 'failed'
| 'skipped'
| 'cancelled';
export const TASK_STATE_TONES: Record<TaskState, StatusTone> = {
running: 'info',
queued: 'info',
scheduled: 'info',
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
export const TASK_STATE_LABELS: Record<TaskState, string> = {
running: 'Running',
queued: 'Queued',
scheduled: 'Scheduled',
succeeded: 'Succeeded',
failed: 'Failed',
skipped: 'Skipped',
cancelled: 'Cancelled',
};
export function TaskStateBadge({ state, className }: { state: TaskState; className?: string }) {
return (
<Badge tone={TASK_STATE_TONES[state]} className={className}>
{state === 'failed' ? <FailMark /> : null}
{TASK_STATE_LABELS[state]}
</Badge>
);
}
// ------------------------------------------------------------------ approval
/** The five states of a proposed write. Structurally identical to
* `PiggyApprovalState` in `piggy/approval-card.tsx`, which owns the
* transitions; declared here so this module does not depend on the card. */
export type ApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
/**
* The decided table from the design direction.
*
* `applied` is neutral, not green the confirmation the reader wants is a
* single positive check beside the record link in the card's status line, not a
* green block in a transcript where every approved write would then be green.
* `pending` is the only warning, and it is warning because it is the only state
* in the product where the agent has stopped and is waiting for a person.
*/
export const APPROVAL_STATE_TONES: Record<ApprovalState, StatusTone> = {
pending: 'warning',
submitting: 'neutral',
applied: 'neutral',
rejected: 'neutral',
failed: 'danger',
};
export function ApprovalStateBadge({
state,
/** Which way the person answered, so `submitting` can say which. */
decision,
className,
}: {
state: ApprovalState;
decision?: 'apply' | 'reject' | null;
className?: string;
}) {
const label =
state === 'pending'
? 'Needs you'
: state === 'submitting'
? decision === 'reject'
? 'Rejecting'
: 'Applying'
: state === 'applied'
? 'Applied'
: state === 'rejected'
? 'Rejected'
: 'Not applied';
return (
<Badge tone={APPROVAL_STATE_TONES[state]} className={cn('shrink-0', className)}>
{state === 'pending' ? <ActMark /> : state === 'failed' ? <FailMark /> : null}
{label}
</Badge>
);
}
+184
View File
@@ -0,0 +1,184 @@
/**
* A confirmation that has to be read.
*
* An alert dialog is not a dialog with different copy. It refuses to be
* dismissed by clicking past it, it opens with focus on the safe choice, and it
* names the thing it is about to destroy because the surface it replaces was
* a plain `Dialog` whose default focus landed on the delete button and whose
* scrim dismissed a decision the person had not made.
*
* Built on `@radix-ui/react-dialog` with `role="alertdialog"` rather than
* `@radix-ui/react-alert-dialog`, which is not a dependency of this app. The
* three behaviours that package adds are the three declared below, so the
* component is the contract, not the package.
*/
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { Button, type ButtonProps } from "@/components/ui"
import {
DialogDescription,
DialogHeader,
DialogOverlay,
DialogTitle,
useOverlayFocusRestore,
} from "@/components/ui/dialog"
const AlertDialog = DialogPrimitive.Root
const AlertDialogTrigger = DialogPrimitive.Trigger
const AlertDialogPortal = DialogPrimitive.Portal
const AlertDialogOverlay = DialogOverlay
/*
* The cancel control registers itself here so the content can put initial
* focus on it. It has to be a registration rather than "focus the first
* focusable", because the destructive action deliberately comes first in the
* DOM see AlertDialogFooter and a destructive button holding focus the
* moment the dialog opens is one Enter away from the thing the dialog exists
* to prevent.
*/
const AlertDialogCancelContext =
React.createContext<React.MutableRefObject<HTMLButtonElement | null> | null>(null)
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(
(
{
className,
children,
onCloseAutoFocus,
onOpenAutoFocus,
onInteractOutside,
...props
},
ref
) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<DialogPrimitive.Content
ref={focus.ref}
role="alertdialog"
onCloseAutoFocus={focus.onCloseAutoFocus}
onOpenAutoFocus={(event) => {
onOpenAutoFocus?.(event)
if (event.defaultPrevented) return
const cancel = cancelRef.current
if (!cancel) return
event.preventDefault()
cancel.focus()
}}
onInteractOutside={(event) => {
onInteractOutside?.(event)
// A decision is not made by clicking somewhere else.
event.preventDefault()
}}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
>
<AlertDialogCancelContext.Provider value={cancelRef}>
{children}
</AlertDialogCancelContext.Provider>
</DialogPrimitive.Content>
</AlertDialogPortal>
)
}
)
AlertDialogContent.displayName = "AlertDialogContent"
const AlertDialogHeader = DialogHeader
/**
* Destructive first in the DOM, last on the screen.
*
* A screen reader reads the footer in source order, and the consequence has to
* arrive before the escape from it. Sighted order is the platform convention
* safe choice on the left, the commit on the right and is restored with
* `order`, which changes the painting and not the reading.
*/
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = DialogTitle
const AlertDialogDescription = DialogDescription
/**
* The commit. Destructive by default: this component exists for the deletes,
* and a confirmation whose commit button looks like every other button is a
* confirmation nobody reads. Pass `variant` for the non-destructive cases.
*/
const AlertDialogAction = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "danger", type = "button", ...props }, ref) => (
<DialogPrimitive.Close asChild>
<Button
ref={ref}
type={type}
variant={variant}
className={cn("order-1 sm:order-2", className)}
{...props}
/>
</DialogPrimitive.Close>
)
)
AlertDialogAction.displayName = "AlertDialogAction"
const AlertDialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "outline", type = "button", ...props }, ref) => {
const registry = React.useContext(AlertDialogCancelContext)
const setRef = React.useCallback(
(node: HTMLButtonElement | null) => {
if (registry) registry.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref, registry]
)
return (
<DialogPrimitive.Close asChild>
<Button
ref={setRef}
type={type}
variant={variant}
className={cn("order-2 sm:order-1", className)}
{...props}
/>
</DialogPrimitive.Close>
)
}
)
AlertDialogCancel.displayName = "AlertDialogCancel"
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+121 -24
View File
@@ -12,6 +12,95 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
/**
* Focus capture and restore, for every overlay in the product.
*
* Radix returns focus to whatever opened the overlay only when it can still
* find it, and on several of PIG's surfaces it cannot: sheets opened
* programmatically have no `SheetTrigger` at all, and the palette's opener is a
* control the next route unmounts. Measured, Escape from nine overlays left
* focus on `<body>` on `/piggy` that is 121 Tab presses back to where the
* person was, which is a keyboard trap wearing a dismissal.
*
* The pattern was written once at `CommandPalette.tsx` and is hoisted here so
* every dialog, sheet, drawer and alert inherits it without a call-site change.
*
* The capture hangs off the content element's ref rather than the wrapper's
* first render, and that distinction is the whole component. `SheetContent`
* renders on every render of the page that declares it Radix gates on open
* *below* it, inside the portal so reading `document.activeElement` while our
* own function body runs reads it at page load, which is `<body>`, which is the
* bug being fixed. A ref callback fires only when the content genuinely mounts,
* and refs are attached earlier in the commit than the effect Radix's
* FocusScope uses to move focus into the overlay.
*/
export function useOverlayFocusRestore<T extends HTMLElement>(
forwardedRef: React.ForwardedRef<T>,
onCloseAutoFocus?: (event: Event) => void,
): {
ref: (node: T | null) => void
onCloseAutoFocus: (event: Event) => void
} {
const opener = React.useRef<HTMLElement | null>(null)
const captured = React.useRef(false)
const forwarded = React.useRef(forwardedRef)
forwarded.current = forwardedRef
// Deliberately stable: a ref callback that changed identity would be called
// with null and then the node again mid-open, and the second capture would
// read a control inside the overlay as the opener.
const ref = React.useCallback((node: T | null) => {
if (node) {
if (!captured.current) {
captured.current = true
const active = document.activeElement
opener.current =
active instanceof HTMLElement && active !== document.body ? active : null
}
} else {
// Armed for the next open. `opener` itself survives, because the close
// handler below runs in a passive effect cleanup — after React has
// already detached this ref.
captured.current = false
}
const target = forwarded.current
if (typeof target === "function") target(node)
else if (target) target.current = node
}, [])
const handleCloseAutoFocus = React.useCallback(
(event: Event) => {
onCloseAutoFocus?.(event)
// The call site wins. CommandPalette has its own restore for the case
// where choosing an item navigates away from the control that opened it.
if (event.defaultPrevented) return
const target = opener.current
// `isConnected` because closing may have navigated, leaving the opener
// detached — focusing a node in no document does nothing.
//
// The fallback is the page's own content landmark rather than Radix's,
// which lands on `<body>`. That case is real and now reachable: an
// account's contract row navigates to `/contracts?contract=…`, which
// opens the detail sheet on a page the opener never existed on, so
// dismissing it dropped the keyboard back to the top of the document.
// `#page-content` is the same target the skip link uses and already
// carries `tabIndex={-1}` for exactly this.
if (!target || !target.isConnected) {
const content = document.getElementById('page-content')
if (!content) return
event.preventDefault()
content.focus()
return
}
event.preventDefault()
target.focus()
},
[onCloseAutoFocus],
)
return { ref, onCloseAutoFocus: handleCloseAutoFocus }
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -19,7 +108,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
@@ -30,25 +119,29 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-1 top-1 flex size-[44px] items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-2 top-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -57,7 +150,9 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
// Left-aligned at every width. The shadcn default centres below `sm`,
// so the same dialog read as a different component on a phone.
"flex flex-col gap-1 text-left",
className
)}
{...props}
@@ -71,7 +166,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
@@ -86,7 +181,9 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
// The section-heading role: 16px/600. An overlay title is not a page
// title, and overlay chrome does not vary by feature.
"text-base font-semibold leading-tight tracking-tight text-fg",
className
)}
{...props}
@@ -100,7 +197,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+92
View File
@@ -0,0 +1,92 @@
import { useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A fold, built on `<details>` so the browser gives it keyboard and
* screen-reader semantics for free.
*
* Four of these existed reasoning, tool steps, a rejected proposal, an
* activity group and each had independently rediscovered the same two
* browser facts:
*
* - `list-style: none` removes Chrome's marker but not Safari's, which draws
* its own from `::-webkit-details-marker`. Without both rules one browser
* shows two triangles.
* - A flex `<summary>` drops the native marker in Chrome and keeps it in
* Firefox, so the chevron has to be an element we render ourselves.
*
* `onExpand` receives the element at the one moment worth having: inside the
* click handler, where `open` still holds its pre-click value. That is how a
* transcript pinned to its newest message can tell an expansion from a
* collapse and scroll the revealed content back into view an expansion adds
* height below the fold, and a follow-the-tail scroller reads that as new
* content and jumps past the very thing the user asked to see.
*/
export function Disclosure({
summary,
children,
onExpand,
defaultOpen = false,
open,
onOpenChange,
className,
summaryClassName,
contentClassName,
...props
}: Omit<ComponentPropsWithoutRef<'details'>, 'onToggle' | 'open' | 'children' | 'className'> & {
summary: ReactNode;
children: ReactNode;
/** Called with the `<details>` element as it is about to open, never as it closes. */
onExpand?: (element: HTMLDetailsElement) => void;
defaultOpen?: boolean;
/** Supply with `onOpenChange` to drive the fold from outside. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
summaryClassName?: string;
contentClassName?: string;
}) {
const ref = useRef<HTMLDetailsElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isOpen = open ?? uncontrolledOpen;
return (
<details
ref={ref}
open={isOpen}
onToggle={(event) => {
const next = event.currentTarget.open;
if (open === undefined) setUncontrolledOpen(next);
onOpenChange?.(next);
}}
className={cn('group min-w-0', className)}
{...props}
>
<summary
onClick={() => {
const element = ref.current;
// `open` is still the pre-click value here, so `false` means the
// click is about to open it.
if (element && !element.open) onExpand?.(element);
}}
className={cn(
'flex min-h-11 min-w-0 cursor-pointer list-none items-center gap-2 py-2',
'text-sm font-medium transition-colors duration-1 ease-enter hover:text-fg',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand',
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
'[&::-webkit-details-marker]:hidden',
summaryClassName,
)}
>
<ChevronRight
className="size-4 shrink-0 text-muted transition-transform duration-1 ease-enter group-open:rotate-90"
aria-hidden
/>
<span className="min-w-0 flex-1">{summary}</span>
</summary>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
</details>
);
}
+38 -23
View File
@@ -2,6 +2,7 @@ import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
import { useOverlayFocusRestore } from "@/components/ui/dialog"
const Drawer = ({
shouldScaleBackground = true,
@@ -35,22 +36,39 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(
// The bottom inset is the drawer's own: it is anchored to the edge of
// the screen the home indicator sits on, and the drawer's last child
// is a composer. Measured at 393×852, its Send button finished 13px
// off-screen.
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-2xl border border-border bg-surface pb-[var(--safe-bottom)]",
className
)}
{...props}
>
{/*
A plain element, not `DrawerPrimitive.Handle`: vaul drags from
anywhere in the content that is not marked no-drag, and the handle
primitive brings its own hit area and sizing. This one is the grip
mark only.
*/}
<div
aria-hidden
className="mx-auto mt-4 h-2 w-[100px] shrink-0 rounded-full bg-border"
/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
})
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
@@ -58,7 +76,7 @@ const DrawerHeader = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
className={cn("flex min-w-0 flex-col gap-1 px-5 py-4 text-left", className)}
{...props}
/>
)
@@ -69,7 +87,7 @@ const DrawerFooter = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
className={cn("mt-auto flex flex-col gap-2 px-5 py-4", className)}
{...props}
/>
)
@@ -81,10 +99,7 @@ const DrawerTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className={cn("text-base font-semibold leading-tight text-fg", className)}
{...props}
/>
))
@@ -96,7 +111,7 @@ const DrawerDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+66
View File
@@ -0,0 +1,66 @@
import { cloneElement, isValidElement, useId, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui';
type ControlProps = { id?: string; 'aria-label'?: string; 'aria-describedby'?: string };
/**
* A labelled control.
*
* The id is cloned onto the *control*, never onto a wrapper: a `<label for>`
* pointing at a `<div>` associates with nothing, and the screen reader then
* reads an unlabelled input. Four copies of this wrapper existed, two of them
* byte-identical, and all four carried a subtler version of the same bug
* they gave the control `children.props.id ?? id` while pointing the label at
* `id` unconditionally, so any control that already had an id of its own ended
* up with a label addressing an element that did not exist.
*
* The hint is wired through `aria-describedby` rather than left as loose text
* beneath, because a hint that only sighted users receive is not a hint, it is
* decoration.
*/
export function FormField({
label,
hint,
children,
className,
}: {
label: string;
hint?: ReactNode;
children: ReactNode;
className?: string;
}) {
const generated = useId();
const hintId = `${generated}-hint`;
const element = isValidElement<ControlProps>(children) ? children : null;
const controlId = element?.props.id ?? generated;
const control = element
? cloneElement(element, {
id: controlId,
// The visible label is the accessible name via `for`/`id`; the
// `aria-label` is a belt-and-braces fallback for controls that render
// a button rather than a form element (Radix Select, for one) where
// `for` does not always carry.
'aria-label': element.props['aria-label'] ?? label,
'aria-describedby':
hint == null ? element.props['aria-describedby'] : (element.props['aria-describedby'] ?? hintId),
})
: children;
return (
<div className={cn('flex min-w-0 flex-col gap-1.5', className)}>
<Label as="label" htmlFor={controlId}>
{label}
</Label>
{control}
{hint == null ? null : (
<p id={hintId} className="min-w-0 text-xs text-muted">
{hint}
</p>
)}
</div>
);
}
+265 -16
View File
@@ -11,11 +11,15 @@ import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
forwardRef,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type LabelHTMLAttributes,
type ReactNode,
} from 'react';
@@ -42,8 +46,17 @@ export function cn(...inputs: ClassValue[]): string {
*/
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
'transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 ' +
'[&_svg]:shrink-0 ' +
/*
* The ring is declared here as well as in the global `:focus-visible` rule.
* A button that also carries a local `focus-visible:ring-*` class beat the
* base rule on specificity and painted the near-invisible subtle accent;
* declaring it in the cva puts it in the same cascade layer as those
* overrides, so `cn()` merging resolves it rather than the stylesheet.
*/
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand ' +
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
// touch-manipulation removes the 300ms tap delay that older mobile Safari
// applies while waiting to see whether a tap is a double-tap zoom.
'touch-manipulation select-none whitespace-nowrap',
@@ -179,28 +192,191 @@ export function Badge({
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
// --------------------------------------------------------------------- stat
// -------------------------------------------------------------- micro label
/**
* A single headline number.
* The one small-caps label in the product.
*
* There were five of these 10px, 11px and 12px, `tracking-wide`,
* `tracking-wider` and `tracking-[0.12em]`, `font-medium` and `font-semibold`
* across 58 hand-written call sites, which is why no two panels' labels
* lined up. 11px at 0.06em was the majority reading and the one that survives
* the sidebar rail's width.
*
* `as` exists because the same label is a `dt` in a definition list, a `th` in
* a table head and a `span` inside a flex row; rendering all three as a `div`
* is how a table stops being a table for a screen reader.
*/
const MICRO_LABEL =
'text-[11px] font-medium uppercase leading-tight tracking-[0.06em] text-muted';
export function Label({
as: Component = 'div',
className,
...props
}: LabelHTMLAttributes<HTMLElement> & {
as?: 'div' | 'span' | 'p' | 'dt' | 'th' | 'legend' | 'label' | 'h2' | 'h3' | 'h4';
}) {
return (
<Component className={cn(MICRO_LABEL, className)} {...props} />
);
}
// ------------------------------------------------------------------ section
/**
* A titled group of content: the panel heading, its count, its action, and
* optionally a chevron that folds it away.
*
* Six of these existed at 11px-uppercase through 16px-sentence-case, so a page
* built from three of them read as three products. `tone` is the only choice
* left: `panel` owns a card, `micro` names a group inside one.
*/
export function Section({
title,
description,
count,
action,
level = 3,
tone = 'panel',
collapsible = false,
defaultOpen = true,
className,
children,
}: {
title: ReactNode;
description?: ReactNode;
/** Rendered beside the title as a tabular figure. `0` still renders. */
count?: number;
action?: ReactNode;
level?: 2 | 3 | 4;
tone?: 'panel' | 'micro';
collapsible?: boolean;
defaultOpen?: boolean;
className?: string;
children?: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
const Heading = ({ 2: 'h2', 3: 'h3', 4: 'h4' } as const)[level];
const titleClass =
tone === 'panel' ? 'text-base font-semibold leading-tight text-fg' : MICRO_LABEL;
const heading = (
<>
<span className={cn('min-w-0 break-words', titleClass)}>{title}</span>
{count == null ? null : <span className="nums shrink-0 text-sm text-muted">{count}</span>}
</>
);
return (
<section className={cn('min-w-0', className)}>
<div className={cn('flex min-w-0 items-start gap-2', collapsible ? '' : 'py-0.5')}>
<Heading className="min-w-0 flex-1">
{collapsible ? (
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className="flex min-h-11 w-full items-center gap-2 rounded-lg text-left transition-colors duration-1 ease-enter hover:text-fg"
>
<ChevronRight
className={cn(
'size-4 shrink-0 text-muted transition-transform duration-1 ease-enter',
open && 'rotate-90',
)}
aria-hidden
/>
{heading}
</button>
) : (
<span className="flex min-w-0 items-baseline gap-2">{heading}</span>
)}
</Heading>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{description ? (
<p className={cn('mt-1 min-w-0 text-sm text-muted', collapsible && 'pl-6')}>{description}</p>
) : null}
{children != null && (!collapsible || open) ? (
<div className={cn('mt-2 min-w-0', tone === 'panel' && !collapsible && 'mt-3')}>
{children}
</div>
) : null}
</section>
);
}
// --------------------------------------------------------------------- stat
const statValueSizes = {
/** Inline tile inside a panel. */
sm: 'text-sm',
/** A figure a panel is about. */
md: 'text-lg',
/** The page's headline number. */
lg: 'text-2xl sm:text-3xl',
} as const;
const statSurfaces = {
/** The default, and the only one that existed: a card of its own. */
card: 'card p-4',
/** A tile on `bg-surface-2` inside a card. No border, no shadow. */
inset: 'rounded-md bg-surface-2 p-2.5',
/** No surface at all — the caller owns the container. */
bare: '',
} as const;
/**
* A single labelled number.
*
* `nums` applies tabular figures so a value does not jitter horizontally as it
* updates which it does, on a dashboard that refreshes while someone is
* looking at it.
*
* `min-w-0 break-words` is on the value, not left to the caller: this tile is
* always a grid child, a grid child refuses to shrink below its content, and
* the content is an unbreakable currency string. The measured failure was
* `GROSS MARGIN $658,194.3` a digit short on any phone under 400px, and
* the same figure colliding with the next card at 1440 with the Piggy dock
* open. A truncated financial figure is worse than no figure.
*
* That stopped the clipping and traded it for a second failure nobody measured,
* because `scrollWidth === clientWidth` is true of a number that has WRAPPED:
* `$658,194.` on one line and `37` on the next, at every width under about
* 220px a phone, and 1440 with the dock open. So the tile is now a container
* query context (`stat-tile`) and the `lg` figure carries `stat-figure-lg`,
* which steps 30 24 22 20 18 16px as the tile narrows. See the
* measured ladder in `index.css`. Every tile in a grid row is the same width,
* so a row steps together; `break-words` stays as the last-resort net.
*
* An absent value renders a muted em dash rather than the tone colour: "—" in
* danger red reads as a number that went wrong rather than one nobody has.
*/
export function Stat({
label,
value,
hint,
tone,
size = 'lg',
surface = 'card',
href,
className,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
tone?: 'positive' | 'warning' | 'danger' | 'default';
size?: 'sm' | 'md' | 'lg';
surface?: 'card' | 'inset' | 'bare';
/** Makes the whole tile a router link to the page that explains the figure. */
href?: string;
className?: string;
}) {
const toneClass =
tone === 'positive'
const absent = value == null || value === '' || value === '—' || value === '-';
const toneClass = absent
? 'text-muted'
: tone === 'positive'
? 'text-positive'
: tone === 'warning'
? 'text-warning'
@@ -208,14 +384,52 @@ export function Stat({
? 'text-danger'
: 'text-fg';
return (
<div className="card p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
const body = (
<>
<Label>{label}</Label>
<div
className={cn(
'nums min-w-0 break-words font-semibold leading-tight',
size === 'sm' ? 'mt-0.5' : 'mt-1',
statValueSizes[size],
// Only the page KPI step is container-scaled; `sm` and `md` are
// already small enough that no figure in the product wraps them.
size === 'lg' && 'stat-figure-lg',
toneClass,
)}
>
{absent ? '—' : value}
</div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div>
{hint ? <div className="mt-1 min-w-0 text-xs text-muted">{hint}</div> : null}
</>
);
const shell = cn(
'block min-w-0',
/*
* The container context is declared ONLY on the page-KPI step, and that is
* a measured constraint rather than tidiness. `container-type: inline-size`
* carries `contain: layout style inline-size`, which suppresses a box's
* content-based intrinsic contribution so putting it on every `Stat`
* changed how the `sm` and `md` tiles inside Growth's account cards
* negotiated width with the flex rows around them, and /growth began
* overflowing its viewport by 32px at 393 and 12px at 1440. Zero horizontal
* overflow is the product's oldest measured guarantee; a nicer number is
* not worth spending it. The `lg` tiles are grid children with declared
* tracks, where the contribution is not what decides the width.
*/
size === 'lg' && 'stat-tile',
statSurfaces[surface],
href && 'transition-colors duration-1 ease-enter hover:bg-surface-2',
className,
);
return href ? (
<Link to={href} className={shell}>
{body}
</Link>
) : (
<div className={shell}>{body}</div>
);
}
@@ -227,24 +441,59 @@ export function Skeleton({ className }: { className?: string }) {
// --------------------------------------------------------------- empty state
/**
* Nothing here, said once.
*
* Seven bespoke empty states stood beside this one, differing only in how much
* vertical room they took: a panel's worth of padding inside a 120px list row
* pushes the thing below it off the screen. `size` is that decision and the
* only one `panel` is the historic rendering and stays the default.
*/
export function EmptyState({
icon,
title,
description,
action,
size = 'panel',
className,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
/** `inline` inside a list or a card body, `panel` for a card, `page` for a route. */
size?: 'inline' | 'panel' | 'page';
className?: string;
}) {
const box =
size === 'inline' ? 'gap-2 px-4 py-6' : size === 'page' ? 'gap-4 px-6 py-20' : 'gap-3 px-6 py-12';
const titleClass =
size === 'inline'
? 'text-sm font-medium'
: size === 'page'
? 'text-base font-semibold'
: 'font-medium';
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div
className={cn(
'flex min-w-0 flex-col items-center justify-center text-center',
box,
className,
)}
>
{icon ? <div className="text-muted">{icon}</div> : null}
<div>
<p className="font-medium">{title}</p>
<div className="min-w-0">
<p className={cn('break-words', titleClass)}>{title}</p>
{description ? (
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
<p
className={cn(
'mx-auto mt-1 max-w-sm break-words text-muted',
size === 'inline' ? 'text-xs' : 'text-sm',
)}
>
{description}
</p>
) : null}
</div>
{action}
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
/**
* The one route heading.
*
* Thirteen pages hand-rolled this block and drifted: the `<h1>` was 20px on
* most, 30px on Growth and Learn, five pages carried two `<h1>`s, and the gap
* to the first section below ran 16 / 20 / 24 / 40px depending on the file. A
* document has one title, and a product has one title size.
*
* `ask` is a separate slot from `actions` deliberately. "Ask Piggy" is not a
* page action it is the same agent on every page, and it sits in the same
* place on every page so a reader stops hunting for it among the buttons that
* do differ.
*/
export function PageHeader({
title,
description,
actions,
ask,
className,
}: {
title: ReactNode;
description?: ReactNode;
/** Page-specific controls, right-aligned at `sm` and up. */
actions?: ReactNode;
/** The `PiggyAskButton` for this page, kept in one consistent slot. */
ask?: ReactNode;
className?: string;
}) {
return (
<header
className={cn(
'flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className,
)}
>
<div className="min-w-0">
<h1 className="min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{title}
</h1>
{description ? (
<p className="mt-1 min-w-0 max-w-2xl text-sm text-muted">{description}</p>
) : null}
</div>
{actions || ask ? (
// `flex-wrap` rather than `whitespace-nowrap`: at 393px a two-button
// row plus the ask button overflows, and a page header is the last
// place that should be the thing which introduces horizontal scroll.
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2 sm:justify-end">
{actions}
{ask}
</div>
) : null}
</header>
);
}
+27 -5
View File
@@ -12,6 +12,17 @@ const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
/**
* The trigger is 44px, matching `Input` and `Button`, because it is the same
* kind of thing a thumb aims at. Stock shadcn ships it at 36px, which is where
* Settings' timezone select and every filter built on this primitive were
* failing PIG's own touch rule not because a call site chose 36px, but
* because nobody had chosen anything.
*
* The rest of the treatment is `Input`'s exactly: 12px radius because it is a
* control, `bg-surface` because a transparent field has no edge on a tinted
* canvas, and focus read as the border changing rather than an extra outline.
*/
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
@@ -19,7 +30,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-11 min-h-[44px] w-full items-center justify-between whitespace-nowrap rounded-lg border border-border bg-surface px-3 text-fg",
"transition-colors duration-1 ease-enter data-[placeholder]:text-muted focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -75,7 +88,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -105,7 +118,12 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
// A group name, so it takes the product's one micro-label treatment
// rather than looking like a selectable option set in bold.
className={cn(
"px-2 py-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted",
className
)}
{...props}
/>
))
@@ -118,7 +136,9 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// An option is a target too: a 30px row in an open listbox is as hard to
// hit as a 30px button, and this menu is how a phone changes a filter.
"relative flex min-h-11 w-full cursor-default select-none items-center rounded-md py-2 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
@@ -139,7 +159,9 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
// `bg-muted` here painted the muted TEXT colour: a near-black hairline in
// light theme, because shadcn's `muted` is a surface and PIG's is not.
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
+69 -30
View File
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useOverlayFocusRestore } from "@/components/ui/dialog"
const Sheet = SheetPrimitive.Root
@@ -21,7 +22,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
@@ -30,17 +31,23 @@ const SheetOverlay = React.forwardRef<
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
/*
* The top inset is written as `max(padding, --safe-top)` rather than the bare
* inset, matching how the app header and the Piggy sheet already do it: a bare
* `pt-[var(--safe-top)]` wins the cascade over the sheet's own padding and
* collapses the top inset to zero on every device without a notch.
*/
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"fixed z-50 gap-4 bg-surface p-5 shadow-lg transition data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
top: "inset-x-0 top-0 border-b pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
"inset-x-0 bottom-0 border-t pb-[max(1.25rem,var(--safe-bottom))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
"inset-y-0 right-0 h-full w-3/4 border-l pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
@@ -56,31 +63,61 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
>(({ side = "right", className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close
className={cn(
// `z-10` because a sheet's body is usually a full-bleed rail the
// call site paints itself, and a dismiss control underneath the
// content is a sheet with no way out on a phone.
"absolute right-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none",
// Most sheets are opened with `p-0` and lay out their own header,
// which means the content's safe-area padding is overridden at the
// call site. The dismiss control is the primitive's own, so it
// carries the inset itself — otherwise it opens under the notch,
// which is where it was measured.
side === "bottom" ? "top-2" : "top-[max(0.5rem,var(--safe-top))]"
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
})
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Render the header as the sheet's fixed top band: the standard overlay
* header padding, a rule against the body, and the top safe-area inset.
*
* Opt-in because the two families of sheet want opposite things. A sheet
* that keeps the content padding already has its inset and would get it
* twice; a sheet opened with `p-0` which is most of them owns its own
* bands and was hand-rolling these four classes each time, at four slightly
* different values.
*/
band?: boolean
}
const SheetHeader = ({ className, band = false, ...props }: SheetHeaderProps) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
"flex min-w-0 flex-col gap-1 text-left",
band &&
"shrink-0 border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]",
className
)}
{...props}
@@ -94,7 +131,7 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
@@ -108,7 +145,9 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
// 16px/600, the section-heading role, everywhere. Overlay chrome that
// varies by feature is how one product ends up looking like three.
className={cn("text-base font-semibold leading-tight text-fg", className)}
{...props}
/>
))
@@ -120,7 +159,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+7 -1
View File
@@ -367,7 +367,13 @@ export const SidebarGroupLabel = React.forwardRef<
ref={ref}
data-sidebar="group-label"
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80',
// The Label spec, not a sixth micro-label variant. This heading is the
// most-repeated small-caps text in the product — four of them on all 16
// routes — and it was the last one off the scale: 10px/600/0.16em in
// `text-muted/80`, which measured 3.28:1 in light theme. `text-muted` at
// 11px/500/0.06em is the one definition everything else already uses,
// and it measures 4.83:1.
'flex h-8 shrink-0 items-center rounded-md px-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted',
'transition-[margin,opacity] duration-200 ease-linear',
// Pulled up rather than hidden, so the icons above and below do not
// jump as the label fades out.
+21 -5
View File
@@ -1,3 +1,19 @@
/**
* The table primitive, on PIG's palette.
*
* This file arrived verbatim from shadcn and stayed that way through the whole
* design pass, which mattered more here than anywhere else: `tailwind.config.js`
* deliberately aliases `muted` onto `--muted`, a TEXT grey, so shadcn's
* `hover:bg-muted/50` painted a 50%-opacity mid-grey slab across a hovered row.
* Measured, the row's own text on that slab was 2.42:1 in light and 2.58:1 in
* dark on `/accounts` and `/contracts`, the two pages a GTM lead lives in,
* and under the row-action icons that only reveal on hover. `surface-2` is the
* product's own "this row is under the pointer" plane and reads at full text
* contrast.
*
* `TableHead` also matches the 44px floor the sort buttons inside it already
* carry: at `h-10` the header cell was shorter than its own control.
*/
import * as React from "react"
import { cn } from "@/lib/utils"
@@ -20,7 +36,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-border [&_tr:hover]:bg-transparent", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -43,7 +59,7 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
"border-t bg-surface-2 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
@@ -58,7 +74,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-b border-border transition-colors duration-1 ease-enter hover:bg-surface-2 data-[state=selected]:bg-surface-2",
className
)}
{...props}
@@ -73,7 +89,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"h-11 px-2 text-left align-middle font-medium text-muted [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
@@ -102,7 +118,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn("mt-4 text-sm text-muted", className)}
{...props}
/>
))
+44 -17
View File
@@ -1,9 +1,26 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root
/**
* Radix tabs wearing PIG's palette and PIG's touch floor.
*
* Two things were wrong with the stock copy, and both were wrong at every call
* site rather than at any one of them.
*
* First the palette: shadcn's `bg-muted` is a surface token. In PIG `muted` is
* the muted TEXT colour, so an unstyled `TabsList` painted a mid-grey slab
* with unreadable labels on it. Every call site had independently written the
* same three overrides `border border-border bg-surface`,
* `data-[state=active]:bg-surface-2`, `text-muted` which is the signal that
* they belong here.
*
* Second the height: a 36px list holding 28px triggers cannot contain a 44px
* touch target, and the rail's tabs measured 36px on a phone. The floor is now
* in the primitive, the same way `buttonVariants` carries it.
*/
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -12,13 +29,15 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
// 52px, not 44px: the list pads its triggers by 4px on each side, so a
// 44px list would squeeze a 44px trigger down to 36px.
'inline-flex min-h-[52px] items-center justify-center rounded-lg border border-border bg-surface p-1 text-muted',
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -27,13 +46,21 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
// `min-w-11` beside `min-h-11`: the floor is a square, and the repair
// that added the height left the width alone — so the Accounts facet
// control's "All" tab measured 40x44 at every viewport. A short label is
// exactly the case a minimum exists for.
'inline-flex min-h-11 min-w-11 items-center justify-center whitespace-nowrap rounded-md px-3 py-2',
'text-sm font-medium text-muted transition-colors duration-1 ease-enter',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-surface-2 data-[state=active]:text-fg',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -42,12 +69,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
'mt-3 min-w-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+40 -19
View File
@@ -1,22 +1,43 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
/**
* The composer field, and the only multi-line input in the product.
*
* This was the last un-adapted shadcn primitive: a 6px radius, a transparent
* fill, a `shadow-sm` nothing else in PIG carries, and a 1px `ring-ring` focus
* treatment sitting directly beside a 12px-radius Send button on the control
* the whole company types into first, because `/` redirects to `/piggy`.
*
* It now matches `Input` from `@/components/ui` line for line: 12px radius
* because it is a control, `bg-surface` because a transparent field on a
* tinted canvas has no edge, and `focus-visible:border-accent` so focus reads
* as the border changing rather than a second outline appearing outside it.
* The global `:focus-visible` rule still paints the brand ring on top.
*
* `min-h-[60px]` is kept: this is a growing composer, and the auto-resize
* logic at its call sites measures against a floor it already assumes.
*/
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-fg',
'placeholder:text-muted focus-visible:border-accent',
'transition-colors duration-1 ease-enter',
'disabled:cursor-not-allowed disabled:opacity-50',
// The base stylesheet enforces a 16px minimum here so Safari does not
// zoom the viewport on focus; nothing may override it downward.
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils';
import { compactNumber, percent } from '@/lib/api';
/**
* How much of a capacity block is sold, how much is merely held, and how much
* is still sellable.
*
* Sold and held are drawn as separate segments because a full-looking bar made
* mostly of unconverted holds is a lie a seller would act on held hours are
* a claim someone can walk away from, sold hours are revenue.
*
* The track is `bg-surface-2`. One of the two copies of this bar had drifted to
* `bg-surface`, which inside the allocation sheet's `bg-surface-2` panel
* measured 1.02:1 against its own container: an invisible track, at the exact
* moment someone commits GPU-hours to a customer.
*/
export function UtilisationBar({
sold,
held,
total,
label,
className,
}: {
/** GPU-hours sold. */
sold: number;
/** GPU-hours held but not yet sold. */
held: number;
/** GPU-hours committed in total. Zero renders an empty track, not a full one. */
total: number;
/** What the bar is about, e.g. the block's name. Prefixes the spoken label. */
label?: string;
className?: string;
}) {
const soldPct = total > 0 ? sold / total : 0;
const heldPct = total > 0 ? held / total : 0;
const available = Math.max(0, total - sold - held);
// Held is clamped against sold so a book that has over-held a block renders a
// full bar rather than a segment running past the end of its track.
const soldWidth = Math.min(100, soldPct * 100);
const heldWidth = Math.max(0, Math.min(100 - soldWidth, heldPct * 100));
const spoken = `${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(available)} GPU-hours sellable`;
return (
<div
className={cn('flex h-2 min-w-0 overflow-hidden rounded-full bg-surface-2', className)}
role="img"
aria-label={label ? `${label}: ${spoken}` : spoken}
>
<div className="bg-primary" style={{ width: `${soldWidth}%` }} />
<div className="bg-primary/35" style={{ width: `${heldWidth}%` }} />
</div>
);
}