Merge gitea/main into the Motion branch

Motion was written against a base five commits behind main, so the
integration is the interesting part of this commit:

- The migration is renumbered 0014 -> 0015. Main shipped
  0014_piggy_conversations, and two migrations sharing an index is a
  journal that applies one of them.
- The seed-idempotency gate keeps main's all-tables diff rather than the
  motion_templates counter this branch added; the general check subsumes
  the specific one.
- Nav gains a Motion group alongside main's new Workspace group, and
  Piggy keeps the mark main gave it.
- Stat keeps main's container-scaled figure, which already carries the
  min-w-0 this branch added for the same reason.
- Piggy's page labels keep main's refusal wording for the four pages with
  no tool of their own, and gain the three Motion routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:30:45 -07:00
149 changed files with 37440 additions and 3502 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>
+27 -15
View File
@@ -7,12 +7,13 @@
* they had already drifted — the tab bar's active pill and the sidebar's
* active row used different tokens.
*/
import { Fragment } from 'react';
import { X } from 'lucide-react';
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
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,
@@ -42,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"
@@ -66,17 +67,28 @@ export function AppSidebar() {
// A heading over nothing is worse than a missing section: it reads
// as a section that failed to load rather than one you cannot use.
if (!groupItems.length) return null;
const heading = NAV_GROUP_HEADING[group];
return (
<SidebarGroup key={group}>
<SidebarGroupLabel>{group}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{groupItems.map((item) => (
<NavItemRow key={item.to} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<Fragment key={group}>
<SidebarGroup>
{heading ? <SidebarGroupLabel>{heading}</SidebarGroupLabel> : null}
<SidebarGroupContent>
<SidebarMenu>
{groupItems.map((item) => (
<NavItemRow key={item.to} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/*
An unlabelled group has no heading to separate it from the next
one, so it gets a rule instead. This is also the only separation
that survives collapse: at icon width every heading is pulled up
and faded out, so without the rule the front door would be just
one more glyph in an undifferentiated stack of them.
*/}
{heading === null ? <SidebarSeparator /> : null}
</Fragment>
);
})}
</SidebarContent>
+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>
+557 -184
View File
@@ -1,8 +1,17 @@
import { useEffect, useRef, useState } 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,
@@ -16,13 +25,17 @@ 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';
import { PiggyReasoning } from './piggy/reasoning';
import { PiggyResponse } from './piggy/response';
import { PiggyToolStep } from './piggy/tool';
import { Badge, Button, EmptyState, cn } from './ui';
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,
DrawerContent,
@@ -47,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;
@@ -58,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
@@ -66,63 +146,37 @@ 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>
);
}
/**
* The height the workspace panel and its placeholder both take.
* Piggy is unavailable, said the same way wherever it is discovered.
*
* Named once because the two must agree: a placeholder of a different height
* makes the page jump the moment the status query answers. It is sized to land
* just inside the page rather than just outside it — the panel scrolls, so a
* page scrolling behind it means following an answer moves two things at once
* and the composer drifts under the fold. Below `lg` the subtraction is larger:
* the phone layout stacks the page header above and the tab bar below.
*
* The floor yields to the viewport rather than being a flat 32rem, because a
* flat one is taller than a phone held sideways: at 852x393 the panel was 512px
* inside a 393px window, which put the composer 230px below the fold on a page
* whose only control is the composer. `min()` keeps the comfortable floor
* everywhere it fits and stops claiming space that does not exist.
* The relay answers 503 when the runtime is off, so every surface that draws a
* composer has to ask `usePiggyStatus` first; this is what they draw instead.
*/
const WORKSPACE_HEIGHT =
'h-[calc(100dvh-19rem)] min-h-[min(32rem,calc(100dvh-11rem))] lg:h-[calc(100dvh-13rem)]';
export function PiggyChatWorkspace() {
const status = usePiggyStatus();
if (status.isLoading) return <div className={cn(WORKSPACE_HEIGHT, 'animate-pulse rounded-xl bg-surface-2')} />;
if (!status.data?.canUse) {
return (
<EmptyState
icon={<Bot />}
title="Piggy is unavailable"
description={
status.data?.enabled
? 'This credential does not have read access.'
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
}
/>
);
}
return <PiggyChatPanel className={cn(WORKSPACE_HEIGHT, 'rounded-xl border border-border bg-surface')} />;
export function PiggyUnavailable({ status }: { status: PiggyStatus | undefined }) {
return (
<EmptyState
icon={<PiggyMark className="size-6" />}
title="Piggy is unavailable"
description={
status?.enabled
? 'This credential does not have read access.'
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
}
/>
);
}
export function ResponsivePiggyChat({
@@ -130,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
@@ -143,17 +200,31 @@ export function ResponsivePiggyChat({
// Held here, one level above the overlay, because both the Sheet and the
// Drawer unmount their children when they close. With the thread inside,
// dismissing the overlay for two seconds to look at the record underneath
// destroyed the conversation, the draft and any answer still streaming.
const conversation = usePiggyConversation({ context, initialPrompt });
// 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} 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>
);
@@ -161,60 +232,119 @@ 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} 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
* workspace page stay mounted and let the panel keep its own.
*/
conversation?: PiggyConversationState;
/**
* The model and mode controls, bound to that conversation by whoever owns it.
*
* Passed in rather than built here because `usePiggyMode` and
* `usePiggyModelChoice` each hold their own copy of the stored preference: a
* second binding inside the panel would mean the workspace header and the
* composer disagreeing about what the next turn may do, which is precisely
* the disagreement the mode control exists to prevent. Omitted, the composer
* simply shows no controls — the surface above it has them.
*/
controls?: PiggyControlsState;
/**
* 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
// conversation was handed in. It holds no resources until something is sent.
const own = usePiggyConversation({ context, initialPrompt });
const { messages, draft, setDraft, running, send, stop, retry } = conversation ?? own;
const 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
@@ -227,20 +357,158 @@ 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
made re-reading an earlier answer mid-stream impossible and dragged
the page behind the dock down with it. Gutters go on the scrollport
so they scroll with the transcript rather than fencing it. */}
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
{messages.length === 0 ? (
<PiggyStarters compact={compact} context={context} onAsk={send} />
) : (
{messages.length === 0 ? (
/*
* The blank state is deliberately NOT inside the transcript viewport.
* That viewport sticks to the bottom of its content, which is right for
* an answer arriving and wrong for a page of openers: at 393x852 the
* workspace's front door opened already scrolled past its own pig, its
* headline and the first column heading. There is nothing to follow
* here and nothing to announce, so it is a plain scrollport anchored at
* the top, and the viewport below takes over the moment a turn exists.
*/
<div
className={cn(
'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',
)}
>
{/* 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` 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,
@@ -254,72 +522,156 @@ export function PiggyChatPanel({
key={message.id}
message={message}
compact={compact}
onApprove={approve}
onRetry={isRetryable(message) && !running ? () => retry(message.id) : undefined}
/>
))}
</div>
)}
<PiggyConversationScrollButton />
</PiggyConversation>
<PiggyConversationScrollButton />
</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(); }}>
{followUps.length ? (
// Wrapped, not scrolled sideways. A row of whole questions is wider
// than every surface but the full page, and a chip sliced off by the
// panel edge reads as a rendering fault — where a second line reads
// as a second suggestion.
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
{followUps.map((suggestion) => (
<button
key={suggestion}
type="button"
// Dead rather than absent while a turn runs: `send` refuses
// anything mid-stream, and a row that vanishes and returns
// moves the composer under the user's thumb.
disabled={running}
// Each chip is one line whatever the width, so the row can only
// ever be as tall as the number of suggestions.
title={suggestion}
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
onClick={() => send(suggestion)}
>
{suggestion}
</button>
))}
</div>
) : null}
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
<div className="flex items-end gap-2">
<Textarea
ref={composerRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
}}
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
className="min-h-11 max-h-36 resize-none"
placeholder="Ask about capacity, margin, paper or next actions…"
aria-label="Message Piggy"
/>
{running ? (
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><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">
<p className="flex-1 text-center">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
{/* No live region: this changes on every keystroke, and the cap is
already announced from the textarea's own `maxLength`. */}
{draft.length >= COUNTER_VISIBLE_FROM ? (
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
</p>
{/*
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
back were visibly different documents. */}
<div className="mx-auto flex w-full max-w-3xl flex-col">
{followUps.length ? (
// Wrapped, not scrolled sideways. A row of whole questions is wider
// than every surface but the full page, and a chip sliced off by the
// panel edge reads as a rendering fault — where a second line reads
// as a second suggestion.
<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}
type="button"
// Dead rather than absent while a turn runs: `send` refuses
// anything mid-stream, and a row that vanishes and returns
// moves the composer under the user's thumb.
disabled={running}
// 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={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>
))}
</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.
Disabled while a turn runs, because that turn's settings are already
fixed — changing them mid-answer would suggest otherwise. */}
{controls ? (
<PiggyControls controls={controls} compact={compact} disabled={running} className="mb-2">
{context ? (
// `basis-full` so the badge takes a row of its own rather than
// sitting beside the controls and pushing the row wider than the
// dock: an inline-flex badge sizes to its content, and a page
// context's label is a whole sentence of it.
<Badge className="flex min-w-0 basis-full">
<Database aria-hidden className="shrink-0" />
<span className="truncate">{contextLabel(context)}</span>
</Badge>
) : null}
</PiggyControls>
) : context ? (
<Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge>
) : null}
<div className="flex items-end gap-2">
<Textarea
ref={composerRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
}}
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
/*
* 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"
// 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-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 [@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`.
`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('ml-auto shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
</p>
) : null}
</div>
</div>
</form>
</div>
@@ -327,40 +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>
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
<div className="mt-4 grid w-full gap-2">
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
// Sends rather than fills the composer. Filling it looked like
// nothing had happened, so the chip read as a dead control.
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
))}
</div>
</div>
);
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
@@ -380,10 +718,13 @@ function contextLabel(context: PiggyChatContext): string {
function ChatMessage({
message,
compact = false,
onApprove,
onRetry,
}: {
message: TranscriptMessage;
compact?: boolean;
/** Answer a proposed write. Absent only where no conversation is driving. */
onApprove?: (changeId: string, decision: PiggyApprovalDecision) => void;
onRetry?: () => void;
}) {
if (message.role === 'user') {
@@ -394,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
@@ -414,13 +766,34 @@ 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
intends to do and the card is the thing that lets it. A card above
the sentence explaining it would ask for a decision before giving
the reason for it. */}
{message.approvals?.length ? (
<div className={cn('flex flex-col gap-2', message.content && 'mt-3')}>
{message.approvals.map((approval) => (
<PiggyApprovalCard
key={approval.change.id}
change={approval.change}
state={approval.state}
error={approval.error}
onDecide={(decision) => onApprove?.(approval.change.id, decision)}
/>
))}
</div>
) : null}
{/* Only while the turn has produced nothing at all. Once a tool chip or
the reasoning panel is on screen, the turn is visibly working and a
second spinner saying so is noise. */}
+133 -15
View File
@@ -16,22 +16,67 @@
* 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 { PanelRightClose, Sparkles } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
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 { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
import type { PiggyChatContext } from '@/lib/piggy-chat';
import { usePiggyChatSession } from './piggy/workspace/controls';
import {
PiggyChatPanel,
ResponsivePiggyChat,
usePiggyAskRequests,
usePiggyStatus,
type PiggyAsk,
} from './PiggyChat';
import { PiggyMark } from './PiggyMark';
import { Button, EmptyState, Skeleton, cn } from './ui';
/**
* Where Piggy is the page rather than the panel.
*
* The dock and the workspace are the same agent, so on `/piggy` an open dock
* put two composers, two empty states and two conversations side by side.
* Nothing broke; it just made the product look like it did not know what it was.
*/
const PIGGY_WORKSPACE_PATH = '/piggy';
export function PiggyDock() {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const ambient = usePiggyCurrentContext();
const { pathname } = useLocation();
const onWorkspace = pathname === PIGGY_WORKSPACE_PATH;
if (!hasRoom || !dockOpen) return null;
/*
* 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
// close a panel the user had open everywhere else.
if (onWorkspace || !hasRoom || !dockOpen) return null;
return (
<aside
@@ -70,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
@@ -87,11 +132,10 @@ export function PiggyDock() {
// pane that stays put while you move around the app. The panel reads
// `context` at send time, so the page it is asking about still tracks
// the route without a remount.
<PiggyChatPanel
<DockThread
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
context={context}
compact
className="min-h-0 flex-1"
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
/>
)}
</aside>
@@ -99,19 +143,73 @@ export function PiggyDock() {
}
/**
* The header control for Piggy.
* The dock's own conversation, and the controls bound to it.
*
* Both live here rather than inside `PiggyChatPanel` because the panel is not
* the thing whose lifetime they follow: the key above is what decides when a
* 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,
seed,
}: {
context: PiggyChatContext;
seed?: { id: number; text?: string };
}) {
const { conversation, controls } = usePiggyChatSession({ context });
return (
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
seed={seed}
className="min-h-0 flex-1"
/>
);
}
/**
* 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 (
<>
@@ -129,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>
@@ -0,0 +1,710 @@
/**
* What Piggy has been doing, and what it has cost.
*
* This is the audit surface. An agent-native CRM is only defensible if the
* agent's work is legible after the fact, so everything the ledger knows is
* shown rather than summarised away: the turn that failed, the task that is
* still queued, the money that has gone.
*
* The one rule that matters here is the money. `costMicroCents` is millionths
* of a cent the unit the provider bills in and the unit the column stores
* and a turn genuinely costs a few ten-thousandths of a cent, so the naive
* rendering rounds every real figure to `$0.00`. So no raw factor is ever
* written in this file: the conversion goes through `MICRO_CENTS_PER_DOLLAR`
* every time, `spendMoney` is the only thing that formats money, and every
* figure carries its exact micro-cent value in a title attribute so a reader
* who does not believe the conversion can check it. Getting this wrong by a
* factor of anything is the worst error this panel could make.
*
* Layout: a single column that scrolls inside whatever height its parent gives
* it, so the same component is a right-hand rail on a desktop and the contents
* of a sheet on a phone. Each section collapses, which is what makes it usable
* at 393px the spend figures stay, the two lists fold away.
*/
import { useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { AlertTriangle } from 'lucide-react';
import { compactNumber, get, relativeTime } from '@/lib/api';
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
/** Mirrors `PiggyRunSummary` in apps/api/src/services/piggy-activity.ts. */
export interface PiggyRunSummary {
id: string;
kind: 'chat' | 'task';
agent: string;
/** Free text on purpose — see the note on the server type. */
status: string;
model: string | null;
label: string;
summary: string | null;
error: string | null;
inputTokens: number | null;
outputTokens: number | null;
costMicroCents: number | null;
startedAt: string;
finishedAt: string | null;
durationMs: number | null;
taskKind: string | null;
/** Present only when the transcript is the viewer's own. */
conversation: { id: string; title: string } | null;
/** Present only when the run was somebody else's — a platform admin's view. */
principal: { id: string; name: string } | null;
}
/** Mirrors `PiggyTaskSummary`. */
export interface PiggyTaskSummary {
id: string;
kind: string;
subject: string;
reason: string | null;
state: 'running' | 'queued' | 'scheduled' | 'succeeded' | 'failed' | 'skipped' | 'cancelled';
attempts: number;
maxAttempts: number;
priority: number;
dueAt: string;
startedAt: string | null;
finishedAt: string | null;
error: string | null;
}
export interface PiggyActivityResponse {
runs: PiggyRunSummary[];
tasks: PiggyTaskSummary[];
spend: { todayMicroCents: number; monthMicroCents: number; turns: number };
}
// ------------------------------------------------------------ formatting
/**
* Micro-cents to US dollars. A cent is 10^6 micro-cents; a dollar is 100 cents.
* Written as one constant so the two conversions cannot be applied separately
* and end up compounding.
*/
const MICRO_CENTS_PER_DOLLAR = 100_000_000;
/** Runs shown before the list asks to be expanded. See `allRuns`. */
const RUNS_BEFORE_EXPANDING = 8;
const EXACT = new Intl.NumberFormat('en-US');
/**
* Money, at whatever precision the figure actually has.
*
* 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 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;
// 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)));
}
/*
* Exported for the conversation's own spend figure, which sits directly beside
* this panel in the workspace rail. A second formatter for millionths of a cent
* one tab away from this one is exactly how two figures of the same money come
* to be shown at two precisions.
*/
export function spendMoney(microCents: number | null, decimals?: number): string {
if (microCents == null) return '—';
const dollars = microCents / MICRO_CENTS_PER_DOLLAR;
const digits = decimals ?? decimalsFor(dollars);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(dollars);
}
/**
* One precision for a pair of figures shown side by side.
*
* Today at six places beside the month at four reads as two different kinds of
* number rather than one number in two windows. The smallest non-zero figure
* decides, so the narrower window never rounds away to nothing.
*/
function sharedDecimals(...microCents: number[]): number {
const positive = microCents
.map((value) => Math.abs(value) / MICRO_CENTS_PER_DOLLAR)
.filter((value) => value > 0);
if (positive.length === 0) return 2;
return decimalsFor(Math.min(...positive));
}
/**
* What went wrong, in the words the person at the keyboard already heard.
*
* `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 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 =
typeof body.message === 'string'
? body.message
: 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.
return error;
}
}
/** The unit, spelled out, for the title attribute on every money figure. */
export function spendTitle(microCents: number | null): string | undefined {
if (microCents == null) return undefined;
return `${EXACT.format(microCents)} micro-cents (millionths of a US cent)`;
}
function formatDuration(ms: number | null): string | null {
if (ms == null || ms < 0) return null;
if (ms < 1_000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1_000);
return `${minutes}m ${seconds}s`;
}
/** The model name without its vendor prefix, which is the same on every row. */
function shortModel(model: string | null): string | null {
if (!model) return null;
const parts = model.split('/');
return parts[parts.length - 1] ?? model;
}
function humanise(value: string): string {
return value.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
}
// ------------------------------------------------------------- primitives
/**
* 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) };
}
/**
* A meta line: small, muted, wrapping.
*
* Separated by space rather than by interpunct characters, because these lines
* wrap at every width the panel is used at and a dot between items lands at the
* start of the next line as often as between two of them.
*/
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-xs text-muted">
{kept.map((part, index) => (
<span key={index}>{part}</span>
))}
</div>
);
}
/**
* 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 duration = formatDuration(run.durationMs);
const tokens =
run.inputTokens == null && run.outputTokens == null
? null
: `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;
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
paragraph that would otherwise own the panel. The full text stays in
the title attribute. */}
<p
className="line-clamp-2 min-w-0 flex-1 break-words text-sm font-medium leading-snug"
title={run.label}
>
{run.label}
</p>
<RunStatusBadge status={run.status} className="shrink-0" />
</div>
{run.summary ? (
<p
className="mt-1 line-clamp-2 break-words text-xs leading-relaxed text-muted"
title={run.summary}
>
{run.summary}
</p>
) : null}
{run.error ? <RunReason status={run.status} error={run.error} /> : null}
<Meta
parts={[
<span key="when">{relativeTime(run.startedAt)}</span>,
duration ? <span key="took" className="nums">{duration}</span> : null,
tokens ? <span key="tokens" className="nums">{tokens}</span> : null,
run.costMicroCents == null ? null : (
<span key="cost" className="nums" title={spendTitle(run.costMicroCents)}>
{spendMoney(run.costMicroCents)}
</span>
),
shortModel(run.model),
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,
run.conversation && !sameWords(run.label, run.conversation.title) ? (
<span
key="conversation"
// `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 group-hover/run:underline"
title={run.conversation.title}
>
{run.conversation.title}
</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>
);
}
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">
<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>
<TaskStateBadge state={task.state} className="shrink-0" />
</div>
{task.reason ? (
<p
className="mt-1 line-clamp-3 break-words text-xs leading-relaxed text-muted"
title={task.reason}
>
{task.reason}
</p>
) : null}
{task.error ? <RunReason status="failed" error={task.error} /> : null}
<Meta
parts={[
// A pending task is described by when it may next run; a finished one
// by when it finished. Showing `dueAt` for both would render a task
// that completed last week as though it were a week overdue.
<span key="when" className="nums">
{outstanding
? `due ${relativeTime(task.dueAt)}`
: `finished ${relativeTime(task.finishedAt ?? task.dueAt)}`}
</span>,
task.attempts > 0 ? (
<span key="attempts" className="nums">
attempt {task.attempts} of {task.maxAttempts}
</span>
) : null,
<span key="subject" className="nums font-mono" title={task.subject}>
{task.subject.slice(0, 8)}
</span>,
]}
/>
</li>
);
}
// ------------------------------------------------------------------ panel
export function PiggyActivityPanel({ className }: { className?: string }) {
const activity = useQuery({
queryKey: ['piggy-activity'],
queryFn: () => get<PiggyActivityResponse>('/api/piggy/activity'),
/*
* Poll faster while something is in flight. A ledger that only updates on
* navigation shows a turn as running long after it finished, which is the
* one thing an activity view must not do; polling every ten seconds
* regardless would be a request a minute from an idle tab for nothing.
*/
refetchInterval: (query) =>
query.state.data?.runs.some((run) => run.status === 'running') ? 10_000 : 60_000,
/*
* One retry, not three. The default backoff leaves the panel showing
* loading skeletons for the better part of a minute before it admits the
* read failed, and an audit surface that looks like it is still thinking
* is worse than one that says it could not read the ledger.
*/
retry: 1,
});
/*
* The ledger opens on a readable number of rows and keeps the rest one click
* away. Without this the queue below sits under twenty-five runs, which on a
* phone means the pending work the half of this panel that needs a person
* is off the bottom of a very long scroll.
*/
const [allRuns, setAllRuns] = useState(false);
const spend = activity.data?.spend;
const runs = activity.data?.runs ?? [];
const tasks = activity.data?.tasks ?? [];
const shownRuns = allRuns ? runs : runs.slice(0, RUNS_BEFORE_EXPANDING);
const average =
spend && spend.turns > 0 ? Math.round(spend.monthMicroCents / spend.turns) : null;
const spendDigits = spend
? 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 (
<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 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">
{activity.error instanceof Error
? activity.error.message
: 'The activity ledger could not be read.'}
</span>
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => void activity.refetch()}
>
Try again
</Button>
</div>
) : null}
{/*
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 || 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="grid grid-cols-2 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)}
{spend ? (
<p className="mt-2 text-xs leading-relaxed text-muted">
{spend.turns > 0 ? (
<>
<span className="nums">{EXACT.format(spend.turns)}</span> turns this month,
averaging{' '}
<span className="nums" title={spendTitle(average)}>
{spendMoney(average)}
</span>{' '}
each. Billed in micro-cents millionths of a cent and converted here.
</>
) : (
'No turns have been billed this month. Every question you ask Piggy is priced per token and lands here.'
)}
</p>
) : null}
</Section>
<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
pending would announce "nothing has run yet" about a ledger nobody
managed to open.
*/}
{!activity.data ? (
activity.isError ? (
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : runs.length === 0 ? (
<EmptyState size="inline" title={runsEmpty.title} description={runsEmpty.description} />
) : (
<>
<ul className="flex flex-col">
{shownRuns.map((run) => (
<RunRow key={run.id} run={run} />
))}
</ul>
{runs.length > RUNS_BEFORE_EXPANDING ? (
<Button
variant="ghost"
size="sm"
className="mt-2 w-full"
onClick={() => setAllRuns((was) => !was)}
>
{allRuns ? 'Show fewer' : `Show all ${runs.length} runs`}
</Button>
) : null}
</>
)}
</Section>
<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 ? (
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : tasks.length === 0 ? (
<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) => (
<TaskRow key={task.id} task={task} />
))}
</ul>
)}
</Section>
</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>}
/>
);
}
@@ -0,0 +1,479 @@
/**
* The moment a person decides whether an agent may change the company's records.
*
* Everything else in the Piggy workspace is reversible or read-only; this card
* is not. So it is built around three refusals:
*
* it never claims more than it knows only an `approval_resolved` event moves
* a card to `applied`, so `submitting` is drawn as its own state rather than
* as an optimistic tick that would have to be taken back;
* it never invites a press by accident nothing here is autofocused, the
* actions sit below the evidence rather than under the reader's thumb, and a
* held Enter cannot fire Apply twice;
* it never shows a change without its context where a field has a
* `previous`, both values are on screen, because "Move Aurelian to legal"
* means nothing to someone who cannot see where Aurelian was.
*
* 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 { ArrowRight, CheckCircle2, Loader2, ShieldAlert, TriangleAlert, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
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';
// --------------------------------------------------------------------- card
export function PiggyApprovalCard({
change,
state,
error,
onDecide,
}: {
change: PiggyProposedChange;
state: PiggyApprovalState;
error?: string;
onDecide: (decision: PiggyApprovalDecision) => void;
}) {
const headingId = useId();
/**
* Which answer is in flight.
*
* The contract hands this card a state, not a decision, so `submitting` alone
* cannot say whether the user pressed Apply or Reject and "Sending your
* decision" is a poor thing to read when you have just authorised a write to a
* customer record. Holding it locally also guards the double press: the parent
* moves to `submitting` on the same tick, but a second click dispatched before
* React re-renders would post the decision twice, and applying a change twice
* logs two activities on someone's account.
*/
const [choice, setChoice] = useState<PiggyApprovalDecision | null>(null);
// Cleared whenever the card is answerable again — a POST that never reached
// the relay puts the state back to `pending`, and a stale "Applying" label on
// a card that is waiting for a decision would be a lie about a write.
useEffect(() => {
if (state === 'pending' || state === 'failed') setChoice(null);
}, [state]);
/**
* Whether a decision has already been dispatched from this render.
*
* The buttons are disabled the moment the parent moves the card to
* `submitting`, which it does synchronously inside `onDecide` but that is
* one render away, and two clicks (or a click and a synthesised one) in the
* same tick would both get through and post the decision twice. Applying twice
* logs two calls on someone's account. Reset after every commit rather than
* only on a state change, so a parent that answers with an error instead of a
* new state leaves the buttons usable rather than dead.
*/
const dispatched = useRef(false);
useEffect(() => {
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);
};
/**
* Auto-repeat must not decide anything.
*
* Holding Enter on a focused button fires a click per repeat, and this is the
* one control in PIG where the second one is a duplicate write rather than a
* duplicate render. The first press still works; only the repeats are dropped.
*/
const swallowRepeat = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.repeat) event.preventDefault();
};
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
// A group rather than a region: a turn can propose several writes, and a
// transcript full of landmarks makes the landmark list useless.
role="group"
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 === 'rejected'
? 'border-border bg-surface-2/40'
: 'border-border',
)}
>
<div className="flex items-start gap-2 p-4 sm:p-5">
<StateIcon state={state} />
<div className="min-w-0 flex-1">
<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-1 break-words text-sm font-semibold leading-snug">
{change.summary}
</h4>
</div>
<ApprovalStateBadge state={state} decision={choice} />
</div>
{showForcedNote ? <ForcedConfirmNote kind={change.kind} /> : null}
{change.fields.length === 0 ? null : state === 'rejected' ? (
/*
A rejected change is history, and history the user has already
declined. Folding the evidence away keeps a long transcript readable
while leaving it recoverable deleting it outright would remove the
only record of what was declined, which is exactly what an audit asks
for.
*/
<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 />
</Disclosure>
) : (
<div className="border-t border-border">
<FieldList fields={change.fields} settled={false} />
</div>
)}
<div className="flex flex-col border-t border-border p-4 sm:p-5">
{/*
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.
`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
reader might want *before* answering open the account, check the
note is not already there and putting it beside the buttons keeps
the footer to a single line on a phone. It wraps above them when the
dock is too narrow for both.
*/}
{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}
{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>
);
}
// ------------------------------------------------------------------- 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-muted')} aria-hidden />;
}
if (state === 'rejected') return <XCircle className={cn(className, 'text-muted')} aria-hidden />;
if (state === 'failed') {
return <TriangleAlert className={cn(className, 'text-danger')} aria-hidden />;
}
return <ShieldAlert className={cn(className, 'text-warning')} aria-hidden />;
}
/**
* Why a card appeared in a mode that promised not to ask.
*
* Without this the user reads a stopped write as a broken mode and turns the
* guardrail off. The four guarded kinds are the ones that move money or make a
* promise to a counterparty, so the note names the kind rather than reciting the
* policy.
*/
function ForcedConfirmNote({ kind }: { kind: string }) {
return (
// 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
the mode is set to.
</span>
</p>
);
}
function kindNoun(kind: string): string {
return kind.replaceAll('_', ' ').trim() || 'guarded';
}
function StatusLine({
state,
choice,
record,
}: {
state: PiggyApprovalState;
choice: PiggyApprovalDecision | null;
record?: PiggyProposedChange['record'];
}) {
// 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 (
<p className="flex items-center gap-2 text-xs text-muted">
<Loader2 className="size-3.5 animate-spin" aria-hidden />
{choice === 'reject' ? 'Rejecting the change…' : 'Applying the change to PIG…'}
</p>
);
}
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="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>
);
}
if (state === 'rejected') {
return <p className="text-xs text-muted">Rejected. Nothing was changed.</p>;
}
// `failed` covers both a write PIG refused and a turn that ended before the
// 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>;
}
function FieldList({
fields,
settled,
}: {
fields: PiggyProposedChange['fields'];
settled: boolean;
}) {
return (
<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.
<FieldRow key={`${index}:${field.label}`} field={field} settled={settled} />
))}
</dl>
);
}
/**
* One field, with its old value where there is one.
*
* A diff without the before is not a diff, and this is the moment where the old
* value matters most: "Stage: Legal" is agreeable to anybody, "Stage: Discovery
* Legal" is the thing you either recognise or stop. `del`/`ins` carry the
* before and after semantically, with the words spelled out for readers whose
* software announces neither.
*/
function FieldRow({
field,
settled,
}: {
field: PiggyProposedChange['fields'][number];
settled: boolean;
}) {
return (
<div className="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')}
>
{field.value}
</span>
) : (
// Wraps rather than truncates: a stage name is short, a reason is a
// sentence, and the 22rem dock has to hold both without a scrollbar.
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<del className="min-w-0 break-words text-sm leading-5 text-muted decoration-muted/70">
<span className="sr-only">Was: </span>
{field.previous}
</del>
<ArrowRight className="size-3.5 shrink-0 self-center text-muted" aria-hidden />
<ins
className={cn(
'min-w-0 break-words text-sm font-medium leading-5 no-underline',
settled ? 'text-muted' : 'text-fg',
)}
>
<span className="sr-only">Becomes: </span>
{field.value}
</ins>
</span>
)}
</dd>
</div>
);
}
@@ -0,0 +1,978 @@
/**
* Piggy's history rail: every conversation this person has had, newest first.
*
* 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 /
* Earlier is how people already hold the week in their heads, and the headers
* stick so the answer stays on screen while the list scrolls under it.
*
* **`running` is a prop, never a field this component fetches.** The server
* does not persist "a turn is in flight" and should not: it is live state
* belonging to the open stream, and a flag in Postgres would survive a crashed
* relay and mark a thread busy forever. `PiggyConversationSummary.running` is
* honoured if a future endpoint ever sets it, but the workspace's own
* `runningId` is the source of truth.
*
* **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. `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';
import {
AlertTriangle,
MessageSquarePlus,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react';
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, Label, Skeleton, cn } from '@/components/ui';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
/** One key for the whole history, so every mutation invalidates the same list. */
const CONVERSATIONS_KEY = ['piggy', 'conversations'] as const;
/**
* Mirrors `PIGGY_TITLE_MAX` on the server, which truncates silently rather than
* refusing. Enforcing it in the input means the title the user reads back is the
* title that was stored, instead of one that lost its last few words on save.
*/
const TITLE_MAX = 120;
/** A stable empty array, so `conversations` does not change identity per render. */
const NO_CONVERSATIONS: PiggyConversationSummary[] = [];
const TIME_OF_DAY = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
const WEEKDAY = new Intl.DateTimeFormat('en-US', { weekday: 'short' });
// ------------------------------------------------------------------ the data
function useConversationsQuery() {
return useQuery({
queryKey: CONVERSATIONS_KEY,
queryFn: () => get<PiggyConversationSummary[]>('/api/piggy/conversations'),
});
}
/**
* The list plus the three writes that change it.
*
* Rename and delete are optimistic. Not for the milliseconds the endpoint is
* fast but because both are direct manipulations of a row the user is looking
* at: a title that stays wrong until a refetch lands reads as the rename having
* failed, and people press it again.
*/
function useConversationMutations() {
const queryClient = useQueryClient();
const settle = () => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
};
const create = useMutation({
mutationFn: () => post<{ id: string }>('/api/piggy/conversations', {}),
onSuccess: settle,
});
const rename = useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
patch<{ id: string }>(`/api/piggy/conversations/${id}`, { title }),
onMutate: async ({ id, title }) => {
// Without the cancel, a refetch already in flight can land after the
// optimistic write and paint the old title back over the new one.
await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
current?.map((entry) => (entry.id === id ? { ...entry, title } : entry)),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
toast.error(error instanceof Error ? error.message : 'The rename did not save.');
},
onSettled: settle,
});
const remove = useMutation({
mutationFn: (id: string) =>
api<{ id: string; deleted: boolean }>(`/api/piggy/conversations/${id}`, {
method: 'DELETE',
}),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
current?.filter((entry) => entry.id !== id),
);
return { previous };
},
onError: (error, _id, context) => {
if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
toast.error(error instanceof Error ? error.message : 'The conversation was not deleted.');
},
onSettled: settle,
});
return { create, rename, remove };
}
export function usePiggyConversations(): {
conversations: PiggyConversationSummary[];
isLoading: boolean;
create: () => Promise<string>;
rename: (id: string, title: string) => Promise<void>;
remove: (id: string) => Promise<void>;
} {
const query = useConversationsQuery();
const { create, rename, remove } = useConversationMutations();
const createConversation = useCallback(async () => {
const created = await create.mutateAsync();
return created.id;
}, [create]);
const renameConversation = useCallback(
async (id: string, title: string) => {
await rename.mutateAsync({ id, title });
},
[rename],
);
const removeConversation = useCallback(
async (id: string) => {
await remove.mutateAsync(id);
},
[remove],
);
return {
conversations: query.data ?? NO_CONVERSATIONS,
// `isPending` rather than `isFetching`: this is "there is nothing to draw
// yet", so a background refresh does not flash the skeletons back in.
isLoading: query.isPending,
create: createConversation,
rename: renameConversation,
remove: removeConversation,
};
}
// -------------------------------------------------------------- the grouping
type Bucket = 'today' | 'yesterday' | 'week' | 'earlier';
const BUCKET_LABELS: Record<Bucket, string> = {
today: 'Today',
yesterday: 'Yesterday',
week: 'This week',
earlier: 'Earlier',
};
const BUCKET_ORDER: readonly Bucket[] = ['today', 'yesterday', 'week', 'earlier'];
interface ConversationGroup {
bucket: Bucket;
label: string;
items: PiggyConversationSummary[];
}
/**
* Buckets are computed from local midnights stepped with `setDate`, not from
* subtracting 86,400,000 milliseconds: on the two days a year the clocks move,
* a fixed-millisecond day puts 23:30 yesterday into "Today".
*/
function groupConversations(
conversations: readonly PiggyConversationSummary[],
now: number,
): ConversationGroup[] {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekStart = new Date(today);
weekStart.setDate(weekStart.getDate() - 6);
const buckets: Record<Bucket, PiggyConversationSummary[]> = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
// Sorted here as well as by the endpoint. The order is the product promise —
// "your last thread is the top row" — and it should not depend on a query
// plan in another process staying the way it is today.
const sorted = [...conversations].sort(
(left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt),
);
for (const entry of sorted) {
const at = timestamp(entry.updatedAt);
if (at >= today.getTime()) buckets.today.push(entry);
else if (at >= yesterday.getTime()) buckets.yesterday.push(entry);
else if (at >= weekStart.getTime()) buckets.week.push(entry);
else buckets.earlier.push(entry);
}
return BUCKET_ORDER.filter((bucket) => buckets[bucket].length > 0).map((bucket) => ({
bucket,
label: BUCKET_LABELS[bucket],
items: buckets[bucket],
}));
}
/** An unparseable date sorts to the bottom rather than throwing the whole list away. */
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* The time a row shows, chosen so it never repeats the header above it.
*
* A relative stamp would: under "Yesterday", every single row says "yesterday".
* Within a day the useful detail is the hour; within a week, which day; beyond
* that, the date.
*/
function formatWhen(bucket: Bucket, value: string): string {
const at = timestamp(value);
if (!at) return '';
const date = new Date(at);
if (bucket === 'today' || bucket === 'yesterday') return TIME_OF_DAY.format(date);
if (bucket === 'week') return WEEKDAY.format(date);
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);
return letter ? letter[0].toUpperCase() : '·';
}
// ------------------------------------------------------------- the component
export interface PiggyConversationListProps {
activeId: string | null;
onSelect: (id: string) => void;
onNew: () => void;
/** Icon rail for narrow desktop. Ignored on a phone — see the component. */
collapsed?: boolean;
/**
* The conversation with a turn in flight, if any.
*
* Live state the workspace owns; nothing here fetches it. Pass
* `running ? conversationId ?? null : null` from `usePiggyConversation`.
*/
runningId?: string | null;
}
export function PiggyConversationList({
activeId,
onSelect,
onNew,
collapsed = false,
runningId = null,
}: PiggyConversationListProps): JSX.Element {
const query = useConversationsQuery();
const { rename, remove } = useConversationMutations();
const isMobile = useIsMobile();
const [renamingId, setRenamingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<PiggyConversationSummary | null>(null);
/**
* 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
* matters at midnight, and a component that re-rendered every minute to catch
* it would cost more than the one row that would briefly sit under the wrong
* header until the next fetch.
*/
const groups = useMemo(() => groupConversations(conversations, Date.now()), [conversations]);
/**
* A rail is a compromise for a screen that has width to spare but not enough.
* A phone has neither, and 60px of initials taken off a 393px column would
* leave the chat unusable so on a phone this ignores `collapsed` entirely
* and renders in full, expecting to be inside the Sheet the workspace opens.
*/
const rail = collapsed && !isMobile;
const handleSelect = useCallback(
(id: string) => {
setRenamingId(null);
onSelect(id);
},
[onSelect],
);
const confirmDelete = useCallback(async () => {
const target = pendingDelete;
if (!target) return;
setPendingDelete(null);
try {
await remove.mutateAsync(target.id);
toast.success('Conversation deleted');
// Deleting the thread you are reading has to leave you somewhere. A fresh
// conversation is the only destination that is certainly still there.
if (target.id === activeId) onNew();
} catch {
/* Reported by the mutation's onError, which also rolls the row back. */
}
}, [activeId, onNew, pendingDelete, remove]);
const body = query.isPending ? (
<ListSkeleton rail={rail} />
) : query.isError ? (
<ListError rail={rail} message={query.error.message} onRetry={() => void query.refetch()} />
) : conversations.length === 0 ? (
rail ? null : (
// No button here. There is already one directly above it, highlighted
// because nothing is selected, and two identical calls to action a
// centimetre apart read as a mistake rather than an invitation.
<EmptyState
icon={<MessageSquarePlus className="size-7" aria-hidden />}
title="Ask Piggy your first question"
description="Piggy reads the book — accounts, deals, contracts, utilisation — and can draft the follow-up. Start one above and it will be kept here."
/>
)
) : (
<ul className="flex flex-col gap-px">
{groups.map((group) => (
<li key={group.bucket}>
{rail ? (
// The header has nowhere to go at 60px, so the grouping survives as
// a rule between runs of conversations. First group gets none.
group.bucket === groups[0]?.bucket ? null : (
<div className="mx-auto my-1.5 h-px w-6 bg-border" aria-hidden />
)
) : (
/* 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}
</Label>
)}
<ul className={cn('flex flex-col', rail ? 'items-center gap-1' : 'gap-px')}>
{group.items.map((conversation) =>
rail ? (
<RailRow
key={conversation.id}
conversation={conversation}
bucket={group.bucket}
active={conversation.id === activeId}
running={Boolean(conversation.running) || conversation.id === runningId}
onSelect={handleSelect}
/>
) : (
<ConversationRow
key={conversation.id}
conversation={conversation}
bucket={group.bucket}
active={conversation.id === activeId}
running={Boolean(conversation.running) || conversation.id === runningId}
renaming={conversation.id === renamingId}
onSelect={handleSelect}
onStartRename={() => setRenamingId(conversation.id)}
onCancelRename={() => setRenamingId(null)}
onCommitRename={(title) => {
setRenamingId(null);
if (title && title !== conversation.title) {
rename.mutate({ id: conversation.id, title });
}
}}
onRequestDelete={(opener) => {
deleteOpener.current = opener;
setPendingDelete(conversation);
}}
/>
),
)}
</ul>
</li>
))}
</ul>
);
return (
<TooltipProvider delayDuration={300}>
<nav
aria-label="Piggy conversations"
className={cn(
// `min-h-0` is what lets the list below scroll instead of pushing the
// whole column past the bottom of the viewport in a flex parent.
'flex h-full min-h-0 flex-col bg-surface',
rail ? 'w-[3.75rem] shrink-0' : 'w-full',
)}
>
<div
className={cn(
'border-b border-border',
rail ? 'flex justify-center p-2' : 'p-2',
// On a phone this list lives inside the workspace's Sheet, whose own
// dismiss control is pinned to the top-right corner — directly over
// a full-width button. The corner is reserved rather than fought
// over.
!rail && isMobile && 'pr-14',
)}
>
{rail ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={newConversationRef}
type="button"
variant={activeId === null ? 'secondary' : 'ghost'}
size="icon"
aria-label="New conversation"
onClick={onNew}
>
<Plus className="size-5" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New conversation</TooltipContent>
</Tooltip>
) : (
<Button
ref={newConversationRef}
type="button"
variant="outline"
className={cn(
'w-full justify-start gap-2',
// No thread selected means the composer is already on a blank
// one; showing that state stops the button reading as dead.
activeId === null && 'border-brand/40 bg-accent-subtle text-accent-fg',
)}
onClick={onNew}
>
<MessageSquarePlus className="size-4" aria-hidden />
New conversation
</Button>
)}
</div>
<div
className={cn(
// `overscroll-contain` stops a flick at the end of the history from
// scrolling the page behind it, which on a phone drags the sheet.
// The `calc` form, not `max(...)`: Tailwind's arbitrary-value parser
// drops the latter and the utility is silently never generated,
// which on a notched phone means the last row sits under the home
// indicator with nothing to say it is there.
'min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(0.5rem+var(--safe-bottom))]',
rail ? 'px-1.5 pt-1.5' : 'px-1.5',
)}
>
{body}
</div>
</nav>
{/*
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);
}}
>
<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
</AlertDialogAction>
<AlertDialogCancel>Keep it</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipProvider>
);
}
// -------------------------------------------------------------------- a row
function ConversationRow({
conversation,
bucket,
active,
running,
renaming,
onSelect,
onStartRename,
onCancelRename,
onCommitRename,
onRequestDelete,
}: {
conversation: PiggyConversationSummary;
bucket: Bucket;
active: boolean;
running: boolean;
renaming: boolean;
onSelect: (id: string) => void;
onStartRename: () => void;
onCancelRename: () => void;
onCommitRename: (title: string) => 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">
<RenameField
initial={conversation.title}
onCancel={onCancelRename}
onCommit={onCommitRename}
/>
</li>
);
}
const when = formatWhen(bucket, conversation.updatedAt);
return (
<li className="group/row relative">
{/*
* The selected row needs a marker that does not depend on the accent.
* `accent-subtle` is 96% lightness under the default monochrome palette
* and 97% under rose against a white surface that is a tint you have to
* look for, and in a list you are scanning it disappears. The bar is the
* brand at full strength, so selection is legible whatever the user's
* colour and whichever theme they are in.
*/}
{active ? (
<span
className="pointer-events-none absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-brand"
aria-hidden
/>
) : null}
<button
type="button"
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 duration-1 ease-enter',
active ? 'bg-accent-subtle text-accent-fg' : 'hover:bg-surface-2',
)}
>
{/*
* Two lines, then an ellipsis. Titles are derived from the opening
* question and routinely run to a full sentence; one line loses the
* distinguishing half of "Draft a follow-up to …" and three turns the
* rail into a wall. `break-words` only splits a word that could not fit
* on a line of its own, so an ordinary title still breaks at a space.
*/}
<span
className={cn('line-clamp-2 break-words text-sm leading-5', active && 'font-medium')}
title={conversation.title}
>
{conversation.title}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted">
{running ? (
<>
<RunningDot />
<span>Working</span>
</>
) : (
<>
{when ? <span className="tabular-nums">{when}</span> : null}
{when && conversation.messageCount > 0 ? <span aria-hidden>·</span> : null}
{conversation.messageCount > 0 ? (
<span className="truncate">
{conversation.messageCount} message{conversation.messageCount === 1 ? '' : 's'}
</span>
) : null}
</>
)}
</span>
</button>
{/*
* Outside the row button rather than inside it: a button inside a button
* is invalid markup, and browsers resolve it by firing both handlers, so
* opening the menu would also switch conversations.
*/}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={actionsRef}
type="button"
// 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 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.
'[@media(hover:hover)]:opacity-0',
'[@media(hover:hover)]:group-hover/row:opacity-100',
)}
>
<MoreHorizontal className="size-4" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem className="min-h-11" onSelect={() => onStartRename()}>
<Pencil aria-hidden />
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 text-danger focus:text-danger"
onSelect={() => onRequestDelete(actionsRef.current)}
>
<Trash2 aria-hidden />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
}
/**
* The inline rename editor.
*
* Deliberately not a `<form>`: this list is dropped into whatever the workspace
* is, and a form nested inside the composer's form would be invalid markup with
* a submit that fires the wrong one. Enter and Escape are handled directly.
*/
function RenameField({
initial,
onCancel,
onCommit,
}: {
initial: string;
onCancel: () => void;
onCommit: (title: string) => void;
}) {
const [value, setValue] = useState(initial);
const inputRef = useRef<HTMLInputElement>(null);
/**
* Escape blurs the field, and blur commits so without this the cancel key
* would save. Set synchronously in the key handler, read in the blur that
* follows it.
*/
const cancelledRef = useRef(false);
useEffect(() => {
// Selected backwards on purpose. `select()` leaves the caret at the end,
// which scrolls a 120-character title so that only its last few words are
// visible — the half the user is least likely to be editing. A backward
// selection puts the caret at the start and shows the beginning.
inputRef.current?.setSelectionRange(0, inputRef.current.value.length, 'backward');
}, []);
const commit = () => {
if (cancelledRef.current) return;
onCommit(value.trim());
};
return (
<div className="flex flex-col gap-1">
<Input
ref={inputRef}
value={value}
maxLength={TITLE_MAX}
autoFocus
aria-label="Conversation title"
// No `text-sm` here, however well it would match the rows: the base
// stylesheet floors every input at 16px so that focusing one does not
// make mobile Safari zoom the viewport and never zoom back out.
className="h-11"
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelledRef.current = true;
onCancel();
}
}}
/>
<p className="px-1 text-xs leading-4 text-muted">Enter to save · Escape to cancel</p>
</div>
);
}
function RailRow({
conversation,
bucket,
active,
running,
onSelect,
}: {
conversation: PiggyConversationSummary;
bucket: Bucket;
active: boolean;
running: boolean;
onSelect: (id: string) => void;
}) {
const when = formatWhen(bucket, conversation.updatedAt);
return (
<li>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onSelect(conversation.id)}
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 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
// `surface-2` is a one-percent difference in lightness.
active
? 'bg-brand text-accent-on'
: 'text-muted hover:bg-surface-2 hover:text-fg',
)}
>
<span aria-hidden>{railInitial(conversation.title)}</span>
{running ? (
// The dot sits on its own patch of the rail's background, because
// the selected square is painted in the same brand colour and the
// marker would otherwise vanish on exactly the conversation most
// likely to be running.
<span className="absolute -right-1 -top-1 rounded-full bg-surface p-0.5">
<RunningDot />
</span>
) : null}
</button>
</TooltipTrigger>
{/* The rail shows one letter, so the tooltip is the only place the
thread is actually named. It carries the timestamp too, because the
headers that would have grouped it are gone at this width. */}
<TooltipContent side="right" className="max-w-[16rem]">
<p className="line-clamp-3 break-words">{conversation.title}</p>
<p className="mt-0.5 text-muted-foreground">
{running ? 'Working…' : when}
</p>
</TooltipContent>
</Tooltip>
</li>
);
}
/** A turn in flight. `motion-reduce` because a pulse in a list is decoration. */
function RunningDot() {
return (
<span className="relative flex size-1.5 shrink-0" aria-hidden>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-brand opacity-75 motion-reduce:animate-none" />
<span className="relative inline-flex size-1.5 rounded-full bg-brand" />
</span>
);
}
// ------------------------------------------------------- loading and failure
function ListSkeleton({ rail }: { rail: boolean }) {
if (rail) {
return (
<div className="flex flex-col items-center gap-1" aria-busy>
<span className="sr-only">Loading conversations</span>
{[0, 1, 2, 3].map((row) => (
<Skeleton key={row} className="size-11 rounded-lg" />
))}
</div>
);
}
return (
<div className="flex flex-col gap-1 pt-3" aria-busy>
<span className="sr-only">Loading conversations</span>
{/* Uneven widths, because a column of identical bars reads as a loaded
table rather than as something still arriving. */}
{['w-3/4', 'w-full', 'w-2/3', 'w-5/6', 'w-1/2'].map((width, index) => (
<div key={width} className="flex flex-col gap-1.5 px-1.5 py-2">
<Skeleton className={cn('h-4', width)} />
<Skeleton className={cn('h-3', index % 2 === 0 ? 'w-1/3' : 'w-1/4')} />
</div>
))}
</div>
);
}
function ListError({
rail,
message,
onRetry,
}: {
rail: boolean;
message: string;
onRetry: () => void;
}) {
if (rail) {
return (
<div className="flex justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="History unavailable. Try again."
onClick={onRetry}
>
<AlertTriangle className="size-5 text-warning" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[16rem]">
History unavailable. Press to try again.
</TooltipContent>
</Tooltip>
</div>
);
}
return (
<EmptyState
icon={<AlertTriangle className="size-7" aria-hidden />}
title="History unavailable"
description={message}
action={
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" aria-hidden />
Try again
</Button>
}
/>
);
}
+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}
@@ -0,0 +1,383 @@
/**
* What Piggy is allowed to do, chosen before the question is asked.
*
* 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. 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
* and nobody choosing a permission should have to learn it.
* consequence the sentence under the segments describes the mode that is
* 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
* auto will reasonably assume nothing stops, and will either be
* frightened of the mode or trust it further than it deserves.
*
* The mode is NOT enforced here. `requiresApproval` in @pig/core is the single
* source of truth and the agent applies it server-side; this control only tells
* the relay what the user picked. Treating it as a guard would put the
* authorisation in the browser, where the user can edit it.
*/
import { useCallback, useEffect, useId, useRef, useState, type JSX } from 'react';
import { Eye, ListChecks, TriangleAlert, Zap, type LucideIcon } from 'lucide-react';
import {
PIGGY_ALWAYS_CONFIRM_KINDS,
type PiggyGuardedKind,
type PiggyMode,
} from '@pig/core';
import { PIGGY_DEFAULT_MODE } from '@/lib/piggy-chat';
import { useOptionalIdentity } from '@/lib/identity';
import { Label, cn } from '@/components/ui';
// ------------------------------------------------------------------- copy
interface ModeOption {
value: PiggyMode;
/** The user's word for it. */
label: string;
icon: LucideIcon;
/** What choosing this mode means, in one sentence, present tense. */
sentence: string;
/** True when picking it hands an agent the ability to write unattended. */
consequential?: boolean;
}
/**
* The four kinds `requiresApproval` refuses to automate, spelled for a person.
*
* Derived from `PIGGY_ALWAYS_CONFIRM_KINDS` rather than typed out, because the
* sentence is a promise about policy: if a fifth guarded kind is added upstream
* and this copy were a literal, the control would quietly go on promising four.
* The map is exhaustive by type, so adding one there fails the build here.
*/
const GUARDED_KIND_LABELS: Record<PiggyGuardedKind, string> = {
contract: 'contracts',
commitment: 'commitments',
allocation: 'allocations',
compliance: 'compliance',
};
const GUARDED_SENTENCE = (() => {
const names = PIGGY_ALWAYS_CONFIRM_KINDS.map((kind) => GUARDED_KIND_LABELS[kind]);
// en-GB: "contracts, commitments, allocations and compliance".
const list = new Intl.ListFormat('en-GB', { style: 'long', type: 'conjunction' }).format(names);
return `${list.charAt(0).toUpperCase()}${list.slice(1)} still stop for your approval.`;
})();
const READ_ONLY_OPTION: ModeOption = {
value: 'read_only',
label: 'Read only',
icon: Eye,
sentence: 'Piggy answers from your CRM and is offered no tool that could change it.',
};
const MODE_OPTIONS: readonly ModeOption[] = [
READ_ONLY_OPTION,
{
value: 'confirm',
label: 'Ask first',
icon: ListChecks,
sentence: 'Piggy proposes each change and nothing is saved until you press Apply.',
},
{
value: 'auto',
label: 'Auto',
icon: Zap,
sentence: 'Piggy makes changes to your CRM itself, without asking first.',
consequential: true,
},
];
/** Why the write modes are unavailable. Shown, never merely implied. */
const NO_WRITE_REASON =
'Your access does not allow changing records, so Piggy can only read.';
/**
* The user's word for a mode, and its icon, for a control that summarises this
* one rather than replacing it the workspace header's trigger.
*
* Exported rather than restated at the call site: the trigger says what the
* segments say, and a second copy of "Ask first" is a second opinion waiting to
* disagree with this file the first time the copy is edited.
*/
export function piggyModeSummary(mode: PiggyMode): { label: string; icon: LucideIcon } {
const option = optionFor(mode);
return { label: option.label, icon: option.icon };
}
function optionFor(mode: PiggyMode): ModeOption {
// The union is closed and the array covers it; the fallback exists so a mode
// read back from storage on a future build cannot render an empty control.
return MODE_OPTIONS.find((option) => option.value === mode) ?? READ_ONLY_OPTION;
}
// ---------------------------------------------------------------- control
export function PiggyModeControl({
value,
onChange,
compact = false,
canWrite,
}: {
value: PiggyMode;
onChange: (mode: PiggyMode) => void;
compact?: boolean;
canWrite: boolean;
}): JSX.Element {
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
* grant was removed would otherwise open the composer being told Piggy is
* about to edit records it will now be refused.
*/
const selected: PiggyMode = canWrite ? value : 'read_only';
useEffect(() => {
// The correction is pushed up rather than kept local, because the parent is
// what puts `mode` on the wire. Showing read-only while sending `auto`
// would be the one disagreement this control must never have. It cannot
// loop: the parent's next value satisfies the condition.
if (!canWrite && value !== 'read_only') onChange('read_only');
}, [canWrite, value, onChange]);
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 === roving);
moveTo(choices[(index + direction + choices.length) % choices.length]?.value);
},
[choices, moveTo, roving],
);
/**
* 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 : <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-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);
} 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);
}
}}
>
{MODE_OPTIONS.map((option) => {
const isSelected = option.value === selected;
const disabled = !canWrite && option.value !== 'read_only';
const Icon = option.icon;
return (
<button
key={option.value}
ref={(node) => {
if (node) buttons.current.set(option.value, node);
else buttons.current.delete(option.value);
}}
type="button"
role="radio"
aria-checked={isSelected}
// Roving tabstop: a radio group is one stop in the tab order, and
// the arrow keys move within it.
tabIndex={option.value === roving ? 0 : -1}
disabled={disabled}
title={disabled ? NO_WRITE_REASON : option.sentence}
onClick={() => {
setKeyboardAt(option.value);
onChange(option.value);
}}
className={cn(
'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-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',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<Icon
aria-hidden
className={cn(
'shrink-0',
compact ? 'h-3 w-3' : 'h-4 w-4',
isSelected && option.consequential ? 'text-warning' : undefined,
)}
/>
<span className="truncate">{option.label}</span>
</button>
);
})}
</div>
{/*
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. 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 text-xs leading-snug">
{active.consequential ? (
<p
className={cn(
'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="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="mt-1 text-muted">{NO_WRITE_REASON}</p>}
</div>
</div>
);
}
// -------------------------------------------------------------- preference
/**
* Per user, not per browser.
*
* Two people share a laptop far more often than a CRM's security model likes to
* admit, and a single `pig.piggy.mode` key would hand the second one an agent
* already licensed to write by the first. The signed-in id is part of the key
* for that reason alone.
*/
const MODE_STORAGE_PREFIX = 'pig.piggy.mode.';
function isMode(value: unknown): value is PiggyMode {
return MODE_OPTIONS.some((option) => option.value === value);
}
function readStoredMode(key: string | null): PiggyMode | null {
if (!key) return null;
try {
const raw = localStorage.getItem(key);
// Validated rather than cast: a value written by an older build, or edited
// by hand, would otherwise travel to the relay as a mode and collect a 400
// on every turn until someone cleared their storage.
return isMode(raw) ? raw : null;
} catch {
// Private browsing throws on access. The default is the safe one anyway.
return null;
}
}
/**
* The stored answer to "what may Piggy do", defaulting to `read_only`.
*
* `PIGGY_DEFAULT_MODE` is imported rather than restated so this cannot become a
* second opinion on what "safe" means; it is read-only, which is both the
* safest mode and a useful one Piggy still answers every question it can
* answer, and the only thing withheld is the ability to change records, which
* is exactly the thing a person should turn on knowingly. Defaulting to
* `confirm` would be defensible on the grounds that it never writes unasked,
* but it puts write tools in front of the model on first use for someone who
* never asked for them, and the relay would then be told so on every turn.
*/
export function usePiggyMode(): { mode: PiggyMode; setMode: (mode: PiggyMode) => void } {
const identity = useOptionalIdentity();
const key = identity ? `${MODE_STORAGE_PREFIX}${identity.id}` : null;
const [mode, setModeState] = useState<PiggyMode>(() => readStoredMode(key) ?? PIGGY_DEFAULT_MODE);
useEffect(() => {
// Re-read whenever the person changes. Falling back to the default rather
// than keeping what is on screen matters here: a new signed-in user with no
// stored preference must not inherit the last one's `auto`.
setModeState(readStoredMode(key) ?? PIGGY_DEFAULT_MODE);
}, [key]);
const setMode = useCallback(
(next: PiggyMode) => {
setModeState(next);
if (!key) return;
try {
localStorage.setItem(key, next);
} catch {
// Non-fatal: the choice simply does not survive the tab, and the next
// one opens read-only, which is the harmless direction to fail in.
}
},
[key],
);
return { mode, setMode };
}
@@ -0,0 +1,534 @@
/**
* Which model answers, and what that costs.
*
* This is a small control carrying a large argument. Every entry in the
* catalogue NVIDIA's Nemotron, DeepSeek, Anthropic's Opus, OpenAI's GPT is
* served by Prime Intellect's own inference on a single API key. Nowhere else
* in PIG is that visible; a transcript footer naming the model is a fact, but a
* menu of five vendors under one key is the pitch. So the menu says so, once,
* quietly, at the bottom.
*
* The catalogue is the server's, never this file's. The relay refuses any id it
* did not send, so a hard-coded option that has been retired upstream is a menu
* entry whose only effect is a 400 and a *stored* id that has been retired is
* the same 400 on every turn until someone clears their browser storage. Both
* are handled below rather than left to the user to discover.
*
* On presenting cost: "$0.05 / $0.20 per Mtok" is the unit providers publish
* and it is meaningless to the sales lead this product is for. The headline
* figure is therefore an estimate of what a hundred questions cost, derived
* from a real measured read-only turn, with the raw per-Mtok rates kept on a
* secondary line for whoever wants to check the arithmetic.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useQuery } from '@tanstack/react-query';
// `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';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge, Button, Label, Skeleton, cn } from '@/components/ui';
// ------------------------------------------------------------------ catalogue
export const PIGGY_MODELS_QUERY_KEY = ['piggy', 'models'] as const;
/**
* A stable empty array for the pending and failed cases.
*
* `?? []` would hand every render a new reference, which is enough to re-run
* any effect below that depends on the list including the one that repairs an
* invalid selection, which would then loop.
*/
const NO_MODELS: PiggyModelOption[] = [];
export interface PiggyModelCatalogue {
models: PiggyModelOption[];
defaultModelId: string | null;
isLoading: boolean;
error: Error | null;
}
/**
* The models this deployment offers.
*
* Cached for the life of the tab: the catalogue is deployment configuration,
* not data. It cannot change while the page is open, and a refetch on window
* focus would put a network round trip behind a control the user is in the act
* of opening.
*/
export function usePiggyModels(): PiggyModelCatalogue {
const query = useQuery({
queryKey: PIGGY_MODELS_QUERY_KEY,
queryFn: fetchPiggyModels,
staleTime: Infinity,
gcTime: Infinity,
retry: 1,
});
return {
models: query.data?.models ?? NO_MODELS,
defaultModelId: query.data?.defaultModelId ?? null,
isLoading: query.isLoading,
error: toError(query.error),
};
}
function toError(value: unknown): Error | null {
if (!value) return null;
return value instanceof Error ? value : new Error(String(value));
}
// ----------------------------------------------------------------- what it costs
/**
* A real read-only turn, measured against nemotron on the live stack: roughly
* 5,300 tokens in (the system prompt, the tool schemas and the CRM context
* dominate) and 150 out. Every price in this menu is that same turn priced on a
* different model, which is the only way five figures spanning a hundredfold
* are comparable at a glance.
*/
const TYPICAL_INPUT_TOKENS = 5_300;
const TYPICAL_OUTPUT_TOKENS = 150;
/**
* A single question on the cheapest model costs three hundredths of a cent, and
* "$0.0003" is a number nobody can rank against another number. Quoting a
* hundred questions puts the whole catalogue in the range people actually price
* things in three cents to three dollars.
*/
const QUOTED_QUESTIONS = 100;
const QUOTE_FORMAT = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* Rates are published as round dollars ($5, $25) as often as fractions ($0.05).
* Two formatters rather than one: a single `minimumFractionDigits: 0` renders
* $0.20 as "$0.2", which reads as a typo next to "$0.05" in the same column.
*/
const WHOLE_RATE_FORMAT = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const PART_RATE_FORMAT = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatRate(dollars: number): string {
return Number.isInteger(dollars)
? WHOLE_RATE_FORMAT.format(dollars)
: PART_RATE_FORMAT.format(dollars);
}
/**
* US dollars for one typical question.
*
* `costPerMTok*` is dollars per million tokens not cents, and deliberately
* not, per the note in the protocol. Nothing here may be run through the
* `Cents` formatters in lib/api.
*/
function dollarsPerQuestion(model: PiggyModelOption): number {
return (
(TYPICAL_INPUT_TOKENS * model.costPerMTokIn + TYPICAL_OUTPUT_TOKENS * model.costPerMTokOut) /
1_000_000
);
}
function formatQuote(model: PiggyModelOption): string {
const total = dollarsPerQuestion(model) * QUOTED_QUESTIONS;
// A model cheap enough to round to zero would otherwise be quoted "$0.00",
// which reads as free rather than as very cheap.
return total > 0 && total < 0.01 ? 'under $0.01' : QUOTE_FORMAT.format(total);
}
function formatRates(model: PiggyModelOption): string {
return `${formatRate(model.costPerMTokIn)} in / ${formatRate(model.costPerMTokOut)} out per Mtok`;
}
function formatContext(tokens: number): string {
return tokens >= 1_000 ? `${Math.round(tokens / 1_000)}K` : String(tokens);
}
/**
* How close to the dearest model a model has to be to count as top tier.
*
* The two frontier entries are priced within a few per cent of each other, and
* naming only the very dearest "most capable" would be PIG picking a winner
* between two vendors on a rounding difference. A band names the tier instead,
* which is the true statement.
*/
const TOP_TIER_RATIO = 0.85;
/**
* Which entry is cheapest, and which are the ones to reach for when it matters.
*
* Price is the proxy for capability, because the catalogue carries no capability
* score and price is the only ordering the server actually sends. The
* alternative a list of model ids ranked in this file is a second source of
* truth that goes stale the first time the deployment adds a model.
*/
function priceBands(models: PiggyModelOption[]): {
cheapestId: string | null;
topTierIds: ReadonlySet<string>;
} {
const empty: ReadonlySet<string> = new Set<string>();
if (models.length < 2) return { cheapestId: null, topTierIds: empty };
let cheapest: PiggyModelOption | null = null;
let dearest = 0;
for (const model of models) {
const cost = dollarsPerQuestion(model);
if (!cheapest || cost < dollarsPerQuestion(cheapest)) cheapest = model;
if (cost > dearest) dearest = cost;
}
if (!cheapest || dearest <= 0) return { cheapestId: null, topTierIds: empty };
const cheapestId = cheapest.id;
const topTierIds = new Set(
models
// The cheapest model is never also the top tier, however flat the
// catalogue's pricing happens to be.
.filter(
(model) =>
model.id !== cheapestId && dollarsPerQuestion(model) >= dearest * TOP_TIER_RATIO,
)
.map((model) => model.id),
);
return { cheapestId, topTierIds };
}
// ------------------------------------------------------------------- persistence
const STORAGE_PREFIX = 'pig:piggy:model';
function storageKey(userId: string | null): string {
return userId ? `${STORAGE_PREFIX}:${userId}` : STORAGE_PREFIX;
}
export function readStoredPiggyModelId(userId: string | null): string | null {
try {
return localStorage.getItem(storageKey(userId));
} catch {
/* Private browsing throws on localStorage. The deployment default is fine. */
return null;
}
}
export function writeStoredPiggyModelId(userId: string | null, modelId: string | null): void {
try {
const key = storageKey(userId);
if (modelId) localStorage.setItem(key, modelId);
else localStorage.removeItem(key);
} catch {
/* As above: an unpersisted preference is a smaller problem than a throw. */
}
}
export interface PiggyModelChoice extends PiggyModelCatalogue {
/**
* The id to send with a turn: the stored choice when the catalogue still
* lists it, otherwise the deployment default. Null only while the catalogue
* is loading or unavailable, in which case send no `modelId` at all.
*/
modelId: string | null;
/** True when the user picked this, false when it is the deployment default. */
isExplicit: boolean;
setModelId: (modelId: string) => void;
}
/**
* The choice, persisted per user, with the server as the authority on validity.
*
* Pair this with `PiggyModelPicker` `value={modelId}` and
* `onChange={setModelId}` rather than a plain `useState`, or the preference
* is remembered for the session only.
*/
export function usePiggyModelChoice(): PiggyModelChoice {
const catalogue = usePiggyModels();
const userId = useIdentityQuery().data?.id ?? null;
const [stored, setStored] = useState<string | null>(() => readStoredPiggyModelId(userId));
// On a cold cache the signed-in user arrives a tick after first render, so
// the key this first read from was the anonymous one. Re-read once it settles
// rather than showing whatever the previous person on this browser chose.
useEffect(() => {
setStored(readStoredPiggyModelId(userId));
}, [userId]);
const known = catalogue.models.some((model) => model.id === stored);
// A stored id the relay no longer lists is a guaranteed 400 on every
// subsequent turn, and the user has no way to connect that error to a choice
// they made weeks ago. Drop it as soon as the catalogue contradicts it.
useEffect(() => {
if (!stored || known) return;
if (catalogue.isLoading || catalogue.models.length === 0) return;
writeStoredPiggyModelId(userId, null);
setStored(null);
}, [stored, known, catalogue.isLoading, catalogue.models, userId]);
const setModelId = useCallback(
(modelId: string) => {
writeStoredPiggyModelId(userId, modelId);
setStored(modelId);
},
[userId],
);
const isExplicit = Boolean(stored) && known;
return {
...catalogue,
modelId: isExplicit ? stored : catalogue.defaultModelId,
isExplicit,
setModelId,
};
}
// ------------------------------------------------------------------------ picker
/**
* The label the tight trigger shows.
*
* The protocol carries no short form, and the docked panel header is 22rem
* wide enough for about a dozen characters once the icon and chevron are
* paid for. Dropping the family prefix from a long name keeps the part that
* distinguishes it ("Nano 30B", "Super 120B") rather than the part every entry
* in a family shares; shorter names are already short enough to leave alone.
*/
const LONG_LABEL_WORDS = 4;
function shortLabel(label: string): string {
const words = label.split(/\s+/).filter(Boolean);
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;
onChange: (modelId: string) => void;
/** The tight rendering, for the 22rem docked panel header. */
compact?: boolean;
disabled?: boolean;
}
export function PiggyModelPicker({
value,
onChange,
compact = false,
disabled = false,
}: PiggyModelPickerProps): JSX.Element {
const { models, defaultModelId, isLoading, error } = usePiggyModels();
const userId = useIdentityQuery().data?.id ?? null;
const selected = models.find((model) => model.id === value) ?? null;
const fallback =
models.find((model) => model.id === defaultModelId) ??
models.find((model) => model.isDefault) ??
null;
const inForce = selected ?? fallback;
/**
* Whether the model in force is the deployment's own default.
*
* Deliberately a fact about the model, not about how it was arrived at. The
* caller may resolve a null preference to the default id before passing it
* in, so "the user made no choice" is not reliably visible here and it is
* not the interesting question anyway. What the user needs to know is which
* model is answering and whether that is the shipped one.
*/
const isDeploymentDefault = Boolean(inForce && inForce.id === defaultModelId);
const { cheapestId, topTierIds } = useMemo(() => priceBands(models), [models]);
/**
* Repair a selection the catalogue does not list.
*
* The owner of `value` may be persisting it itself, or restoring it from
* somewhere this component cannot see, so showing the default while the
* caller still holds a retired id would render one model and send another.
* Correcting the caller is the only fix that reaches the request. Guarded by
* the id already repaired, so a caller that ignores `onChange` gets one
* attempt rather than an infinite loop.
*/
const repaired = useRef<string | null>(null);
useEffect(() => {
if (!value || isLoading || models.length === 0 || !defaultModelId) return;
if (models.some((model) => model.id === value)) return;
if (repaired.current === value) return;
repaired.current = value;
writeStoredPiggyModelId(userId, null);
onChange(defaultModelId);
}, [value, isLoading, models, defaultModelId, onChange, userId]);
const handleSelect = useCallback(
(modelId: string) => {
// Written here as well as in `usePiggyModelChoice` so the preference
// survives a reload however the caller holds it. Writing the same value
// twice costs nothing; losing it because the caller used `useState`
// costs the user their choice on every visit.
writeStoredPiggyModelId(userId, modelId);
onChange(modelId);
},
[onChange, userId],
);
if (isLoading) {
return <Skeleton className={cn('h-11 rounded-lg', compact ? 'w-28' : 'w-44')} />;
}
if (!inForce) {
return (
<Button
variant="outline"
size="sm"
disabled
className={cn('gap-1.5 font-normal', compact ? 'px-2' : 'px-2.5 text-sm')}
aria-label="The model list is unavailable"
title={error?.message ?? 'Piggy did not return a model list.'}
>
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
{compact ? null : <span className="text-muted">Model unavailable</span>}
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={compact ? 'ghost' : 'outline'}
size="sm"
disabled={disabled}
aria-label={`Model: ${inForce.label}${isDeploymentDefault ? ', the deployment default' : ''}. Change the model Piggy answers with.`}
className={cn(
'gap-1.5 font-normal data-[state=open]:bg-surface-2',
compact ? 'max-w-[11rem] px-2' : 'max-w-[18rem] px-2.5 text-sm',
)}
>
<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-xs text-muted">Default</span>
) : null}
<ChevronsUpDown className="size-3.5 shrink-0 text-muted" aria-hidden />
</Button>
</DropdownMenuTrigger>
{/*
Above the sheet and drawer primitives, which sit at z-50 themselves: the
same trigger appears inside the mobile drawer, and a menu that opens
behind the surface that spawned it is a control that simply does not
work on a phone.
*/}
<DropdownMenuContent
align="end"
sideOffset={6}
className="z-[60] w-[min(26rem,calc(100vw-1.5rem))] p-1.5"
>
<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}>
{models.map((model) => (
<DropdownMenuRadioItem
key={model.id}
value={model.id}
// The indicator is absolutely positioned with no `top`, so it
// would ride the top edge of a three-line row; nudged down to sit
// against the label rather than the padding above it.
className="items-start gap-2 rounded-md py-2.5 pl-8 pr-2 [&>span]:top-3"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<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">
Default
</Badge>
) : null}
{model.id === cheapestId ? (
<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">
Most capable
</Badge>
) : null}
</div>
{model.hint ? (
<p className="whitespace-normal text-xs leading-snug text-muted">{model.hint}</p>
) : null}
<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-xs leading-tight text-muted">per 100 questions</span>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<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.
</p>
</DropdownMenuContent>
</DropdownMenu>
);
}
+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;
}
@@ -0,0 +1,218 @@
/**
* The two decisions a person makes before they press send: which model answers,
* and what it is allowed to do.
*
* They are one component because they are one row on every surface that shows
* them the workspace header, the docked panel's composer, the phone drawer
* and because they have to agree with each other and with what is actually put
* on the wire. `usePiggyControls` is the half that guarantees the last part: the
* preferences live per user in localStorage, the conversation holds what the
* next turn will send, and this binds one to the other so the header cannot show
* "Ask first" while the composer sends `read_only`.
*
* The mode control is behind a popover rather than sitting inline. Its segments
* carry a consequence sentence that changes with the selection three lines of
* it in `auto` which is exactly right in a panel and impossible in a header
* strip. The trigger names the mode in the same words the segments use, so
* nothing is hidden except the explanation, which is one press away.
*/
import { useEffect, type ReactNode } from 'react';
import { ChevronDown } from 'lucide-react';
import type { PiggyChatContext, PiggyMode } from '@pig/core';
import { useOptionalIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
usePiggyConversation,
type PiggyConversation,
type TranscriptMessage,
} from '@/lib/piggy-chat';
import { PiggyModeControl, piggyModeSummary, usePiggyMode } from '@/components/piggy/mode-control';
import { PiggyModelPicker, usePiggyModelChoice } from '@/components/piggy/model-picker';
import { Button, cn } from '@/components/ui';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
/**
* The grants Piggy's write tools actually enforce, server-side, one per tool
* family. Held here because `GET /api/piggy/status` does not report whether the
* caller may write, and a mode control offered to someone without any of these
* is three segments, two of which turn every proposed change into a 403.
*
* Display only. The tools check capabilities themselves against the team the
* record belongs to; this asks the weaker question could this person write
* anywhere at all because the mode is chosen before any record is named.
*/
const PIGGY_WRITE_CAPABILITIES = [
'deal:write',
'activity:write',
'commitment:write',
'contract:sign',
] as const;
export interface PiggyControlsState {
mode: PiggyMode;
setMode: (mode: PiggyMode) => void;
modelId: string | null;
setModelId: (modelId: string) => void;
canWrite: boolean;
}
/**
* A conversation and the controls that decide what it may do, created together.
*
* They are one hook because the order matters and getting it wrong is invisible
* until it costs a write. The conversation owns `mode` and `modelId` it
* outlives every panel that draws a control for them while the preferences
* own the same two values because they outlive the conversation. If the
* conversation is created first and corrected by an effect afterwards, there is
* a window one render wide in which it holds `read_only` while the header says
* `Ask first`, and anything that sends inside that window (a suggestion pressed
* on mount, a thread resumed with a question already in hand) sends the mode
* from before the correction. Seeding at construction closes the window; the
* effects below then only carry later changes.
*/
export function usePiggyChatSession(options: {
context?: PiggyChatContext;
initialPrompt?: string;
/** A stored transcript being resumed. See `usePiggyConversation`. */
initialMessages?: TranscriptMessage[];
initialConversationId?: string;
} = {}): { conversation: PiggyConversation; controls: PiggyControlsState } {
const { mode, setMode } = usePiggyMode();
const model = usePiggyModelChoice();
const identity = useOptionalIdentity();
const canWrite = PIGGY_WRITE_CAPABILITIES.some((capability) =>
canAny(identity ?? undefined, capability),
);
const conversation = usePiggyConversation({
...options,
initialMode: canWrite ? mode : 'read_only',
// Null is "no stored preference", which the relay reads as its own default.
// Resolving it to a model id here would be this file guessing which one.
initialModelId: model.modelId ?? undefined,
});
const { setMode: setConversationMode, setModelId: setConversationModelId } = conversation;
/*
* The correction B2's control performs while it is on screen, performed here
* as well because on this surface the control is inside a popover and spends
* almost all of its life unmounted. A stored `auto` that outlived the grant
* that justified it would otherwise sit in localStorage and go on the wire.
*/
useEffect(() => {
if (!canWrite && mode !== 'read_only') setMode('read_only');
}, [canWrite, mode, setMode]);
useEffect(() => {
setConversationMode(canWrite ? mode : 'read_only');
}, [canWrite, mode, setConversationMode]);
useEffect(() => {
// `undefined` is "whatever the deployment's default is" — never a guess at
// which model that is, which is why the picker's null is passed through
// rather than resolved to `defaultModelId` here.
setConversationModelId(model.modelId ?? undefined);
}, [model.modelId, setConversationModelId]);
return {
conversation,
controls: { mode, setMode, modelId: model.modelId, setModelId: model.setModelId, canWrite },
};
}
/**
* The mode, as a header-sized control.
*
* Disabled rather than hidden when nothing may be written: "you cannot change
* this" is information, and a control that vanishes reads as a missing feature.
*/
export function PiggyModeButton({
mode,
setMode,
canWrite,
compact = false,
disabled = false,
}: {
mode: PiggyMode;
setMode: (mode: PiggyMode) => void;
canWrite: boolean;
compact?: boolean;
disabled?: boolean;
}) {
const shown = canWrite ? mode : 'read_only';
const { label, icon: Icon } = piggyModeSummary(shown);
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant={compact ? 'ghost' : 'outline'}
disabled={disabled}
className={cn(
'min-w-0 gap-1.5 font-medium',
// `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}`}
>
<Icon
aria-hidden
className={cn('shrink-0', shown === 'auto' && 'text-warning')}
/>
<span className="truncate">{label}</span>
<ChevronDown aria-hidden className="size-3 shrink-0 opacity-60" />
</Button>
</PopoverTrigger>
{/* Above the sheet and drawer primitives at z-50, so the same trigger
works inside the phone overlays as it does in the header. */}
{/* 24rem, because the three segments are a grid of equal thirds and
"Read only" needs about 7rem of it: at 20rem the control opened with
two of its three labels truncated to "Read …" and "Ask fir…". */}
<PopoverContent align="end" className="z-[60] w-[min(24rem,calc(100vw-1.5rem))]">
<PiggyModeControl value={mode} onChange={setMode} canWrite={canWrite} />
</PopoverContent>
</Popover>
);
}
/**
* Both controls, in the order they are decided in: what may it do, then which
* model does it. They wrap rather than shrink at 22rem the pair is a whisker
* over one line, and a truncated model name is worse than a second row.
*/
export function PiggyControls({
controls,
compact = false,
disabled = false,
children,
className,
}: {
controls: PiggyControlsState;
compact?: boolean;
/** A turn is running: the next one's settings are already fixed. */
disabled?: boolean;
children?: ReactNode;
className?: string;
}) {
return (
<div className={cn('flex min-w-0 flex-wrap items-center gap-1.5', className)}>
<PiggyModeButton
mode={controls.mode}
setMode={controls.setMode}
canWrite={controls.canWrite}
compact={compact}
disabled={disabled}
/>
<PiggyModelPicker
value={controls.modelId}
onChange={controls.setModelId}
compact={compact}
disabled={disabled}
/>
{children}
</div>
);
}
@@ -0,0 +1,253 @@
/**
* What Piggy is doing, and what it has touched.
*
* Two views of the same question at two scopes, so they are two tabs rather
* than two stacked panels: THIS CHAT is what the conversation on screen has
* read and changed, and ACTIVITY is B5's ledger of every run the workspace has
* made and what it has cost. Stacking them would put the second half of a
* scrolling rail permanently below the fold on a laptop; a tab keeps both one
* press away and neither of them half-visible.
*
* Which one opens is decided by whether there is a conversation to describe. An
* empty transcript has no evidence, so a rail that opened on it would greet
* every new arrival with a blank column; once a question has been asked, the
* chat's own evidence is the more specific answer and takes the tab. A press
* fixes the choice after that the reader has said what they want to see and
* the transcript does not get to overrule them.
*/
import { useMemo, useState } from '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, EmptyState, Section, Stat, cn } from '@/components/ui';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function PiggyWorkspaceRail({
messages,
className,
}: {
messages: TranscriptMessage[];
className?: string;
}) {
const [chosen, setChosen] = useState<string | null>(null);
const started = messages.length > 0;
const tab = chosen ?? (started ? 'chat' : 'activity');
return (
<Tabs
value={tab}
onValueChange={setChosen}
// `min-w-0` on every level: this is a flex child, and a flex item's
// default `min-width: auto` lets a long run label — a title that is a
// whole UUID — push the rail wider than the column it lives in and spill
// over the transcript's edge. Measured at 1600: 641px of content in a
// 319px rail.
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
>
<div className="shrink-0 border-b border-border p-2">
{/* 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
border under it, and a second gap below that reads as a dropped panel. */}
<TabsContent
value="chat"
className="mt-0 min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
>
<ConversationEvidence messages={messages} />
</TabsContent>
<TabsContent
value="activity"
className="mt-0 min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
>
<PiggyActivityPanel />
</TabsContent>
</Tabs>
);
}
// ------------------------------------------------------------------ this chat
interface ConversationSummary {
turns: number;
tools: { name: string; runs: number; failures: number }[];
approvals: ApprovalStep[];
inputTokens: number;
outputTokens: number;
/** Null when no turn reported usage — which is not the same as free. */
costMicroCents: number | null;
}
function summarise(messages: TranscriptMessage[]): ConversationSummary {
const tools = new Map<string, { name: string; runs: number; failures: number }>();
const approvals: ApprovalStep[] = [];
let turns = 0;
let inputTokens = 0;
let outputTokens = 0;
let costMicroCents: number | null = null;
for (const message of messages) {
if (message.role !== 'assistant') continue;
turns += 1;
inputTokens += message.inputTokens ?? 0;
outputTokens += message.outputTokens ?? 0;
// Left null until a figure exists, so a conversation whose provider
// reported no usage reads as unknown rather than as costing nothing.
if (message.costMicroCents != null) costMicroCents = (costMicroCents ?? 0) + message.costMicroCents;
for (const tool of message.tools ?? []) {
const entry = tools.get(tool.name) ?? { name: tool.name, runs: 0, failures: 0 };
entry.runs += 1;
if (tool.state === 'failed') entry.failures += 1;
tools.set(tool.name, entry);
}
approvals.push(...(message.approvals ?? []));
}
return {
turns,
tools: [...tools.values()].sort((a, b) => b.runs - a.runs),
approvals,
inputTokens,
outputTokens,
costMicroCents,
};
}
function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
const summary = useMemo(() => summarise(messages), [messages]);
if (!summary.turns) {
return (
<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-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))}
/>
<Stat
size="sm"
surface="inset"
label="Tokens"
value={`${compactNumber(summary.inputTokens)} / ${compactNumber(summary.outputTokens)}`}
hint="in / out"
/>
{/* 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" tone="micro" level={3}>
<ul className="flex flex-col gap-1.5">
{summary.approvals.map((approval) => (
<li key={approval.change.id}>
<ChangeRow approval={approval} />
</li>
))}
</ul>
</Section>
) : null}
{/* Not "records read": the same list carries the write tools a turn
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" tone="micro" level={3}>
<ul className="flex flex-col gap-1">
{summary.tools.map((tool) => (
<li
key={tool.name}
className="flex items-center gap-2 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs"
>
<FileText aria-hidden className="size-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-fg" title={tool.name}>
{piggyToolLabel(tool.name)}
</span>
{tool.failures ? (
<Badge tone="danger">{tool.failures} failed</Badge>
) : null}
<span className="nums shrink-0 text-muted">×{tool.runs}</span>
</li>
))}
</ul>
<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>
) : null}
</div>
);
}
/**
* A proposed change, at rail width.
*
* The card in the transcript is the place a change is read and answered; this
* is the index to it, so it carries the summary, what became of it, and nothing
* that would invite a decision from a column too narrow to show the diff.
*/
function ChangeRow({ approval }: { approval: ApprovalStep }) {
const state = CHANGE_STATES[approval.state];
const Icon = state.icon;
return (
<div className="flex items-start gap-2 rounded-lg border border-border px-2.5 py-2">
<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-xs leading-5 text-muted">
{state.label}
{recordLabel(approval.change) ? ` · ${recordLabel(approval.change)}` : ''}
</p>
</div>
</div>
);
}
/**
* 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 }
> = {
// 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' },
};
function recordLabel(change: PiggyProposedChange): string | null {
if (!change.record) return null;
return change.record.label ?? change.record.type.replaceAll('_', ' ');
}
@@ -0,0 +1,250 @@
/**
* 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
* yet, and they are written against the same constraint: each one is answerable
* with the tools a `/piggy` turn is actually given the workspace summary, the
* record lookups, the renewals list and none of them names a record that only
* exists in the demo book.
*
* Pressing a write opener while Piggy is in Read only moves it to Ask first.
* That is a change to a permission, so it is never silent: the card says so
* before it is pressed, and the mode control in the header changes with it. Ask
* first cannot write unattended it proposes, and the Apply button is the
* person so the escalation this performs is from "no tools" to "a proposal
* you must approve", which is the thing the user just asked for by pressing it.
*/
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 { Label, cn } from '@/components/ui';
/**
* Openers that end in a change to the book.
*
* Every write tool takes a record id, and none of the tools a `/piggy` turn is
* given returns one from the page context alone so each of these is a lookup
* followed by a write, and none of them names a record. Naming one would make
* them land beautifully on the seeded demo book and fail on the first real
* deployment, which is the opposite of the trade this file should make.
*
* The consequence is stated to the user rather than hidden: where the sentence
* does not identify the record, Piggy asks which one instead of choosing. That
* is the behaviour a CRM should have, and it is measurably what the default
* model does see the note under the column.
*/
const WRITE_STARTERS = [
'Find the block furthest from break-even and log a note on its account.',
'Look up the contract renewing soonest and log a call about extending it.',
'Add a task to chase the account we have not spoken to in a month.',
];
const READ_STARTERS_SHOWN = 3;
export function PiggyWorkspaceStarters({
context,
mode,
canWrite,
onAsk,
onAskWithChange,
narrow = false,
}: {
context?: PiggyChatContext;
/** Only to word the note. The escalation itself belongs to the thread. */
mode: PiggyMode;
canWrite: boolean;
onAsk: (text: string) => void;
/**
* An opener that ends in a write. The thread raises the mode first and sends
* once the conversation is holding the new one `send` reads the mode out of
* the conversation, so sending in the same tick would ask for a change with
* the write tools still withheld.
*/
onAskWithChange: (text: string) => void;
/**
* 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;
}) {
/*
* Two openers each on a phone, three on a desktop.
*
* Not a taste decision: the transcript sticks to the bottom of its
* scrollport, so anything taller than the viewport opens with its own
* heading scrolled off the top. Measured at 393x852 the six-opener version
* overran by about 180px, which put the pig, the headline and the first
* column header above the fold on the screen that is supposed to introduce
* the product.
*/
const perGroup = narrow ? 2 : READ_STARTERS_SHOWN;
const reads = piggySuggestions(context).slice(0, perGroup);
const writes = WRITE_STARTERS.slice(0, perGroup);
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 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-3' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
{/* 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',
// 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',
)}
>
{piggyLine(piggyCopy.headline, narrow)}
</h2>
{/* 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
? piggyLine(piggyCopy.capability, true)
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
</p>
</div>
<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={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 : piggyLine(piggyCopy.readGroupNote, narrow)}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
{suggestion}
</StarterButton>
))}
</StarterGroup>
<StarterGroup
narrow={narrow}
icon={<PenLine aria-hidden className="size-3.5" />}
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
note={piggyLine(
canWrite
? mode === 'read_only'
? piggyCopy.writeGroupNote.readOnly
: piggyCopy.writeGroupNote.askFirst
: piggyCopy.writeGroupNote.noAccess,
narrow,
)}
>
{writes.map((suggestion) => (
<StarterButton
key={suggestion}
disabled={!canWrite}
onClick={() => onAskWithChange(suggestion)}
>
{suggestion}
</StarterButton>
))}
</StarterGroup>
</div>
</div>
);
}
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 (
/*
* 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}
</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>
);
}
function StarterButton({
children,
onClick,
disabled = false,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'group flex min-h-11 w-full items-center gap-2 rounded-lg border border-border bg-surface',
'px-3 py-2 text-left text-sm leading-5 transition-colors',
'hover:border-fg/20 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface',
)}
>
<span className="min-w-0 flex-1">{children}</span>
<ArrowRight
aria-hidden
className="size-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}
@@ -0,0 +1,158 @@
/**
* A stored conversation, read back into the shape the transcript renders.
*
* The store keeps one row per THING that happened a question, a tool call, a
* proposed change, an answer because that is what an append-only ledger has
* to do to survive a turn that dies half-way through. The transcript renders one
* block per TURN, with its tools and its approval cards inside it. Folding the
* rows back into turns is therefore not a formality; it is the difference
* between reopening a conversation and reopening a log file.
*
* The wire shapes below mirror `PiggyConversationDetail` in
* apps/api/src/services/piggy-conversations.ts. They are restated rather than
* 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.
*/
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';
export interface StoredPiggyMessage {
id: string;
seq: number;
role: 'user' | 'assistant' | 'tool';
content: string;
reasoning: string | null;
model: string | null;
mode: PiggyMode | null;
inputTokens: number | null;
outputTokens: number | null;
costMicroCents: number | null;
finishReason: string | null;
tool: {
callId: string;
name: string;
arguments: Record<string, unknown> | null;
result: Record<string, unknown> | null;
ok: boolean | null;
} | null;
approval: {
id: string;
change: PiggyProposedChange;
decision: 'apply' | 'reject' | null;
decidedAt: string | null;
} | null;
error: string | null;
createdAt: string;
}
export interface StoredPiggyConversation {
id: string;
title: string;
model: string | null;
mode: PiggyMode | null;
context: PiggyChatContext | null;
createdAt: string;
updatedAt: string;
messages: StoredPiggyMessage[];
}
/**
* A change that was proposed and never answered.
*
* It is not offered as pending on reopening, and that is deliberate rather than
* cautious: the agent holds a proposal for the length of its own turn, so by the
* time a transcript is read back from the database there is nothing left at the
* other end for an Apply button to reach. Showing the buttons would collect an
* error; showing the card settled says what happened.
*/
const UNANSWERED = 'This change was never answered, and the turn that proposed it has ended.';
export function toTranscript(messages: StoredPiggyMessage[]): TranscriptMessage[] {
const transcript: TranscriptMessage[] = [];
// The assistant turn currently being assembled. Tool rows and approval rows
// belong to whichever answer they were streamed alongside, and they arrive
// BEFORE its text — the answer is written last.
let open: TranscriptMessage | null = null;
for (const row of [...messages].sort((a, b) => a.seq - b.seq)) {
if (row.role === 'user') {
if (open) transcript.push(open);
open = null;
transcript.push({ id: row.id, role: 'user', content: row.content });
continue;
}
if (row.tool) {
const turn: TranscriptMessage = open ?? newTurn(row.id);
turn.tools = [...(turn.tools ?? []), toToolStep(row.tool)];
open = turn;
continue;
}
if (row.approval) {
const turn: TranscriptMessage = open ?? newTurn(row.id);
turn.approvals = [...(turn.approvals ?? []), toApprovalStep(row.approval)];
open = turn;
continue;
}
// A second answer inside one turn cannot happen on the wire, but a repaired
// or re-run conversation could hold one; starting a fresh block is the only
// reading that does not silently concatenate two answers into one.
if (open && open.content) {
transcript.push(open);
open = null;
}
const turn: TranscriptMessage = open ?? newTurn(row.id);
turn.id = row.id;
turn.content = row.content;
turn.reasoning = row.reasoning ?? undefined;
turn.model = row.model ?? undefined;
turn.mode = row.mode ?? undefined;
turn.inputTokens = row.inputTokens;
turn.outputTokens = row.outputTokens;
turn.costMicroCents = row.costMicroCents;
turn.finishReason = row.finishReason ?? undefined;
turn.error = row.error ?? undefined;
transcript.push(turn);
open = null;
}
if (open) transcript.push(open);
return transcript;
}
function newTurn(id: string): TranscriptMessage {
return { id, role: 'assistant', content: '', tools: [], approvals: [], pending: false };
}
function toToolStep(tool: NonNullable<StoredPiggyMessage['tool']>): ToolStep {
return {
id: tool.callId,
name: tool.name,
arguments: tool.arguments ?? {},
// `ok: null` is a call the store never saw finish. It is drawn as succeeded
// rather than running: a spinner in a transcript read back from disk would
// never stop, and the payload beside it is the evidence either way.
state: tool.ok === false ? 'failed' : 'succeeded',
result: tool.result ?? undefined,
/*
* No clock. `startedAt` is `performance.now()` on the live path, which is
* milliseconds since this document loaded and means nothing for a call made
* last Tuesday. Zero with no `durationMs` renders as a step with no timing,
* which is honest; a computed one would be fiction.
*/
startedAt: 0,
};
}
function toApprovalStep(approval: NonNullable<StoredPiggyMessage['approval']>): ApprovalStep {
if (approval.decision === 'apply') {
return { change: approval.change, state: 'applied', decision: 'apply' };
}
if (approval.decision === 'reject') {
return { change: approval.change, state: 'rejected', decision: 'reject' };
}
return { change: approval.change, state: 'failed', error: UNANSWERED };
}
@@ -0,0 +1,714 @@
/**
* The Piggy workspace: history, the conversation, and the evidence beside it.
*
* Three columns on a wide screen, and the interesting decisions are all about
* what happens when there are not three columns' worth of room. In order of
* what gives way first:
*
* 1536 history rail, conversation, activity rail. The activity rail is the
* last thing added because it is the least urgent of the three: it
* says what has already happened.
* 1280 history and conversation. Activity moves into a sheet, on a button
* in the header, because a third column here leaves the middle one at
* about 420px narrower than the phone layout, on the pane the whole
* screen exists to show.
* 1024 the history rail collapses to initials by default, which buys the
* 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 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, 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,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
PanelRightClose,
SquarePen,
} from 'lucide-react';
import type { PiggyChatContext } from '@pig/core';
import { get, post } from '@/lib/api';
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';
import { PiggyConversationList } from '@/components/piggy/conversation-list';
import { Button, EmptyState, Skeleton, cn } from '@/components/ui';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './controls';
import { PiggyWorkspaceRail } from './evidence';
import { PiggyWorkspaceStarters } from './starters';
import { toTranscript, type StoredPiggyConversation } from './stored-transcript';
/**
* The context every turn from this page carries.
*
* A constant, not an inline object: `usePiggyContext` re-publishes whenever the
* value changes identity, and the page tool this resolves to
* (`pig_get_workspace_summary`) is fixed for the whole workspace.
*/
const WORKSPACE_CONTEXT: PiggyChatContext = { type: 'page', route: '/piggy' };
/** Where the activity rail earns a column of its own rather than a sheet. */
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';
/** The conversation being read, as a URL — so a run in the ledger can link to it. */
const CONVERSATION_PARAM = 'conversation';
export function PiggyWorkspace() {
const status = usePiggyStatus();
usePiggyContext(WORKSPACE_CONTEXT);
const isMobile = useIsMobile();
const hasActivityColumn = useMediaQuery(`(min-width: ${ACTIVITY_COLUMN_BREAKPOINT}px)`);
const wideHistory = useMediaQuery(`(min-width: ${WIDE_HISTORY_BREAKPOINT}px)`);
const [historyExpanded, setHistoryExpanded] = useStoredFlag(HISTORY_STORAGE_KEY, wideHistory);
const [activityOpen, setActivityOpen] = useStoredFlag(ACTIVITY_STORAGE_KEY, hasActivityColumn);
const [historySheet, setHistorySheet] = useState(false);
const [params, setParams] = useSearchParams();
const activeId = params.get(CONVERSATION_PARAM);
/**
* Bumped by "New conversation" so the thread below is rebuilt even when the
* URL does not change pressing New twice must give you two fresh threads,
* not one thread and a control that appears to be broken.
*/
const [newThread, setNewThread] = useState(0);
const [pendingAsk, setPendingAsk] = useState<{ id: string; message: string } | null>(null);
const [running, setRunning] = useState(false);
const queryClient = useQueryClient();
const select = useCallback(
(id: string) => {
// Replaced rather than pushed: reading four threads should not put four
// entries in the history stack for Back to walk out through.
setParams({ [CONVERSATION_PARAM]: id }, { replace: true });
setHistorySheet(false);
},
[setParams],
);
const startNew = useCallback(() => {
setParams({}, { replace: true });
setNewThread((count) => count + 1);
setHistorySheet(false);
}, [setParams]);
/**
* A conversation is created by asking the first question, not by pressing New.
*
* The row's title is derived server-side from that first message, so creating
* eagerly would fill the sidebar with rows called "New conversation" every
* time somebody opened the page and thought better of it.
*/
const created = useCallback(
(conversation: StoredPiggyConversation, message: string) => {
// Seeded so the detail query below answers from cache: without it, the
// thread would remount into a loading skeleton for the length of a round
// trip, immediately after the user pressed send.
queryClient.setQueryData(conversationKey(conversation.id), conversation);
void queryClient.invalidateQueries({ queryKey: ['piggy', 'conversations'] });
setPendingAsk({ id: conversation.id, message });
setParams({ [CONVERSATION_PARAM]: conversation.id }, { replace: true });
},
[queryClient, setParams],
);
const detail = useQuery({
queryKey: conversationKey(activeId ?? ''),
queryFn: () => get<StoredPiggyConversation>(`/api/piggy/conversations/${activeId}`),
enabled: Boolean(activeId),
/*
* 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">
<Skeleton className="h-12 rounded-xl" />
<Skeleton className="min-h-0 flex-1 rounded-xl" />
</div>
);
}
if (!status.data?.canUse) {
return (
<div className="flex h-full min-h-0 items-center justify-center p-6">
<PiggyUnavailable status={status.data} />
</div>
);
}
const list = (
<PiggyConversationList
activeId={activeId}
onSelect={select}
onNew={startNew}
collapsed={!historyExpanded}
runningId={running ? activeId : null}
/>
);
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">
<PiggyWorkspaceThread
key={activeId ?? `new-${newThread}`}
conversationId={activeId}
title={detail.data?.title ?? null}
initialMessages={storedTranscript}
loading={Boolean(activeId) && detail.isLoading}
loadError={detail.isError ? detail.error : null}
autoSend={pendingAsk?.id === activeId ? pendingAsk.message : undefined}
onAutoSent={() => setPendingAsk(null)}
onCreated={created}
onRunningChange={setRunning}
onNew={startNew}
onOpenHistory={() => setHistorySheet(true)}
historyExpanded={historyExpanded}
onToggleHistory={() => setHistoryExpanded(!historyExpanded)}
showHistoryToggle={!isMobile}
activityOpen={hasActivityColumn && activityOpen}
onToggleActivity={() => setActivityOpen(!activityOpen)}
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-[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>Pick one up from where it stopped.</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1">{isMobile ? list : null}</div>
</SheetContent>
</Sheet>
</div>
);
}
// ------------------------------------------------------------------- thread
function PiggyWorkspaceThread({
conversationId,
title,
initialMessages,
loading,
loadError,
autoSend,
onAutoSent,
onCreated,
onRunningChange,
onNew,
onOpenHistory,
historyExpanded,
onToggleHistory,
showHistoryToggle,
activityOpen,
onToggleActivity,
activityInColumn,
}: {
conversationId: string | null;
title: string | null;
initialMessages?: TranscriptMessage[];
loading: boolean;
loadError: unknown;
autoSend?: string;
onAutoSent: () => void;
onCreated: (conversation: StoredPiggyConversation, message: string) => void;
onRunningChange: (running: boolean) => void;
onNew: () => void;
onOpenHistory: () => void;
historyExpanded: boolean;
onToggleHistory: () => void;
showHistoryToggle: boolean;
activityOpen: boolean;
onToggleActivity: () => void;
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,
initialConversationId: conversationId ?? undefined,
});
const [creating, setCreating] = useState(false);
/*
* The rail as an overlay, below the width where it earns a column. It lives
* here rather than beside the history sheet in the parent because its first
* tab describes THIS conversation, and the parent has no transcript to
* describe a sheet opened from up there would tell a phone user in the
* middle of a conversation that nothing had been asked yet.
*/
const [activitySheet, setActivitySheet] = useState(false);
/** A write opener waiting for the mode it needs. See `askWithChange`. */
const [escalating, setEscalating] = useState<string | null>(null);
const autoSent = useRef(false);
const { running, send: sendTurn, messages } = conversation;
useEffect(() => {
onRunningChange(running);
// The rail's running dot belongs to whichever thread is on screen, so the
// flag has to be lowered when this one is replaced as well as when its turn
// ends — otherwise switching conversations mid-answer leaves a dot spinning
// on a thread nothing is running in.
return () => onRunningChange(false);
}, [running, onRunningChange]);
/**
* Send, creating the stored conversation first when this is the first thing
* said in it.
*
* The order is forced by the server: the row's title comes from the opening
* message, and the id has to exist before the turn is streamed so that the
* relay continues the same conversation the sidebar is listing.
*/
const ask = useCallback(
(text?: string, from?: TranscriptMessage[]) => {
const message = (text ?? conversation.draft).trim();
if (!message || running || creating) return;
// A thread that already has messages but no stored id is one whose
// creation failed. Creating now would strand everything above on a page
// that is about to remount, so it stays unsaved for the rest of its life.
if (conversationId || messages.length) {
sendTurn(text, from);
return;
}
setCreating(true);
post<StoredPiggyConversation>('/api/piggy/conversations', { firstMessage: message })
.then((created) => onCreated(created, message))
.catch(() => {
// The question is worth more than the filing. Piggy answers, the
// relay mints its own conversation id, and only the history entry is
// lost — which is what the toast says rather than implying the turn
// failed.
toast.error('Piggy could not save this to your history. The answer below is not filed.');
sendTurn(text, from);
})
.finally(() => setCreating(false));
},
[conversation.draft, conversationId, creating, messages.length, onCreated, running, sendTurn],
);
/**
* The opening question of a conversation created a moment ago.
*
* Scheduled rather than sent inline, and cancelled by this effect's own
* cleanup. A send started from an effect body outlives the mount that started
* it: React's StrictMode mounts, runs effects, tears them down and mounts
* again, and `usePiggyConversation` aborts its stream on unmount so the
* first thing anyone saw after asking the very first question of a new
* conversation was their own question with "Stopped" under it, and a real
* turn spent to get there. Deferring by a tick means the throwaway pass
* cancels a timer instead of a request.
*/
useEffect(() => {
if (!autoSend || autoSent.current) return;
const timer = setTimeout(() => {
autoSent.current = true;
sendTurn(autoSend);
onAutoSent();
}, 0);
return () => clearTimeout(timer);
}, [autoSend, onAutoSent, sendTurn]);
/**
* 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 askWithChange = useCallback(
(text: string) => {
if (!controls.canWrite) return;
if (conversation.mode === 'read_only') {
controls.setMode('confirm');
setEscalating(text);
return;
}
ask(text);
},
[ask, controls, conversation.mode],
);
useEffect(() => {
if (escalating === null) return;
if (conversation.mode === 'read_only') return;
setEscalating(null);
ask(escalating);
}, [ask, escalating, conversation.mode]);
const busy = running || creating;
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">
{/* `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
label={historyExpanded ? 'Collapse the conversation list' : 'Expand the conversation list'}
onClick={onToggleHistory}
>
{historyExpanded ? <PanelLeftClose aria-hidden /> : <PanelLeftOpen aria-hidden />}
</IconButton>
) : (
<IconButton label="Your Piggy conversations" onClick={onOpenHistory}>
<History aria-hidden />
</IconButton>
)}
<div className="min-w-0 flex-1">
{/* An unsaved thread has no name yet, and calling it "New
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-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>
{controlsOnSecondRow ? null : controlsRow}
{showHistoryToggle ? null : (
<IconButton label="Start a new conversation" onClick={onNew}>
<SquarePen aria-hidden />
</IconButton>
)}
<IconButton
label={
activityInColumn
? activityOpen
? 'Hide the activity panel'
: 'Show the activity panel'
: 'Show activity'
}
pressed={activityInColumn ? activityOpen : undefined}
onClick={() => (activityInColumn ? onToggleActivity() : setActivitySheet(true))}
>
{activityOpen ? <PanelRightClose aria-hidden /> : <PanelRight aria-hidden />}
</IconButton>
</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. 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">
{/* 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 ? (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<EmptyState
icon={<AlertTriangle />}
title="That conversation could not be opened"
description={
loadError instanceof Error
? loadError.message
: 'It may have been deleted, or it belongs to someone else.'
}
action={
<Button type="button" variant="outline" onClick={onNew}>
Start a new conversation
</Button>
}
/>
</div>
) : (
<PiggyChatPanel
conversation={askingConversation(conversation, ask)}
/* No `context`: the panel would draw a badge saying the
conversation is working from the page you are looking at, which
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={
<PiggyWorkspaceStarters
context={WORKSPACE_CONTEXT}
mode={controls.mode}
canWrite={controls.canWrite}
onAsk={ask}
onAskWithChange={askWithChange}
/* The two columns of openers fit whenever the pane does:
even with both rails out at 1280 the middle keeps ~700px,
which is two 340px cards. Only the phone stacks them. */
narrow={isMobile}
/>
}
/>
)}
</div>
{activityOpen ? (
<aside
className="hidden w-[20rem] shrink-0 overflow-hidden border-l border-border bg-surface 2xl:flex"
aria-label="Piggy activity"
>
<PiggyWorkspaceRail messages={messages} className="min-h-0 w-full flex-1" />
</aside>
) : null}
</div>
<Sheet open={activitySheet} onOpenChange={setActivitySheet}>
<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>
{/* Mounted only while open: the ledger polls, and a hidden copy would
poll alongside the one in the column. */}
{activitySheet ? (
<PiggyWorkspaceRail messages={messages} className="min-h-0 flex-1" />
) : null}
</SheetContent>
</Sheet>
</div>
);
}
/**
* The conversation as the panel should see it: identical, except that sending
* goes through the workspace's own `ask`, which may have a conversation to
* create first. The panel is deliberately unaware of that it has three other
* callers with nothing to file.
*/
function askingConversation(
conversation: PiggyConversation,
ask: (text?: string, from?: TranscriptMessage[]) => void,
): PiggyConversation {
return { ...conversation, send: ask };
}
function ThreadSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>
<Skeleton className="h-16 w-2/3 rounded-xl" />
<Skeleton className="ml-auto h-12 w-1/2 rounded-xl" />
<Skeleton className="h-24 w-3/4 rounded-xl" />
</div>
);
}
function IconButton({
label,
onClick,
pressed,
children,
}: {
label: string;
onClick: () => void;
pressed?: boolean;
children: React.ReactNode;
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0 text-muted', pressed && 'text-fg')}
aria-label={label}
aria-pressed={pressed}
title={label}
onClick={onClick}
>
{children}
</Button>
);
}
// -------------------------------------------------------------------- state
function conversationKey(id: string) {
return ['piggy', 'conversation', id] as const;
}
/**
* A panel's open/closed state, remembered.
*
* Not keyed by user, unlike the mode: which rails somebody likes open is a
* preference about a window, not a permission, and the worst a shared laptop
* can do with it is show the second person a column they can close.
*/
function useStoredFlag(key: string, fallback: boolean): [boolean, (value: boolean) => void] {
const [value, setValue] = useState<boolean>(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw === 'true';
} catch {
// Private browsing throws on access; the layout default is fine.
return fallback;
}
});
const update = useCallback(
(next: boolean) => {
setValue(next);
try {
localStorage.setItem(key, String(next));
} catch {
// Nothing to do: the panel still opens, it just forgets by tomorrow.
}
},
[key],
);
return [value, update];
}
+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 -20
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,18 +384,52 @@ export function Stat({
? 'text-danger'
: 'text-fg';
return (
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
// is always a grid child whose figure is `tabular-nums` and whose hint does
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
// shrink and the page scrolls sideways (AGENTS.md §5).
<div className="card min-w-0 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>
);
}
@@ -231,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>
);
}