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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 18:22:15 -07:00
parent f0173440e4
commit 18d5f5bfc0
89 changed files with 8523 additions and 2447 deletions
+58 -36
View File
@@ -16,7 +16,8 @@ import { SignIn } from '@/pages/SignIn';
import { CreateProfile } from '@/pages/CreateProfile';
import { Register } from '@/pages/Register';
import { PiggyMark } from '@/components/PiggyMark';
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
import { Badge, Button, Card, EmptyState, Section, Skeleton, Stat } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Toaster } from '@/components/ui/sonner';
import { usePageTitle } from '@/lib/title';
@@ -323,10 +324,16 @@ function RoutePage({ children }: { children: React.ReactNode }) {
* inset's own tab-bar clearance does not apply to it, and without this the
* composer would sit underneath the phone tab bar — the exact control a phone
* user came here to reach. `lg` matches where the tab bar gives way.
*
* Under 500px tall the reserve is given back. A phone in landscape, or a phone
* with the keyboard up, is spending 72px of a 390px viewport on a bar it can
* reach again by turning the handset back — while the transcript, which is why
* the page exists, is measured at 40px. The tab bar itself stands down at the
* same height (Shell.tsx), so nothing lands underneath it.
*/
function WorkspaceRoute({ children }: { children: React.ReactNode }) {
return (
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0">
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0">
{/* `flex-1` on the fallback, or the spinner for a pane this tall sits up
against the header while the rest of it stays empty. */}
<Suspense fallback={<div className="flex flex-1 items-center justify-center"><RouteLoading /></div>}>
@@ -379,7 +386,7 @@ function Placeholder({ title }: { title: string }) {
function Team() {
usePageTitle('Team');
const { data, isLoading, error } = useQuery({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['team'],
queryFn: () =>
get<
@@ -397,39 +404,55 @@ function Team() {
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
return (
/*
* The one page that never got a design pass, because it never had an owner:
* it lives inline in App.tsx rather than in `pages/`, so the wave that swept
* all thirteen routes swept past it. Measured against the rest of the
* product it carried a 30px `<h1>` where every other route is 24px, an
* accent-coloured "ACCESS MAP" eyebrow of exactly the kind the direction
* deleted from Growth (identity colour used as decoration, on a page with
* no agent in it), hand-rolled 10px/24px stat tiles instead of `Stat`, and
* a 14px section heading floating on the canvas. It is now the same three
* primitives every other page is built from and nothing else changed.
*/
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
See who can operate each side of the compute business and where ownership is thin.
</p>
</div>
<Link
to="/settings"
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
>
Manage access in Settings
</Link>
</header>
<PageHeader
title="Team"
description="See who can operate each side of the compute business and where ownership is thin."
actions={
<Link
to="/settings"
className="tap inline-flex items-center rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
>
Manage access in Settings
</Link>
}
/>
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
{[
['People', data?.length ?? 0],
['Teams', representedTeams],
['Assignments', assignments],
].map(([label, value]) => (
<Card key={label} className="p-3 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
</Card>
))}
{/* `grid-cols-2 gap-3 xl:grid-cols-*`, the same KPI row Overview and
Margin use. This carried `grid-cols-3 sm:max-w-xl`, which made Team
the one page whose headline figures were a different size and whose
row stopped halfway across the page. */}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
<Stat label="People" value={data?.length ?? 0} />
<Stat label="Teams" value={representedTeams} />
<Stat label="Assignments" value={assignments} />
</div>
{error ? (
<Card>
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
{/* Three routes rendered an honest error and then offered nothing to
do about it. A transient 500 on a page with no Try again is a page
a person has to know to reload. */}
<EmptyState
title="Team unavailable"
description={error instanceof Error ? error.message : 'Could not load team access.'}
action={
<Button type="button" variant="outline" onClick={() => void refetch()}>
Try again
</Button>
}
/>
</Card>
) : null}
@@ -446,11 +469,10 @@ function Team() {
) : null}
{!isLoading && !error && data?.length ? (
<section aria-labelledby="team-members-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
<span className="text-xs text-muted">Roles are enforced server-side</span>
</div>
<Section
title="People and permissions"
description="Roles are enforced server-side."
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{data.map((person) => {
const initials = person.name
@@ -486,7 +508,7 @@ function Team() {
);
})}
</div>
</section>
</Section>
) : null}
</div>
);
+55 -17
View File
@@ -1,11 +1,16 @@
/**
* The account tile at the top of the sidebar.
*
* It carries the Piggy mark in the user's own accent, because that accent is
* the one piece of the interface they chose and the workspace identity is
* where they will look for it. The swatch row in the menu is the same
* `setAccent` the Settings page calls — not a copy of the palette, and not a
* second place a colour could be defined.
* It used to carry the Piggy mark, which made the pig face mean two things at
* once: the agent, and your organisation. Piggy is now one mark with one
* meaning everywhere in the product, so this tile carries a monogram instead —
* the workspace's initials on the trigger, the signed-in person's on the menu
* label above their own email. Both sit in the user's chosen accent, because
* that accent is the one piece of the interface they picked and identity is
* where they will look for it.
*
* The swatch row in the menu is the same `setAccent` the Settings page calls —
* not a copy of the palette, and not a second place a colour could be defined.
*
* PIG is single-workspace today, so this is a switcher with one entry. It is
* still a menu rather than a label: it is where identity, appearance and
@@ -18,7 +23,6 @@ import type { ThemeMode } from '@pig/core';
import { getSupabase } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { useTheme } from '@/lib/theme';
import { PiggyMark } from './PiggyMark';
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from './ui/sidebar';
import {
DropdownMenu,
@@ -28,10 +32,48 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { cn } from './ui';
import { Label, cn } from './ui';
const WORKSPACE_NAME = 'Prime Intellect Growth';
/**
* Two letters at most.
*
* Three initials in a 32px square is a monogram nobody can read, and a name
* with one word still has to fill the chip rather than sit in the corner of
* it. Falls back to the first character of whatever it was given, because an
* empty chip beside a name reads as a failed avatar load.
*/
function monogram(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase();
return `${words[0]![0]!}${words[1]![0]!}`.toUpperCase();
}
/**
* The chip both monograms sit in, so they cannot drift apart.
*
* Rounded square for an organisation, circle for a person — the convention
* every product this one sits beside already uses, and the fastest way to say
* which of the two rows in this menu is your workspace and which is you. Both
* are decorative: the name they stand for is always printed next to them.
*/
function Monogram({ text, shape }: { text: string; shape: 'workspace' | 'person' }) {
return (
<span
aria-hidden
className={cn(
'flex size-8 shrink-0 items-center justify-center bg-accent-subtle',
'text-xs font-semibold tracking-[0.04em] text-accent-fg',
shape === 'workspace' ? 'rounded-lg' : 'rounded-full',
)}
>
{text}
</span>
);
}
const MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
@@ -63,9 +105,7 @@ export function AccountSwitcher() {
className="data-[state=open]:bg-sidebar-accent"
aria-label={`${WORKSPACE_NAME} — account and appearance`}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<Monogram text={monogram(WORKSPACE_NAME)} shape="workspace" />
<span className="flex min-w-0 flex-1 flex-col text-left leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold text-fg">{WORKSPACE_NAME}</span>
<span className="truncate text-xs font-normal text-muted">{identity.name}</span>
@@ -81,9 +121,7 @@ export function AccountSwitcher() {
sideOffset={8}
>
<DropdownMenuLabel className="flex min-w-0 items-center gap-2 py-2">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
<PiggyMark className="size-5" />
</span>
<Monogram text={monogram(identity.name)} shape="person" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold">{identity.name}</span>
<span className="truncate text-xs font-normal text-muted">{identity.email}</span>
@@ -92,8 +130,8 @@ export function AccountSwitcher() {
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Accent
<DropdownMenuLabel className="pb-1 pt-2">
<Label>Accent</Label>
</DropdownMenuLabel>
<div className="flex flex-wrap gap-1.5 px-2 pb-2">
{accents.map((option) => (
@@ -124,8 +162,8 @@ export function AccountSwitcher() {
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-[0.16em] text-muted">
Appearance
<DropdownMenuLabel className="pb-1 pt-2">
<Label>Appearance</Label>
</DropdownMenuLabel>
{MODES.map((option) => (
<DropdownMenuItem
+50 -45
View File
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
Bot,
Check,
CircleCheck,
CircleDashed,
@@ -26,13 +25,17 @@ import {
cn,
EmptyState,
Input,
Label as MicroLabel,
Section,
Skeleton,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import { FormField } from '@/components/ui/form-field';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { IntegrationSettings } from './IntegrationSettings';
import { PiggyMark } from './PiggyMark';
/**
* What the server can honestly say about Piggy, all of it derived from the
@@ -96,37 +99,34 @@ export function AdminSettings() {
return (
<section className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
<div className="relative flex items-start gap-3">
<div className="border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-accent-on">
<ShieldCheck aria-hidden />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">Platform control plane</h2>
<Badge tone="warning">Admin only</Badge>
</div>
<p className="mt-1 max-w-2xl text-sm text-muted">
Configure intelligence, inventory sync, workspace entry, and team authority.
</p>
</div>
<Section
level={2}
className="min-w-0"
title="Platform control plane"
description="Configure intelligence, inventory sync, workspace entry, and team authority."
action={<Badge tone="warning">Admin only</Badge>}
/>
</div>
</div>
<Tabs defaultValue="runtime" className="p-4 sm:p-6">
<TabsList className="scroll-x flex h-auto w-full justify-start bg-surface-2 p-1 sm:w-auto sm:inline-flex">
<TabsTrigger value="runtime" className="tap flex-1 sm:flex-none">Runtime</TabsTrigger>
<TabsTrigger value="invites" className="tap flex-1 sm:flex-none">Invites</TabsTrigger>
<TabsTrigger value="access" className="tap flex-1 sm:flex-none">Access</TabsTrigger>
<TabsTrigger value="integrations" className="tap flex-1 sm:flex-none">Integrations</TabsTrigger>
<TabsList className="scroll-x flex w-full justify-start sm:inline-flex sm:w-auto">
<TabsTrigger value="runtime" className="flex-1 sm:flex-none">Runtime</TabsTrigger>
<TabsTrigger value="invites" className="flex-1 sm:flex-none">Invites</TabsTrigger>
<TabsTrigger value="access" className="flex-1 sm:flex-none">Access</TabsTrigger>
<TabsTrigger value="integrations" className="flex-1 sm:flex-none">Integrations</TabsTrigger>
</TabsList>
<TabsContent value="runtime" className="mt-5">
<TabsContent value="runtime">
{isLoading || !data ? <p className="text-sm text-muted">Loading runtime settings</p> : <RuntimeForm key={data.updatedAt} settings={data} />}
</TabsContent>
<TabsContent value="invites" className="mt-5"><InviteManager /></TabsContent>
<TabsContent value="access" className="mt-5"><MemberManager /></TabsContent>
<TabsContent value="integrations" className="mt-5"><IntegrationSettings /></TabsContent>
<TabsContent value="invites"><InviteManager /></TabsContent>
<TabsContent value="access"><MemberManager /></TabsContent>
<TabsContent value="integrations"><IntegrationSettings /></TabsContent>
</Tabs>
</section>
);
@@ -165,24 +165,22 @@ function RuntimeForm({ settings }: { settings: AdminRuntimeSettings }) {
<Card>
<CardHeader>
<div className="flex items-center gap-2"><RefreshCw className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
<div className="flex items-center gap-2"><RefreshCw className="text-muted" aria-hidden /><CardTitle className="text-base">Prime inventory</CardTitle></div>
<p className="break-all text-xs text-muted">Compute endpoint: {settings.primeComputeBase}</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={settings.primeApiKey.configured ? 'positive' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
<Badge tone={settings.primeApiKey.configured ? 'neutral' : 'warning'}>{settings.primeApiKey.configured ? 'Credential configured' : 'Credential missing'}</Badge>
{settings.primeApiKey.source ? <Badge>{settings.primeApiKey.source} source</Badge> : null}
{settings.primeApiKey.updatedAt ? <span className="text-xs text-muted">updated {relativeTime(settings.primeApiKey.updatedAt)}</span> : null}
</div>
<label className="flex flex-col gap-1.5" htmlFor="prime-api-key">
<span className="text-sm font-medium">Replace Prime API key</span>
<FormField label="Replace Prime API key" hint={settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}>
<Input id="prime-api-key" type="password" autoComplete="new-password" value={primeApiKey} onChange={(event) => { setPrimeApiKey(event.target.value); setClearKey(false); }} placeholder="Enter a new key; existing material is never shown" disabled={!settings.primeApiKey.encryptionReady} />
<span className="text-xs text-muted">{settings.primeApiKey.encryptionReady ? 'Encrypted with AES-256-GCM before it reaches the database.' : 'Set PIG_SETTINGS_ENCRYPTION_KEY on the server to enable credential writes.'}</span>
</label>
</FormField>
{settings.primeApiKey.source === 'database' ? <Button type="button" variant={clearKey ? 'danger' : 'outline'} size="sm" onClick={() => { setClearKey((value) => !value); setPrimeApiKey(''); }}>{clearKey ? 'Credential will be cleared' : 'Clear stored credential'}</Button> : null}
<div className="grid gap-3 sm:grid-cols-[1fr_9rem] sm:items-end">
<ToggleRow id="prime-sync" label="Inventory sync" description="Continuously refresh Prime availability and pricing." checked={syncEnabled} onCheckedChange={setSyncEnabled} />
<label className="flex flex-col gap-1.5" htmlFor="sync-interval"><span className="text-sm font-medium">Every (minutes)</span><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></label>
<FormField label="Every (minutes)"><Input id="sync-interval" type="number" min="1" max="1440" value={interval} onChange={(event) => setIntervalValue(event.target.value)} /></FormField>
</div>
</CardContent>
</Card>
@@ -234,7 +232,7 @@ function PiggyCard({
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Bot className="text-accent-fg" aria-hidden />
<PiggyMark className="size-5 text-muted" />
<CardTitle className="text-base">Piggy intelligence</CardTitle>
</div>
<Button type="button" size="sm" variant="outline" onClick={recheck} disabled={rechecking}>
@@ -335,15 +333,21 @@ function PiggyCard({
);
}
/*
* `positive` is deliberately the neutral treatment. A control plane that paints
* a green panel every time everything is fine teaches an operator to stop
* reading the panel, and the one state that matters here is the one that needs
* them. Colour marks a problem; working is quiet.
*/
const VERDICT_SURFACE = {
positive: 'border-positive bg-positive/10',
positive: 'border-border bg-surface-2',
warning: 'border-warning bg-warning/10',
danger: 'border-danger bg-danger/10',
neutral: 'border-border bg-surface-2',
} as const;
const VERDICT_TEXT = {
positive: 'text-positive',
positive: 'text-fg',
warning: 'text-warning',
danger: 'text-danger',
neutral: 'text-fg',
@@ -420,7 +424,7 @@ function PiggyFact({ state, label, detail }: { state: 'ok' | 'bad' | 'unknown';
<Icon
className={cn(
'mt-0.5 size-4 shrink-0',
state === 'ok' ? 'text-positive' : state === 'bad' ? 'text-danger' : 'text-muted',
state === 'bad' ? 'text-danger' : 'text-muted',
)}
aria-hidden
/>
@@ -474,17 +478,17 @@ function InviteManager() {
const revoke = useMutation({ mutationFn: (id: string) => api(`/api/admin/invites/${id}`, { method: 'DELETE' }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-invites'] }) });
return <div className="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]">
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-accent-fg" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
<label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Email, optional</span><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></label>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Team</span><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Role</span><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></label></div>
<div className="grid grid-cols-2 gap-3"><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Uses</span><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></label><label className="flex flex-col gap-1.5"><span className="text-sm font-medium">Expires, optional</span><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></label></div>
<Card><CardHeader><div className="flex items-center gap-2"><UserPlus className="text-muted" aria-hidden /><CardTitle className="text-base">Issue an invite</CardTitle></div><p className="text-sm text-muted">Codes gate PIG registration. They never open registration on the shared identity provider.</p></CardHeader><CardContent><form className="flex flex-col gap-4" onSubmit={(event) => { event.preventDefault(); setIssuedCode(null); create.mutate(); }}>
<FormField label="Email, optional"><Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Pin to a known address" /></FormField>
<div className="grid grid-cols-2 gap-3"><FormField label="Team"><Select value={team} onValueChange={(value) => setTeam(value as Team | 'any')}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="any">Choose at signup</SelectItem>{TEAMS.map((value) => <SelectItem key={value} value={value}>{TEAM_LABELS[value]}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField><FormField label="Role"><Select value={role} onValueChange={(value) => setRole(value as TeamRole)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{TEAM_ROLES.map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField></div>
<div className="grid grid-cols-2 gap-3"><FormField label="Uses"><Input type="number" min="1" max="100" value={uses} onChange={(event) => setUses(event.target.value)} /></FormField><FormField label="Expires, optional"><Input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></FormField></div>
{create.error ? <p role="alert" className="text-sm text-danger">{create.error.message}</p> : null}<Button type="submit" variant="primary" disabled={create.isPending}>{create.isPending ? 'Issuing…' : 'Issue invite'}</Button>
{issuedCode ? <div className="rounded-xl border border-warning bg-warning/10 p-3"><p className="text-xs font-medium text-warning">Shown once. Send it through a secure channel.</p><div className="mt-2 flex min-w-0 items-center gap-2"><code className="min-w-0 flex-1 break-all text-xs">{issuedCode}</code><Button type="button" size="icon" variant="ghost" aria-label="Copy invite code" onClick={() => void navigator.clipboard.writeText(issuedCode)}><Copy aria-hidden /></Button></div></div> : null}
</form></CardContent></Card>
<Card><CardHeader><CardTitle className="text-base">Invite ledger</CardTitle><p className="text-sm text-muted">Only metadata remains visible after issuance.</p></CardHeader>{/* A failed ledger read must not render as "no invites issued": an admin who
believes the workspace is empty issues a second code to someone who
already has one. */}
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <p className="py-8 text-center text-sm text-muted">No invites issued yet.</p> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
<CardContent className="flex flex-col gap-2">{ledger.isPending ? <div className="flex flex-col gap-2" aria-busy><span className="sr-only">Loading invites</span>{[0, 1].map((row) => <Skeleton key={row} className="h-16 rounded-xl" />)}</div> : ledger.isError ? <EmptyState icon={<AlertTriangle aria-hidden />} title="Invite ledger unavailable" description={ledger.error.message} action={<Button type="button" variant="outline" onClick={() => void ledger.refetch()}><RefreshCw aria-hidden />Try again</Button>} /> : ledger.data.length === 0 ? <EmptyState size="inline" title="No invites issued yet" /> : ledger.data.map((invite) => <div key={invite.id} className="flex min-w-0 flex-col gap-3 rounded-xl border border-border p-3 sm:flex-row sm:items-center"><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="truncate text-sm font-medium">{invite.email ?? 'Workspace invite'}</p><Badge tone={invite.status === 'active' ? 'positive' : invite.status === 'expired' ? 'warning' : 'neutral'}>{invite.status}</Badge></div><p className="mt-1 text-xs text-muted">{invite.team ? TEAM_LABELS[invite.team] : 'Team chosen at signup'} · {invite.role} · {invite.usesRemaining} use{invite.usesRemaining === 1 ? '' : 's'} left</p></div>{invite.status === 'active' ? <Button type="button" size="sm" variant="outline" disabled={revoke.isPending} onClick={() => revoke.mutate(invite.id)}>Revoke</Button> : null}</div>)}</CardContent></Card>
</div>;
}
@@ -500,12 +504,13 @@ function MemberManager() {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Users className="text-accent-fg" aria-hidden />
<div>
<h3 className="font-semibold">Team and role administration</h3>
<p className="text-sm text-muted">Roles are team-scoped. Platform administration is a separate grant.</p>
</div>
<div className="flex items-start gap-2">
<Users className="mt-0.5 shrink-0 text-muted" aria-hidden />
<Section
className="min-w-0"
title="Team and role administration"
description="Roles are team-scoped. Platform administration is a separate grant."
/>
</div>
{query.isPending ? (
<div className="flex flex-col gap-3" aria-busy>
@@ -555,5 +560,5 @@ function MemberAccess({ member }: { member: Member }) {
const [isPlatformAdmin, setIsPlatformAdmin] = useState(member.isPlatformAdmin);
const [roles, setRoles] = useState<Partial<Record<Team, TeamRole>>>(() => Object.fromEntries(member.memberships.map(({ team, role }) => [team, role])));
const save = useMutation({ mutationFn: () => patch(`/api/admin/members/${member.id}/access`, { isPlatformAdmin, memberships: TEAMS.flatMap((team) => roles[team] ? [{ team, role: roles[team] }] : []) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin-members'] }) });
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1"><span className="text-xs font-medium text-muted">{TEAM_LABELS[team]}</span><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
return <Card><CardContent className="p-4 sm:p-5"><div className="flex flex-col gap-4 xl:flex-row xl:items-center"><div className="min-w-0 xl:w-64"><div className="flex flex-wrap items-center gap-2"><p className="truncate font-medium">{member.name}</p>{member.isPlatformAdmin ? <Badge tone="warning"><KeyRound aria-hidden />Platform admin</Badge> : null}</div><p className="truncate text-sm text-muted">{member.email}</p></div><div className="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">{TEAMS.map((team) => <label key={team} className="flex flex-col gap-1.5"><MicroLabel as="span">{TEAM_LABELS[team]}</MicroLabel><Select value={roles[team] ?? 'none'} onValueChange={(value) => setRoles((current) => ({ ...current, [team]: value === 'none' ? undefined : value as TeamRole }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">No access</SelectItem>{TEAM_ROLES.map((role) => <SelectItem key={role} value={role}>{role}</SelectItem>)}</SelectGroup></SelectContent></Select></label>)}</div><div className="flex items-center justify-between gap-3 xl:w-52"><div><Label htmlFor={`admin-${member.id}`}>Platform admin</Label>{member.adminSource === 'environment' ? <p className="text-xs text-muted">Pinned by environment</p> : null}</div><Switch id={`admin-${member.id}`} checked={isPlatformAdmin} disabled={member.adminSource === 'environment'} onCheckedChange={setIsPlatformAdmin} /></div><Button type="button" size="sm" variant="primary" disabled={save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save access'}</Button></div>{save.error ? <p role="alert" className="mt-3 text-sm text-danger">{save.error.message}</p> : null}</CardContent></Card>;
}
+88 -57
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -9,15 +9,15 @@ import {
import { AlertTriangle, Clock3, LoaderCircle, RotateCcw, ShieldCheck } from 'lucide-react';
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
import { z } from 'zod';
import { Badge, Button, Input } from '@/components/ui';
import { Badge, Button, EmptyState, Input, Label, Section, Stat } from '@/components/ui';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
} from '@/components/ui/form';
import {
Select,
@@ -36,6 +36,7 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { UtilisationBar } from '@/components/ui/utilisation-bar';
import { ApiError, compactNumber, dateRange, get, percent, post, shortDate, unitPrice } from '@/lib/api';
import { toast } from 'sonner';
@@ -192,6 +193,7 @@ export function AllocationSheet({
onChanged?(): void;
}) {
const queryClient = useQueryClient();
const releaseReasonId = useId();
const [releaseReason, setReleaseReason] = useState('');
const [releaseError, setReleaseError] = useState<string | null>(null);
const form = useForm<AllocationForm>({
@@ -345,20 +347,19 @@ export function AllocationSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 text-left sm:px-6">
<SheetHeader band className="sm:px-6">
<SheetTitle>Reserve capacity</SheetTitle>
<SheetDescription>
Join committed supply to a demand deal. Availability is re-checked by the server when you save.
</SheetDescription>
</SheetHeader>
<Separator />
<Form {...form}>
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={form.handleSubmit((values) => save.mutate(values))}
>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-5 py-5 sm:gap-6 sm:px-6">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6">
<div className="grid grid-cols-2 rounded-lg bg-surface-2 p-1" role="group" aria-label="Reservation type">
{(['allocation', 'hold'] as const).map((value) => (
<button
@@ -379,16 +380,16 @@ export function AllocationSheet({
{availabilityError || demandError ? <ServerError message={errorMessage(availabilityError ?? demandError)} /> : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name="capacityCommitmentId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Capacity commitment</FormLabel>
<FieldLabel>Capacity commitment</FieldLabel>
<Select value={field.value} onValueChange={chooseCommitment}>
<FormControl>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue placeholder={availabilityLoading ? 'Loading capacity…' : 'Select capacity'} />
</SelectTrigger>
</FormControl>
@@ -412,10 +413,10 @@ export function AllocationSheet({
name="demandDealId"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Demand deal</FormLabel>
<FieldLabel>Demand deal</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="h-11"><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={demandLoading ? 'Loading customer deals…' : 'Select the customer deal'} /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
@@ -438,7 +439,7 @@ export function AllocationSheet({
{selected ? (
<CommitmentContext row={selected} detail={detail} match={match} quotedPrice={quotedPrice} />
) : options.length === 0 && !availabilityLoading ? (
<div role="status" className="rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
<div role="status" className="rounded-md bg-surface-2 p-4 text-sm text-muted">
{/* Two different dead ends. Told to re-run a matcher they
never ran, someone with an empty book has nowhere to go —
the answer there is to record what capacity was bought. */}
@@ -448,14 +449,11 @@ export function AllocationSheet({
</div>
) : null}
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold">Commercial reservation</h3>
<p className="mt-1 text-xs leading-relaxed text-muted">
GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Section
title="Commercial reservation"
description="GPU-hours and the window are submitted to the ledger as entered. The server checks the term, shaped capacity, holds, and concurrent writes."
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField control={form.control} name="gpuHours" label="GPU-hours" inputMode="decimal" placeholder="2048" />
<TextField control={form.control} name="price" label={kind === 'hold' ? 'Expected $/GPU-hr' : 'Sell $/GPU-hr'} inputMode="decimal" placeholder={kind === 'hold' ? 'Optional' : '2.75'} />
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
@@ -471,29 +469,33 @@ export function AllocationSheet({
name="notes"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Reservation notes</FormLabel>
<FieldLabel>Reservation notes</FieldLabel>
<FormControl><Textarea {...field} className="min-h-24 resize-y" placeholder="Commercial assumptions, caveats, or approval context." /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
</Section>
{selected ? (
<section className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold">Reservations on this commitment</h3>
<p className="mt-1 text-xs text-muted">Live holds reserve capacity but remain separate from sold allocations.</p>
</div>
<Section
title="Reservations on this commitment"
description="Live holds reserve capacity but remain separate from sold allocations."
>
{reserving.length === 0 ? (
<p className="rounded-lg bg-surface-2 p-4 text-sm text-muted">No live reserving allocations.</p>
<EmptyState
size="inline"
className="rounded-md bg-surface-2"
title="No live reserving allocations"
description="Nothing is held against this block, so every unsold hour is still sellable."
/>
) : (
<div className="flex flex-col gap-2">
{reserving.map((allocation) => {
const deal = allocation.demandDealId ? dealsById.get(allocation.demandDealId) : undefined;
return (
<div key={allocation.id} className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
<div key={allocation.id} className="flex flex-col gap-3 rounded-md bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{deal?.deal.name ?? 'Internal allocation'}</p>
@@ -511,14 +513,23 @@ export function AllocationSheet({
</div>
);
})}
<label className="flex flex-col gap-1 text-xs font-medium text-muted">
Release reason <span className="font-normal">Optional; recorded in the audit trail</span>
<Input value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder="Deal changed, hold lapsed…" />
</label>
<div className="flex flex-col gap-1.5">
<Label as="label" htmlFor={releaseReasonId}>Release reason</Label>
<Input
id={releaseReasonId}
aria-describedby={`${releaseReasonId}-hint`}
value={releaseReason}
onChange={(event) => setReleaseReason(event.target.value)}
placeholder="Deal changed, hold lapsed…"
/>
<p id={`${releaseReasonId}-hint`} className="text-xs text-muted">
Optional; recorded in the audit trail.
</p>
</div>
</div>
)}
{releaseError ? <ServerError message={releaseError} /> : null}
</section>
</Section>
) : null}
{save.isError ? <ServerError message={errorMessage(save.error)} /> : null}
@@ -539,16 +550,33 @@ export function AllocationSheet({
);
}
/**
* A field label on PIG's micro-label spec, wired to react-hook-form's id.
*
* `FormLabel` wears shadcn's 14px sentence-case label. The moment anything else
* on this sheet adopted `Label`, the same sheet carried two label styles — the
* commitment panel's figures small-caps, the form fields above them not — which
* is exactly the drift the primitive exists to stop. `useFormField` supplies the
* generated id and the error state, so nothing about the wiring is re-invented,
* only the type.
*/
function FieldLabel({ children }: { children: ReactNode }) {
const { formItemId, error } = useFormField();
return (
<Label as="label" htmlFor={formItemId} className={error ? 'text-danger' : undefined}>
{children}
</Label>
);
}
function CommitmentContext({ row, detail, match, quotedPrice }: { row: AvailabilityRow; detail?: CommitmentRow; match?: MatchRow; quotedPrice: number | null }) {
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
const breakEvenDollars = row.breakEvenPriceCents == null ? null : row.breakEvenPriceCents / 100;
const delta = quotedPrice != null && Number.isFinite(quotedPrice) && quotedPrice >= 0 && breakEvenDollars != null
? quotedPrice - breakEvenDollars
: null;
const shape = detail?.commitment.shape;
return (
<section className="rounded-xl border border-border bg-surface-2 p-4">
<section className="min-w-0 rounded-xl border border-border bg-surface-2 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words font-semibold leading-snug">{row.name}</p>
@@ -556,40 +584,43 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil
</div>
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours available`}>
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
<div><p className="text-muted">Held</p><p className="nums mt-0.5 font-medium">{compactNumber(row.heldGpuHours)} hrs</p></div>
<div><p className="text-muted">Available</p><p className="nums mt-0.5 font-medium">{compactNumber(row.availableGpuHours)} hrs</p></div>
<UtilisationBar
className="mt-4"
sold={row.soldGpuHours}
held={row.heldGpuHours}
total={row.totalGpuHours}
label={row.name}
/>
<div className="mt-2 grid grid-cols-3 gap-2">
<Stat size="sm" surface="bare" label="Sold" value={`${compactNumber(row.soldGpuHours)} hrs`} />
<Stat size="sm" surface="bare" label="Held" value={`${compactNumber(row.heldGpuHours)} hrs`} />
<Stat size="sm" surface="bare" label="Available" value={`${compactNumber(row.availableGpuHours)} hrs`} />
</div>
<Separator className="my-4" />
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-muted">Contract window</dt><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
<dt className="text-muted">Capacity shape</dt><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Remaining-block break even</dt><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><dt className="text-muted">Recorded oversubscription</dt><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
{delta != null ? <><dt className="text-muted">Quote vs break even</dt><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
<dl className="grid grid-cols-2 items-baseline gap-x-4 gap-y-2 text-sm">
<Label as="dt">Contract window</Label><dd className="text-right">{dateRange(row.startsAt, row.endsAt)}</dd>
<Label as="dt">Capacity shape</Label><dd className="text-right">{shape ? `${shape.quantities.length} tranches · ${shape.quantities.join('→')} GPUs` : 'Flat'}{detail?.commitment.isContiguous ? ' · contiguous' : ''}</dd>
<Label as="dt">Our cost</Label><dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<Label as="dt">Remaining-block break even</Label><dd className="nums text-right">{row.breakEvenPriceCents == null ? 'Fully sold' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</dd>
{Number(detail?.commitment.oversubscriptionPct ?? 0) > 0 ? <><Label as="dt">Recorded oversubscription</Label><dd className="nums text-right">{Number(detail?.commitment.oversubscriptionPct)}%</dd></> : null}
{delta != null ? <><Label as="dt">Quote vs break even</Label><dd className={delta >= 0 ? 'nums text-right text-positive' : 'nums text-right text-danger'}>{delta >= 0 ? '+' : ''}{unitPrice(Math.round(delta * 100))}/GPU-hr</dd></> : null}
</dl>
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-xs text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
<p className="mt-4 text-[11px] leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
{match?.rationale.length ? <ul className="mt-4 flex flex-col gap-1 text-sm text-muted">{match.rationale.map((reason) => <li key={reason}>{reason}</li>)}</ul> : null}
<p className="mt-4 text-xs leading-relaxed text-muted">These figures are the latest server view, not a guarantee. Save acquires a commitment lock and re-checks the exact window, shape, hours, live holds, and oversubscription policy.</p>
</section>
);
}
function TextField<T extends FieldValues>({ control, name, label, className, ...props }: { control: Control<T>; name: FieldPath<T>; label: string; className?: string } & Omit<React.ComponentProps<typeof Input>, 'name' | 'value' | 'defaultValue'>) {
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FormLabel>{label}</FormLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
return <FormField control={control} name={name} render={({ field }) => <FormItem className={className}><FieldLabel>{label}</FieldLabel><FormControl><Input {...field} {...props} value={String(field.value ?? '')} /></FormControl><FormMessage /></FormItem>} />;
}
function SelectField<T extends FieldValues>({ control, name, label, options }: { control: Control<T>; name: FieldPath<T>; label: string; options: readonly string[] }) {
return <FormField control={control} name={name} render={({ field }) => <FormItem><FormLabel>{label}</FormLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger className="h-11"><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
return <FormField control={control} name={name} render={({ field }) => <FormItem><FieldLabel>{label}</FieldLabel><Select value={String(field.value)} onValueChange={field.onChange}><FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl><SelectContent><SelectGroup>{options.map((option) => <SelectItem key={option} value={option}>{option.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase())}</SelectItem>)}</SelectGroup></SelectContent></Select><FormMessage /></FormItem>} />;
}
function ServerError({ message }: { message: string }) {
return <div role="alert" className="flex gap-3 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
return <div role="alert" className="flex gap-3 rounded-md border border-danger/30 bg-danger/10 p-3 text-sm text-danger"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><p>{message}</p></div>;
}
function errorMessage(error: unknown): string {
+6 -3
View File
@@ -97,14 +97,17 @@ export function AppHeader() {
aria-haspopup="dialog"
aria-expanded={commandOpen}
className={cn(
'hidden h-9 min-w-0 items-center gap-2 rounded-md border border-input bg-surface-2 px-2.5',
'text-left text-sm text-muted shadow-sm transition-colors hover:text-fg md:flex md:w-56 lg:w-72',
// A control, so 12px radius and the 44px floor the rest of the
// product holds — it reads as a field but it is a button, and a
// button that is 36px tall is one this system does not have.
'hidden h-11 min-w-0 items-center gap-2 rounded-lg border border-border bg-surface-2 px-3',
'text-left text-sm text-muted transition-colors duration-1 ease-enter hover:text-fg md:flex md:w-56 lg:w-72',
)}
onClick={() => setCommandOpen(true)}
>
<Search className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px]">
<kbd className="shrink-0 rounded-md border border-border px-1.5 py-0.5 font-mono text-xs">
K
</kbd>
</button>
+4 -4
View File
@@ -13,7 +13,7 @@ import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { AccountSwitcher } from './AccountSwitcher';
import { Button } from './ui';
import { Button, Label } from './ui';
import {
Sidebar,
SidebarContent,
@@ -43,9 +43,9 @@ export function AppSidebar() {
device where neither is discoverable. */}
{isMobile ? (
<div className="flex items-center justify-between pl-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Navigate
</span>
{/* Same spec as every other group heading in the rail — this one
had its own copy of the retired 10px/600/0.16em values. */}
<Label>Navigate</Label>
<Button
type="button"
variant="ghost"
+6 -3
View File
@@ -40,7 +40,7 @@ export function AudioControl({ className }: { className?: string }) {
aria-pressed={enabled}
aria-label={label}
title={label}
className={cn('h-9 w-9 min-h-0 min-w-0', enabled ? 'text-fg' : 'text-muted')}
className={cn(enabled ? 'text-fg' : 'text-muted')}
>
{enabled ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
</Button>
@@ -52,9 +52,12 @@ export function AudioControl({ className }: { className?: string }) {
variant="ghost"
size="icon"
aria-label="Choose a track"
className="-ml-1.5 h-9 w-5 min-h-0 min-w-0 text-muted"
// The track list used to hang off a 20px-wide caret, which is the
// smallest target in the product and sits on the sign-in screen,
// where a mis-tap mutes the music instead of opening the list.
className="-ml-2 text-muted"
>
<ChevronDown className="size-3" />
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
+30 -23
View File
@@ -11,7 +11,18 @@ import {
} from 'lucide-react';
import { NOTIFICATION_KINDS, type NotificationKind } from '@pig/core';
import { api, get, post } from '@/lib/api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Label as MicroLabel,
} from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
@@ -263,8 +274,7 @@ function ChannelLinkManager({
) : (
<form className="flex flex-col gap-4" onSubmit={submit}>
{provider === 'slack' ? (
<label className="flex flex-col gap-1.5" htmlFor="slack-workspace">
<span className="text-sm font-medium">Workspace ID</span>
<FormField label="Workspace ID">
<Input
id="slack-workspace"
value={workspace}
@@ -272,10 +282,10 @@ function ChannelLinkManager({
placeholder="T0123456789"
required
/>
</label>
</FormField>
) : (
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Relay workspace</span>
<div className="flex min-w-0 flex-col gap-1.5">
<MicroLabel>Relay workspace</MicroLabel>
<div className="flex min-h-11 items-center rounded-lg border border-border bg-surface-2 px-3 text-sm text-muted">
{workspaceId}
</div>
@@ -283,10 +293,7 @@ function ChannelLinkManager({
)}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-id`}>
<span className="text-sm font-medium">
{provider === 'slack' ? 'Channel ID' : 'Channel UUID'}
</span>
<FormField label={provider === 'slack' ? 'Channel ID' : 'Channel UUID'}>
<Input
id={`${provider}-channel-id`}
value={channelId}
@@ -294,22 +301,20 @@ function ChannelLinkManager({
placeholder={provider === 'slack' ? 'C0123456789' : '00000000-0000-…'}
required
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5" htmlFor={`${provider}-channel-name`}>
<span className="text-sm font-medium">Display name</span>
</FormField>
<FormField label="Display name">
<Input
id={`${provider}-channel-name`}
value={channelName}
onChange={(event) => setChannelName(event.target.value)}
placeholder="gpu-sales"
/>
</label>
</FormField>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${provider}-account`}>Account</Label>
<FormField label="Account">
<Select value={accountId} onValueChange={setAccountId} required>
<SelectTrigger id={`${provider}-account`} className="h-11">
<SelectTrigger id={`${provider}-account`}>
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
@@ -322,10 +327,10 @@ function ChannelLinkManager({
</SelectGroup>
</SelectContent>
</Select>
</div>
</FormField>
<fieldset className="flex flex-col gap-1.5">
<legend className="text-sm font-medium">Notify this channel</legend>
<MicroLabel as="legend">Notify this channel</MicroLabel>
<div className="grid gap-2 sm:grid-cols-2">
{NOTIFICATION_KINDS.map((kind) => {
const checked = notifyOn.includes(kind);
@@ -367,12 +372,14 @@ function ChannelLinkManager({
)}
<div className="flex flex-col gap-2">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted">Linked channels</h4>
<MicroLabel as="h4">Linked channels</MicroLabel>
{linksLoading ? <p className="text-sm text-muted">Loading links</p> : null}
{!linksLoading && links.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted">
No {provider === 'slack' ? 'Slack' : 'Buzz'} channels linked yet.
</p>
<EmptyState
size="inline"
className="rounded-xl border border-dashed border-border"
title={`No ${provider === 'slack' ? 'Slack' : 'Buzz'} channels linked yet`}
/>
) : null}
{links.map(({ link, accountName }) => (
<div
+13 -7
View File
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Link2, LoaderCircle, Unplug } from 'lucide-react';
import { api, get, post } from '@/lib/api';
import { Badge, Button } from '@/components/ui';
import { Badge, Button, EmptyState } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import {
Select,
SelectContent,
@@ -87,7 +88,12 @@ export function NotionImportSource({
});
if (status.data && !status.data.configured) {
return <div className="rounded-xl border border-dashed border-border p-4"><p className="text-sm font-medium">Notion is not configured</p><p className="mt-1 text-xs text-muted">An operator must set the Notion OAuth environment variables and encryption key on the API server.</p></div>;
return <EmptyState
size="inline"
className="rounded-xl border border-dashed border-border"
title="Notion is not configured"
description="An operator must set the Notion OAuth environment variables and encryption key on the API server."
/>;
}
return (
@@ -97,13 +103,13 @@ export function NotionImportSource({
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-border bg-surface"><Database className="size-5" aria-hidden /></span>
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><p className="font-medium">Notion database</p>{status.data?.connected ? <Badge tone="positive">Connected</Badge> : null}</div><p className="mt-0.5 text-xs text-muted">Choose a shared data source, then map it through the same dry run as a spreadsheet.</p></div>
</div>
{!status.data?.connected ? <Button className="min-h-11" type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
{!status.data?.connected ? <Button type="button" variant="outline" disabled={disabled || connect.isPending || !status.data?.configured} onClick={() => connect.mutate()}>{connect.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Link2 data-icon="inline-start" aria-hidden />}Connect Notion</Button> : null}
</div>
{status.data?.connected ? <div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_auto_auto] lg:items-end">
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Workspace<Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<label className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">Database<Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger className="h-11"><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></label>
<Button className="min-h-11" type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
<Button className="min-h-11" type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
<FormField label="Workspace"><Select value={selectedConnection} onValueChange={(value) => { setConnectionId(value); setDataSourceId(''); }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{status.data.connections.map((connection) => <SelectItem key={connection.id} value={connection.id}>{connection.workspaceIcon ? `${connection.workspaceIcon} ` : ''}{connection.workspaceName ?? connection.workspaceId}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField>
<FormField label="Database"><Select value={dataSourceId} onValueChange={setDataSourceId} disabled={dataSources.isLoading}><SelectTrigger><SelectValue placeholder={dataSources.isLoading ? 'Loading databases…' : 'Choose a database'} /></SelectTrigger><SelectContent><SelectGroup>{(dataSources.data?.dataSources ?? []).map((source) => <SelectItem key={source.id} value={source.id}>{source.icon ? `${source.icon} ` : ''}{source.name}</SelectItem>)}</SelectGroup></SelectContent></Select></FormField>
<Button type="button" variant="primary" disabled={!dataSourceId || materialize.isPending} onClick={() => materialize.mutate()}>{materialize.isPending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : <Database data-icon="inline-start" aria-hidden />}{materialize.isPending ? 'Reading…' : 'Use database'}</Button>
<Button type="button" variant="ghost" disabled={disconnect.isPending} onClick={() => disconnect.mutate(selectedConnection)}><Unplug data-icon="inline-start" aria-hidden />Disconnect</Button>
</div> : null}
{connect.error || dataSources.error || materialize.error || disconnect.error ? <p role="alert" className="mt-3 text-sm text-danger">{errorMessage(connect.error ?? dataSources.error ?? materialize.error ?? disconnect.error)}</p> : null}
</div>
+407 -105
View File
@@ -1,9 +1,17 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { useQuery } from '@tanstack/react-query';
import { Bot, CircleStop, Database, Loader2, MessageCircleMore, Send, Sparkles, XCircle } from 'lucide-react';
import { CircleStop, Database, Loader2, Send, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision } from '@pig/core';
import { get } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { PIGGY_ASK_LABEL, piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import {
PIGGY_MESSAGE_MAX_LENGTH,
@@ -17,7 +25,7 @@ import {
type PiggyStatus,
type TranscriptMessage,
} from '@/lib/piggy-chat';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } from '@/lib/piggy-suggestions';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps } from '@/lib/piggy-suggestions';
import { PiggyApprovalCard } from './piggy/approval-card';
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
import { PiggyMessageActions } from './piggy/message-actions';
@@ -25,6 +33,8 @@ import { PiggyReasoning } from './piggy/reasoning';
import { PiggyResponse } from './piggy/response';
import { PiggyToolStep } from './piggy/tool';
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './piggy/workspace/controls';
import { PiggyWorkspaceStarters } from './piggy/workspace/starters';
import { PiggyMark } from './PiggyMark';
import { Button, Badge, EmptyState, cn } from './ui';
import {
Drawer,
@@ -50,10 +60,78 @@ import { Textarea } from './ui/textarea';
*/
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;
/**
* How narrow a Piggy surface has to be before it takes the tight treatment.
*
* 28rem, and measured on the panel rather than the window, because the four
* surfaces this panel is drawn in disagree with the viewport: the dock is 22rem
* inside a 1440px window, the phone drawer is 393px inside a 393px one. It used
* to be a prop that only the dock passed, so the narrowest surface in the
* product — the drawer a phone gets — was drawing the roomy layout.
*/
const COMPACT_WIDTH = 448;
/** `max-h-36` on the field, in pixels, for the auto-grow below to cap at. */
const COMPOSER_MAX_HEIGHT = 144;
// ------------------------------------------------------------- one front door
/**
* A question a page asked on the user's behalf.
*
* `id` is new on every press so that pressing the same button twice puts the
* question back in the composer, rather than the second press doing nothing
* because the request happened to be identical.
*/
export interface PiggyAsk {
id: number;
context?: PiggyChatContext;
prompt?: string;
}
let nextAskId = 1;
const askListeners = new Set<(ask: PiggyAsk) => void>();
/**
* Ask Piggy something from anywhere, on whichever surface Piggy is showing.
*
* PIG used to have two front doors on the same row of pixels: the pig in the
* header opened the dock, and a speech bubble labelled "Ask Piggy" beside it
* opened a *different* Piggy in a sheet on top of it. Two surfaces, two
* transcripts, one agent — and no sentence that told them apart.
*
* So a page button no longer opens anything. It publishes the question, and
* the one Piggy this viewport has room for takes it: the docked column above
* 1280px, the sheet or drawer below it. The question is seeded into the
* composer and never sent, because the user asked for a starting point, not
* for a turn to be spent on their behalf.
*/
export function requestPiggyAsk(request: Omit<PiggyAsk, 'id'>): void {
const ask: PiggyAsk = { ...request, id: nextAskId++ };
for (const listener of askListeners) listener(ask);
}
/** Subscribe to those requests for as long as this component is mounted. */
export function usePiggyAskRequests(handler: (ask: PiggyAsk) => void): void {
const latest = useRef(handler);
// Written in a layout effect rather than during render: a handler captured
// during a render React later discards would answer with stale state.
useLayoutEffect(() => {
latest.current = handler;
});
useEffect(() => {
const listener = (ask: PiggyAsk) => latest.current(ask);
askListeners.add(listener);
return () => {
askListeners.delete(listener);
};
}, []);
}
export function PiggyAskButton({
context,
prompt,
label = 'Ask Piggy',
label = PIGGY_ASK_LABEL,
variant = 'outline',
}: {
context?: PiggyChatContext;
@@ -61,7 +139,6 @@ export function PiggyAskButton({
label?: string;
variant?: React.ComponentProps<typeof Button>['variant'];
}) {
const [open, setOpen] = useState(false);
const status = usePiggyStatus();
const unavailable = status.data && !status.data.canUse;
// An explicit prop always wins. Every existing call site passes the record
@@ -69,24 +146,16 @@ export function PiggyAskButton({
// that must never displace it.
const ambient = usePiggyCurrentContext();
return (
<>
<Button
type="button"
variant={variant}
disabled={Boolean(unavailable)}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
onClick={() => setOpen(true)}
>
<MessageCircleMore aria-hidden />
{label}
</Button>
<ResponsivePiggyChat
open={open}
onOpenChange={setOpen}
context={context ?? ambient}
initialPrompt={prompt}
/>
</>
<Button
type="button"
variant={variant}
disabled={Boolean(unavailable)}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
onClick={() => requestPiggyAsk({ context: context ?? ambient, prompt })}
>
<PiggyMark className="size-4" />
{label}
</Button>
);
}
@@ -99,7 +168,7 @@ export function PiggyAskButton({
export function PiggyUnavailable({ status }: { status: PiggyStatus | undefined }) {
return (
<EmptyState
icon={<Bot />}
icon={<PiggyMark className="size-6" />}
title="Piggy is unavailable"
description={
status?.enabled
@@ -115,11 +184,14 @@ export function ResponsivePiggyChat({
onOpenChange,
context,
initialPrompt,
seed,
}: {
open: boolean;
onOpenChange(open: boolean): void;
context?: PiggyChatContext;
initialPrompt?: string;
/** A later question from a page's Ask Piggy button. See `PiggyChatPanel`. */
seed?: PiggySeed;
}) {
// The same breakpoint the shell switches navigation at. It used to be `md`,
// which meant a 900px tablet got the desktop side sheet sliding in behind
@@ -131,15 +203,28 @@ export function ResponsivePiggyChat({
// destroyed the conversation, the draft and any answer still streaming — and
// with the controls inside, the mode went with it.
const { conversation, controls } = usePiggyChatSession({ context, initialPrompt });
const subtitle = context
? `Working from ${contextLabel(context)}`
: 'Working from your PIG workspace';
if (desktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
<SheetHeader className="border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]">
<SheetTitle>Ask Piggy</SheetTitle>
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
{/* `band` rather than a hand-rolled top strip: the padding, the rule
and the safe-area inset are the overlay primitive's job, and this
file was one of four keeping its own copy of them. */}
<SheetHeader band>
<SheetTitle>{PIGGY_ASK_LABEL}</SheetTitle>
<SheetDescription>{subtitle}</SheetDescription>
</SheetHeader>
<PiggyChatPanel conversation={conversation} controls={controls} context={context} autoFocusComposer className="min-h-0 flex-1" />
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
seed={seed}
autoFocusComposer
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
);
@@ -147,41 +232,59 @@ export function ResponsivePiggyChat({
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[92dvh]">
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
<DrawerTitle>Ask Piggy</DrawerTitle>
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
<DrawerHeader className="border-b border-border px-5 py-4 text-left">
<DrawerTitle>{PIGGY_ASK_LABEL}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
{/* No autofocus on the phone: focusing the composer raises the keyboard
over most of the drawer before the user has read anything. */}
<PiggyChatPanel conversation={conversation} controls={controls} context={context} className="min-h-0 flex-1" />
over most of the drawer before the user has read anything. A seeded
question fills the composer here too, but silently — see the seed
effect in `PiggyChatPanel`. */}
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
seed={seed}
className="min-h-0 flex-1"
/>
</DrawerContent>
</Drawer>
);
}
/** A question handed to the composer from outside. See `requestPiggyAsk`. */
export interface PiggySeed {
/** Changes on every request, so the same question can be seeded twice. */
id: number;
text?: string;
}
/**
* The transcript and composer. Width-agnostic on purpose — it is used at a
* full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
*
* `compact` is for the dock only. At 22rem the ordinary spacing does not fail,
* it just crowds: the assistant avatar takes a tenth of the line, a user
* bubble at 88% leaves no gutter to read the alignment from, and the
* suggestion buttons wrap to three lines each.
* It measures itself rather than being told how much room it has. At 22rem the
* ordinary spacing does not fail, it just crowds: the assistant avatar takes a
* tenth of the line, a user bubble at 88% leaves no gutter to read the
* alignment from, and the suggestion chips wrap to three lines each. That used
* to be a `compact` prop, which only the dock ever passed — so the phone
* drawer, the narrowest surface in the product, drew the roomy layout while a
* 22rem column inside a 1440px window drew the tight one. The surface's own
* width is the honest question, and it is the one asked here.
*/
export function PiggyChatPanel({
context,
initialPrompt = '',
className,
compact = false,
conversation,
controls,
emptyState,
seed,
autoFocusComposer = false,
}: {
context?: PiggyChatContext;
initialPrompt?: string;
className?: string;
compact?: boolean;
/**
* A conversation owned by something that outlives this panel. The overlays
* pass one because they unmount their children on close; the dock and the
@@ -199,8 +302,13 @@ export function PiggyChatPanel({
* simply shows no controls — the surface above it has them.
*/
controls?: PiggyControlsState;
/** Replaces the default openers. The workspace has a bigger front door. */
/**
* Replaces the default openers. The workspace passes its own so it can wire
* the escalation to the thread that owns the mode.
*/
emptyState?: ReactNode;
/** A question from a page's Ask Piggy button, seeded into the composer. */
seed?: PiggySeed;
autoFocusComposer?: boolean;
}) {
// Called unconditionally — hooks must be — and then ignored when a
@@ -209,14 +317,34 @@ export function PiggyChatPanel({
const active = conversation ?? own;
const { messages, draft, setDraft, running, send, stop, retry, approve } = active;
const composerRef = useRef<HTMLTextAreaElement | null>(null);
// The dock keeps one. Nothing fits two on a line at 22rem, so the second is a
// whole extra row of chrome taken off the shortest transcript of the three.
const followUps = messages.length
? piggyFollowUps(context, userQuestions(messages)).slice(0, compact ? 1 : PIGGY_FOLLOW_UP_COUNT)
: [];
const rootRef = useRef<HTMLDivElement | null>(null);
const compact = useIsNarrow(rootRef);
// Never on a phone, however the composer was filled: focusing a textarea
// raises the keyboard over the drawer the user has not read yet.
const isMobile = useIsMobile();
useEffect(() => {
if (!autoFocusComposer) return;
/*
* A pending approval owns the conversation until it is answered.
*
* The chips offer a different question, and offering one under a card that
* is asking whether to write to the book invites the user to walk away from
* a decision that is still open.
*/
const awaitingApproval = messages.some((message) =>
message.approvals?.some((approval) => approval.state === 'pending' || approval.state === 'submitting'),
);
// One chip on a narrow surface. Nothing fits two on a line at 22rem, so the
// second is a whole extra row of chrome taken off the shortest transcript in
// the product.
const followUps =
messages.length && !awaitingApproval
? piggyFollowUps(context, userQuestions(messages)).slice(
0,
compact ? 1 : PIGGY_FOLLOW_UP_COUNT,
)
: [];
const focusComposer = useCallback(() => {
const composer = composerRef.current;
if (!composer) return;
// Radix moves focus to the first tabbable element in the sheet — its own
@@ -229,10 +357,100 @@ export function PiggyChatPanel({
composer.setSelectionRange(composer.value.length, composer.value.length);
});
return () => cancelAnimationFrame(frame);
}, [autoFocusComposer]);
}, []);
useEffect(() => {
if (!autoFocusComposer) return;
return focusComposer();
}, [autoFocusComposer, focusComposer]);
/*
* A write opener pressed while Piggy is in Read only.
*
* The mode has to be committed before the turn leaves, because `send` reads
* it off the conversation — so the text is parked here and sent by the effect
* below once the conversation is actually holding the new mode. Sending in
* the same tick would ask Piggy to change something with the write tools
* still withheld, and the answer would be a polite refusal.
*/
const [escalating, setEscalating] = useState<string | null>(null);
const conversationMode = active.mode;
const askWithChange = useCallback(
(text: string) => {
if (!controls?.canWrite) return;
if (conversationMode === 'read_only') {
controls.setMode('confirm');
setEscalating(text);
return;
}
send(text);
},
[controls, conversationMode, send],
);
useEffect(() => {
if (escalating === null) return;
if (conversationMode === 'read_only') return;
setEscalating(null);
send(escalating);
}, [escalating, conversationMode, send]);
/*
* The composer grows with what is in it, up to `COMPOSER_MAX_HEIGHT`.
*
* It never did, and a seeded question made that impossible to ignore: a page
* button hands the composer a whole sentence, the caret lands at the end, and
* a 44px box scrolled to the caret shows the user the middle of a question
* they have not read yet — with the first line sliced through the middle of
* its letters. Growing is also what the `max-h-36` already on the field was
* plainly written for.
*/
useLayoutEffect(() => {
const composer = composerRef.current;
if (!composer) return;
const fit = () => {
// Collapse first: `scrollHeight` on an already-tall box reports the
// height it currently has, so without this the field can only ever grow.
composer.style.height = 'auto';
composer.style.height = `${Math.min(composer.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
};
fit();
/*
* Again next frame, and again on every resize.
*
* An inline height measured once at mount is a height that goes stale, and
* measurably did: on a 393px-wide phone the first pass read 62px for an
* empty single-row field that measures 42px a frame later, so the composer
* settled 20px taller than its own content for the life of the mount — and
* a phone rotated after mount kept the height it was given in the other
* orientation. The listener costs nothing and the read is cheap; a stale
* measurement on the control the whole company types into is not.
*/
const frame = requestAnimationFrame(fit);
window.addEventListener('resize', fit);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', fit);
};
// `compact` too: it flips when the surface's measured width crosses the
// narrow threshold, which is the other way this field's line count changes
// without a keystroke.
}, [draft, compact]);
const seedId = seed?.id;
const seedText = seed?.text;
useEffect(() => {
if (seedId == null) return;
// Replaces the draft rather than appending to it: the user pressed a
// button asking for this exact question, and a half-typed line joined to a
// canned one is a sentence neither of them wrote.
if (seedText) setDraft(seedText);
if (isMobile) return;
return focusComposer();
}, [seedId, seedText, setDraft, focusComposer, isMobile]);
return (
<div className={cn('flex min-h-0 flex-col', className)}>
<div ref={rootRef} className={cn('flex min-h-0 flex-col', className)}>
{/* The viewport owns the scrolling, the log role and the follow-the-tail
behaviour. There is deliberately no scroll effect left in this file:
the `scrollIntoView` it replaced fired once per streamed token, which
@@ -251,14 +469,46 @@ export function PiggyChatPanel({
*/
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain py-5',
compact ? 'px-3' : 'px-4 sm:px-5',
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain',
// Measured: at 393x852 the workspace's front door overran its own
// scrollport by 32px, which cut the last note in half — a sliced
// line of type reads as a rendering fault rather than as something
// to scroll to. Sixteen of those pixels are here.
compact ? 'px-3 py-3' : 'px-4 py-5 sm:px-5',
)}
>
{emptyState ?? <PiggyStarters compact={compact} context={context} onAsk={send} />}
{/* One front door. The dock used to draw its own openers — three
read questions under a Sparkles glyph, with no mention that
Piggy can write, which is the product's headline capability
missing from the surface people keep open all day. */}
{emptyState ?? (
<PiggyWorkspaceStarters
context={context}
mode={controls?.mode ?? conversationMode}
canWrite={controls?.canWrite ?? false}
onAsk={send}
onAskWithChange={askWithChange}
narrow={compact}
/>
)}
</div>
) : (
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
<PiggyConversation
/*
* `busy` is dropped the moment a proposal is parked, not when the
* turn ends. A write tool holds its call open across the whole
* approval, so `running` stays true for as long as the card is on
* screen — and `aria-busy` on the live-region ROOT is precisely the
* flag an assistive technology consults before deciding whether to
* speak what arrived inside it. Left set, the one announcement the
* product's entire safety argument rests on ("Piggy wants to change
* this; nothing has happened yet") was the announcement being
* suppressed. The stream is still running; it is running *waiting for
* this person*, which is not the state the attribute means.
*/
busy={running && !awaitingApproval}
className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}
>
<div
// The column is capped at a reading measure rather than filling the
// page: at 1440 the workspace panel is over a thousand pixels wide,
@@ -281,7 +531,21 @@ export function PiggyChatPanel({
</PiggyConversation>
)}
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); send(); }}>
{/*
The composer's short-viewport diet.
Measured on a stored thread: at 852x393 this form was 173px of a 393px
screen and at 393x390 it was 171px of 390 — 44% of the viewport, on the
page whose point is the transcript above it. Nothing in it was wrong;
there was simply no gate on a screen with no room. So under
`(max-height: 500px)` the two pieces that are conveniences give way —
the follow-up suggestions and the standing safety sentence — and the
padding halves, leaving the textarea, the Send button and the counter,
which are the parts a person on a landscape phone actually came for.
The sentence is a standing reminder, not a warning about this turn; the
approval card states the stakes at the moment they exist.
*/}
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4', '[@media(max-height:500px)]:p-2')} onSubmit={(event) => { event.preventDefault(); send(); }}>
{/* The same measure the transcript is set to. Without it the composer
ran the full width of the workspace pane while every answer above it
stopped at 48rem, so the box you type into and the column you read
@@ -292,7 +556,7 @@ export function PiggyChatPanel({
// than every surface but the full page, and a chip sliced off by the
// panel edge reads as a rendering fault — where a second line reads
// as a second suggestion.
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
<section className="mb-2 flex flex-wrap gap-1.5 [@media(max-height:500px)]:hidden" aria-label="Suggested questions">
{followUps.map((suggestion) => (
<button
key={suggestion}
@@ -301,16 +565,23 @@ export function PiggyChatPanel({
// anything mid-stream, and a row that vanishes and returns
// moves the composer under the user's thumb.
disabled={running}
// Each chip is one line whatever the width, so the row can only
// ever be as tall as the number of suggestions.
// Wraps rather than truncating, and 12px-radius rather than a
// pill, because a chip cut off mid-word — "How much of this
// block is still un…" — is a question nobody can decide
// whether they want asked. A second line costs less than a
// suggestion nobody presses.
title={suggestion}
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
className={cn(
'flex min-h-11 max-w-full items-center rounded-lg border border-border px-3 py-2',
'text-left text-xs leading-4 text-muted transition-colors duration-1 ease-enter',
'hover:bg-surface-2 hover:text-fg disabled:opacity-50',
)}
onClick={() => send(suggestion)}
>
{suggestion}
</button>
))}
</div>
</section>
) : null}
{/* Above the textarea, not below it: these decide what the next turn may
do, and they are read at the moment the send button is looked at.
@@ -344,26 +615,59 @@ export function PiggyChatPanel({
}
}}
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
className="min-h-11 max-h-36 resize-none"
placeholder="Ask about capacity, margin, paper or next actions…"
/*
* One row at rest, not the element default of two.
*
* The effect above sizes the field to its own `scrollHeight`, and
* `scrollHeight` counts `rows` — so an empty composer was 64px, a
* blank second line the user has not typed, beside a 44px Send
* button it therefore never lined up with. On a 390px keyboard-up
* screen those 20px are 5% of the viewport taken from the
* transcript, which is what made this worth chasing; on every
* other surface it is simply the composer finally matching the
* 44px floor it already declares. It still grows to `max-h-36`
* from the first keystroke, so nothing about typing changes.
*/
rows={1}
// `overflow-y-auto` so the field scrolls once it has grown to its
// cap rather than hiding the rest of a long paste.
className="min-h-11 max-h-36 resize-none overflow-y-auto"
placeholder={piggyLine(piggyCopy.composerPlaceholder, compact)}
aria-label="Message Piggy"
/>
{running ? (
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><CircleStop aria-hidden /></Button>
<Button
type="button"
size="icon"
variant="outline"
aria-label="Stop Piggy"
// The Stop button is replaced by Send the instant the turn
// ends, so focus was landing on `<body>` — on the workspace,
// 121 Tab presses from the composer the user was about to type
// the next question into.
onClick={() => {
stop();
focusComposer();
}}
>
<CircleStop aria-hidden />
</Button>
) : (
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
)}
</div>
<div className="mt-2 flex items-baseline gap-2 text-[11px] leading-4 text-muted">
<div className="mt-2 flex items-baseline gap-2 text-xs leading-4 text-muted [@media(max-height:500px)]:mt-0">
{/* No longer "Read-only session": Piggy writes now, and what it may
do this turn is stated by the mode control above rather than by a
line of copy that would have to be kept in step with it. What is
left is the part that is true in every mode. */}
<p className="flex-1 text-center">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
<p className="flex-1 text-center [@media(max-height:500px)]:hidden">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
{/* No live region: this changes on every keystroke, and the cap is
already announced from the textarea's own `maxLength`. */}
already announced from the textarea's own `maxLength`.
`ml-auto` keeps it right-aligned on a short viewport, where the
sentence that was pushing it there is gone. */}
{draft.length >= COUNTER_VISIBLE_FROM ? (
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
<p className={cn('ml-auto shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
</p>
) : null}
@@ -375,43 +679,26 @@ export function PiggyChatPanel({
}
/**
* The blank transcript.
* How wide the panel actually is, as a boolean.
*
* The openers come from `piggySuggestions`, which chooses them by the one read
* tool this context resolves to rather than by what the page is called — so
* every line offered here is one Piggy can actually ground. The dock takes
* three of them: at 22rem each opener wraps to two lines, and a fourth turns a
* quick way in into a page of text to read before typing.
* A media query cannot answer this: the dock is 22rem inside a 1440px window,
* and asking the window would give the roomy layout to a column that has no
* room. Measured in a layout effect so the first paint is already correct —
* a panel that renders roomy and reflows to tight one frame later is a panel
* that visibly twitches every time an overlay opens.
*/
function PiggyStarters({
context,
compact,
onAsk,
}: {
context?: PiggyChatContext;
compact: boolean;
onAsk: (text: string) => void;
}) {
const suggestions = piggySuggestions(context);
return (
// `flex-1`, not `h-full`: the conversation's content element is sized by its
// children, so a percentage height here resolves to nothing.
<div className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center text-center">
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
{/* The old line ended "and this chat cannot write CRM records", which
stopped being true the moment the mode control appeared under it. What
is still true is the boundary: PIG's own tools, and nothing else. */}
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools no shell, no filesystem, no browser. Set to Ask first, it also proposes changes for you to approve.</p>
<div className="mt-4 grid w-full gap-2">
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
// Sends rather than fills the composer. Filling it looked like
// nothing had happened, so the chip read as a dead control.
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
))}
</div>
</div>
);
function useIsNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
const [narrow, setNarrow] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const measure = () => setNarrow(element.getBoundingClientRect().width < COMPACT_WIDTH);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return narrow;
}
/** What the user has already asked, so a follow-up chip cannot offer back a
@@ -448,13 +735,24 @@ function ChatMessage({
</div>
{/* The question is still on screen after a failed send, so the user's
words are never lost — but the bubble alone reads as sent. */}
{message.failed ? <p className="mt-1 text-[11px] leading-4 text-muted">Not sent</p> : null}
{message.failed ? <p className="mt-1 text-xs leading-4 text-muted">Not sent</p> : null}
</div>
);
}
return (
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
{/* The signature on every answer the company will read this year, and
until now it was a stock robot glyph — the one place Piggy's identity
is seen most, wearing somebody else's face. Round, because this is an
avatar and avatars are round in this product. */}
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-full bg-accent-subtle text-accent-fg',
compact ? 'size-7' : 'size-9',
)}
>
<PiggyMark className={compact ? 'size-4' : 'size-5'} />
</div>
{/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
on, and it is repeated here on purpose: the footer marks itself, so
without this the only way to find Copy is to sweep the pointer across
@@ -468,11 +766,15 @@ function ChatMessage({
show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
<PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
{message.tools?.length ? (
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
// `aria-live="off"`: these rows change several times a second while a
// turn runs, and they sit inside the transcript's own live region, so
// without it a screen reader reads out every tool starting and
// finishing before the answer the user asked for arrives.
<section className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity" aria-live="off">
{message.tools.map((tool) => (
<PiggyToolStep key={tool.id} step={tool} />
))}
</div>
</section>
) : null}
{message.content ? <PiggyResponse content={message.content} /> : null}
{/* Below the answer, because the answer is where Piggy says what it
+90 -14
View File
@@ -16,15 +16,22 @@
* runtime is disabled, so a dock that renders its composer without asking
* first is a permanent third of the window that fails on first use.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PanelRightClose, Sparkles } from 'lucide-react';
import { PanelRightClose } from 'lucide-react';
import { useHasDockRoom } from '@/hooks/use-media-query';
import { PIGGY_ASK_LABEL } from '@/lib/piggy-copy';
import { useLayout } from '@/lib/layout';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import type { PiggyChatContext } from '@/lib/piggy-chat';
import { usePiggyChatSession } from './piggy/workspace/controls';
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
import {
PiggyChatPanel,
ResponsivePiggyChat,
usePiggyAskRequests,
usePiggyStatus,
type PiggyAsk,
} from './PiggyChat';
import { PiggyMark } from './PiggyMark';
import { Button, EmptyState, Skeleton, cn } from './ui';
@@ -41,8 +48,30 @@ export function PiggyDock() {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
const ambient = usePiggyCurrentContext();
const { pathname } = useLocation();
const onWorkspace = pathname === PIGGY_WORKSPACE_PATH;
/*
* A question pressed on the page, taken by the column rather than by a
* second Piggy in a sheet on top of it.
*
* The ask carries its own context because a record button knows something
* the route does not: pressing Ask Piggy on a contract row must put the dock
* on that contract, not on the page it happens to be listed under.
*
* Cleared on navigation. A dock still pinned to a contract while you read an
* account is a dock whose badge is lying about what it is looking at, and
* the cost — a record-scoped thread ending when you leave the record — is
* the same cost the ambient key already pays for every record page.
*/
const [ask, setAsk] = useState<PiggyAsk | null>(null);
usePiggyAskRequests((next) => {
if (hasRoom) setAsk(next);
});
useEffect(() => setAsk(null), [pathname]);
const context = ask?.context ?? ambient;
// The remembered `dockOpen` is deliberately left alone: the column comes back
// by itself on the next page, so visiting the workspace does not silently
@@ -86,7 +115,7 @@ export function PiggyDock() {
</div>
) : !status.data?.canUse ? (
<EmptyState
icon={<Sparkles />}
icon={<PiggyMark className="size-6" />}
title="Piggy is unavailable"
description={
status.data?.enabled
@@ -106,6 +135,7 @@ export function PiggyDock() {
<DockThread
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
context={context}
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
/>
)}
</aside>
@@ -120,41 +150,67 @@ export function PiggyDock() {
* docked thread is thrown away and started again, and the mode and model have
* to be bound to whichever conversation that key produced.
*/
function DockThread({ context }: { context: PiggyChatContext }) {
function DockThread({
context,
seed,
}: {
context: PiggyChatContext;
seed?: { id: number; text?: string };
}) {
const { conversation, controls } = usePiggyChatSession({ context });
return (
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
compact
seed={seed}
className="min-h-0 flex-1"
/>
);
}
/**
* The header control for Piggy.
* The header control for Piggy, and the only thing that opens the agent.
*
* Below `xl` there is no column to toggle, so the same button opens the sheet
* or drawer instead — one affordance in one place, whatever the viewport can
* accommodate.
* accommodate. A page's "Ask Piggy" button no longer opens a surface of its
* own: it publishes its question through `requestPiggyAsk` and this control
* decides where Piggy is, which is what stops a pig face and a speech bubble
* on the same row of pixels from opening two different agents.
*/
export function PiggyDockToggle({ className }: { className?: string }) {
const { dockOpen, setDockOpen } = useLayout();
const hasRoom = useHasDockRoom();
const status = usePiggyStatus();
const context = usePiggyCurrentContext();
const ambient = usePiggyCurrentContext();
const [overlayOpen, setOverlayOpen] = useState(false);
/** The question that opened the overlay, when a page asked one. */
const [ask, setAsk] = useState<PiggyAsk | null>(null);
const unavailable = status.data && !status.data.canUse;
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
usePiggyAskRequests((next) => {
if (unavailable) return;
if (hasRoom) {
// The column takes it from here — `PiggyDock` is subscribed too, and it
// is the surface that holds the thread. All this has to do is make sure
// the column the user is about to be answered in is actually on screen.
setDockOpen(true);
return;
}
setAsk(next);
setOverlayOpen(true);
});
// Nothing for it to open: the whole page is Piggy. Left in the header as a
// dead control it would be the only button in PIG that does nothing when
// pressed — and pressed on the workspace it would toggle a column that
// `PiggyDock` refuses to draw.
if (onWorkspace) return null;
const overlayContext = ask?.context ?? ambient;
return (
<>
<Button
@@ -171,15 +227,35 @@ export function PiggyDockToggle({ className }: { className?: string }) {
? dockOpen
? 'Close the Piggy panel'
: 'Open the Piggy panel'
: 'Ask Piggy'
: PIGGY_ASK_LABEL
}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : 'Piggy'}
onClick={() => (hasRoom ? setDockOpen(!dockOpen) : setOverlayOpen(true))}
onClick={() => {
if (hasRoom) {
setDockOpen(!dockOpen);
return;
}
// Pressing the header control asks about the page, not about the
// record a button last pointed at: the ask is spent.
setAsk(null);
setOverlayOpen(true);
}}
>
<PiggyMark className="size-5" />
</Button>
{hasRoom ? null : (
<ResponsivePiggyChat open={overlayOpen} onOpenChange={setOverlayOpen} context={context} />
<ResponsivePiggyChat
// Keyed on the record the question is about, so a question pressed on
// a contract opens a thread about that contract — and pressing the
// same button again returns to the thread already in progress rather
// than discarding it.
key={overlayContext.type === 'page' ? 'page' : JSON.stringify(overlayContext)}
open={overlayOpen}
onOpenChange={setOverlayOpen}
context={overlayContext}
initialPrompt={ask?.prompt}
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
/>
)}
</>
);
+145
View File
@@ -0,0 +1,145 @@
/**
* A named record, one click from wherever it was named.
*
* Extracted from the approval card, where it was the escape hatch on a pending
* write: before deciding, open the account and check the note is not already
* there. It is here because Piggy's answers need the same thing and do not have
* it — an answer naming "DEMO — 128× H100 reserved, 6 months" and "$399,972.42"
* is inert text, while the Overview renders that same record as a row with
* "Match →". Closing that gap is what turns the transcript from an island that
* knows your data into a surface you can act from.
*
* Two honesty rules, both load-bearing:
*
* The title never promises a record the link cannot open. `/accounts/:id` is
* the only per-record route PIG has, so everything else lands on the list
* that contains the row. "Open the list containing Northwind Robotics" is a
* worse sentence than "Open Northwind Robotics" and a true one.
*
* A record with no route renders as plain text rather than as a dead link.
* Losing the name would be worse — an answer would silently stop mentioning
* the thing it reasoned about — and a chip that goes nowhere is the failure
* `tool.tsx` already names: proving nothing is worse than claiming nothing.
*
* `newTab` exists because the approval card's own escape hatch was destroying
* the proposal it existed to help verify: a same-tab navigation unmounts the
* transcript, and the pending card with it.
*/
import { ArrowUpRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/components/ui';
/**
* Where a record of each kind can be opened.
*
* When the other detail routes land, each of these becomes a one-line edit and
* `opensRecord` grows an entry; this is the only place a record id becomes a
* URL. The keys are wider than `PiggyRecordType` on purpose — a proposed change
* carries `record.type` as free text, and `allocation` and `task` are both
* things a write tool can produce.
*/
export const RECORD_ROUTES: Record<string, string> = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
task: '/calendar',
};
/** The URL for a record, or null when PIG has nowhere to send the reader. */
export function recordHref(type: string, id: string): string | null {
const base = RECORD_ROUTES[type];
if (!base) return null;
return type === 'account' ? `${base}/${id}` : base;
}
/** Whether the link opens the record itself or merely the list holding it. */
export function opensRecord(type: string): boolean {
return type === 'account';
}
export interface RecordLinkProps {
/** Record type as the payload reported it — free text, not a closed union. */
type: string;
id: string;
/** The record's name. Falls back to a neutral noun rather than showing a uuid. */
label?: string;
/**
* Open in a new tab. Off by default: this is the extracted behaviour, and a
* link inside a page that steals the tab is normal. Turn it on where leaving
* would destroy unsubmitted state — the pending approval card, and any link
* inside a streaming transcript.
*/
newTab?: boolean;
/**
* A word before the name: "Check", "Open". Omitted by default so a link
* inside a sentence reads as the record's name and nothing else.
*/
verb?: string;
className?: string;
}
export function RecordLink({ type, id, label, newTab = false, verb, className }: RecordLinkProps) {
const href = recordHref(type, id);
const name = label?.trim() || 'the record';
/*
* `min-h-11` even though this is a text link: it sits in the approval card's
* footer beside two 44px buttons and inside transcript prose, and both are
* places a thumb lands. The 44px floor is the primitive set's oldest rule and
* an inline link is not an exemption from it.
*
* `text-xs` is the approval footer's size and the default here; a caller
* inside 14px prose passes `className="text-sm"` and `cn()` resolves it,
* rather than this growing a size prop for two values.
*/
const shared =
'inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-lg text-xs ' +
'text-muted underline-offset-4 transition-colors duration-1 ease-enter ' +
'hover:text-fg hover:underline focus-visible:text-fg focus-visible:outline-none ' +
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 ' +
'focus-visible:ring-offset-bg';
if (!href) {
return (
<span className={cn('inline-flex max-w-full items-center text-xs text-muted', className)}>
<span className="truncate">{name}</span>
</span>
);
}
const destination = opensRecord(type)
? `Open ${name}`
: `Open the list containing ${name}`;
return (
<Link
to={href}
className={cn(shared, className)}
title={newTab ? `${destination} in a new tab` : destination}
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<span className="truncate">
{verb ? `${verb} ` : ''}
{name}
</span>
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
{/* The title attribute is not announced reliably, and where this link
lands is the one thing a screen-reader user must be told before they
follow it out of a pending approval: that it opens a new tab, and —
for every kind but `account`, which is the only per-record route PIG
has — that it opens the list rather than the row. The honest wording
existed already but lived only in `title`, which is neither visible
nor announced. */}
{opensRecord(type) && !newTab ? null : (
<span className="sr-only">
{opensRecord(type) ? '' : ' (opens the list containing it)'}
{newTab ? ' (opens in a new tab)' : ''}
</span>
)}
</Link>
);
}
+52 -37
View File
@@ -22,7 +22,7 @@ import { LoaderCircle, Lock } from 'lucide-react';
import { useForm, type Control, type FieldPath, type FieldValues } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Badge, Input } from '@/components/ui';
import { Badge, Input, Section } from '@/components/ui';
import { Button } from '@/components/ui/button';
import {
Form,
@@ -363,7 +363,9 @@ const activityFormSchema = z.object({
});
type ActivityForm = z.infer<typeof activityFormSchema>;
const label = (value: string) => value.replace(/_/g, ' ').replace(/^./, (letter) => letter.toUpperCase());
// `ai` is cased because the enum is `applied_ai_startup`, and an option
// reading "Applied ai startup" in a form about GPU capacity looks like a typo.
const label = (value: string) => value.replace(/_/g, ' ').replace(/\bai\b/g, 'AI').replace(/^./, (letter) => letter.toUpperCase());
const blankToNull = (value: string) => value.trim() || null;
const optionalNumber = (value: string) => value === '' ? null : Number(value);
const cents = (value: string) => value === '' ? null : Math.round(Number(value) * 100);
@@ -478,19 +480,19 @@ export function AccountSheet({ open, onOpenChange, record, identity }: SheetProp
<TextField control={form.control} name="website" label="Website" placeholder="https://acme.ai" className="sm:col-span-2" />
<TextAreaField control={form.control} name="description" label="Relationship context" placeholder="What they build, what they buy or sell, and why the relationship matters." className="sm:col-span-2" />
</FieldGrid>
<Section title="Commercial geography" description="Headquarters and legal jurisdiction are separate because export controls and data residency attach differently.">
<FormSection title="Commercial geography" description="Headquarters and legal jurisdiction are separate because export controls and data residency attach differently.">
<FieldGrid>
<TextField control={form.control} name="country" label="Headquarters country" />
<TextField control={form.control} name="region" label="Region" />
<TextField control={form.control} name="jurisdiction" label="Legal jurisdiction" className="sm:col-span-2" />
</FieldGrid>
</Section>
<Section title="Ultimate ownership" description="Only enter ownership you can substantiate; the compliance engine must not infer it from headquarters.">
</FormSection>
<FormSection title="Ultimate ownership" description="Only enter ownership you can substantiate; the compliance engine must not infer it from headquarters.">
<FieldGrid>
<TextField control={form.control} name="ultimateParentName" label="Ultimate parent" />
<TextField control={form.control} name="ultimateParentCountry" label="Parent country" />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save account' : 'Create account'} />
</form>
@@ -569,14 +571,14 @@ export function ContactSheet({ open, onOpenChange, record, identity, defaultAcco
<TextField control={form.control} name="phone" label="Phone" />
<SwitchField control={form.control} name="isDecisionMaker" label="Decision maker" description="They can materially approve or block this relationship." />
</FieldGrid>
<Section title="Public profiles">
<FormSection title="Public profiles">
<FieldGrid>
<TextField control={form.control} name="linkedinUrl" label="LinkedIn URL" className="sm:col-span-2" />
<TextField control={form.control} name="twitterHandle" label="X / Twitter handle" />
<TextField control={form.control} name="githubHandle" label="GitHub handle" />
<TextField control={form.control} name="websiteUrl" label="Website URL" className="sm:col-span-2" />
</FieldGrid>
</Section>
</FormSection>
<TextAreaField control={form.control} name="confidenceNote" label="Provenance note" description="Use this when the relationship or details need qualification." />
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save contact' : 'Create contact'} />
@@ -640,7 +642,7 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional className="sm:col-span-2" options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
<TextAreaField control={form.control} name="description" label="Deal context" className="sm:col-span-2" />
</FieldGrid>
<Section title="Commercials" description="Money is converted to integer cents at the API boundary; probability stays independent of stage.">
<FormSection title="Commercials" description="Money is converted to integer cents at the API boundary; probability stays independent of stage.">
<FieldGrid>
<TextField control={form.control} name="acv" label="ACV" inputMode="decimal" prefix="$" />
<TextField control={form.control} name="tcv" label="TCV" inputMode="decimal" prefix="$" />
@@ -649,15 +651,15 @@ export function DemandDealSheet({ open, onOpenChange, record }: SheetProps<Deman
<TextField control={form.control} name="probability" label="Probability (%)" inputMode="decimal" />
<TextField control={form.control} name="expectedCloseDate" label="Expected close" type="date" />
</FieldGrid>
</Section>
<Section title="Paper and continuity" description="Legal clears early in this market. These flags remain visible after the deal advances.">
</FormSection>
<FormSection title="Paper and continuity" description="Legal clears early in this market. These flags remain visible after the deal advances.">
<FieldGrid>
<SwitchField control={form.control} name="msaExecuted" label="MSA executed" />
<SwitchField control={form.control} name="dpaExecuted" label="DPA executed" />
<SelectField control={form.control} name="parentDealId" label="Parent deal" optional className="sm:col-span-2" options={parentOptions.map((row) => ({ value: row.deal.id, label: row.deal.name }))} />
<TextAreaField control={form.control} name="closedReason" label="Closed reason" description="Record why a deal was won or lost; leave blank while it is open." className="sm:col-span-2" />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save demand deal' : 'Create demand deal'} />
</form>
@@ -714,7 +716,7 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
<SelectField control={form.control} name="stage" label="Stage" options={SUPPLY_STAGES.map((value) => ({ value, label: SUPPLY_STAGE_LABELS[value] }))} />
<SelectField control={form.control} name="primaryContactId" label="Primary contact" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: row.contact.fullName }))} />
</FieldGrid>
<Section title="Capacity on offer" description="These terms describe the opportunity, not booked inventory. A commitment is created only after paper is executed.">
<FormSection title="Capacity on offer" description="These terms describe the opportunity, not booked inventory. A commitment is created only after paper is executed.">
<FieldGrid>
<TextField control={form.control} name="gpuType" label="GPU type" placeholder="H100_80GB" />
<TextField control={form.control} name="gpuCount" label="GPU count" inputMode="numeric" />
@@ -723,15 +725,15 @@ export function SupplyDealSheet({ open, onOpenChange, record }: SheetProps<Suppl
<TextField control={form.control} name="termMonths" label="Term (months)" inputMode="numeric" />
<TextField control={form.control} name="availableFrom" label="Available from" type="date" />
</FieldGrid>
</Section>
<Section title="Two-key diligence" description="Technical fitness and financial clearance are independent decisions. Record each verdict in its own voice.">
</FormSection>
<FormSection title="Two-key diligence" description="Technical fitness and financial clearance are independent decisions. Record each verdict in its own voice.">
<FieldGrid>
<TextField control={form.control} name="technicalVerdict" label="Technical verdict" placeholder="Passed, conditional, blocked…" />
<TextField control={form.control} name="financialVerdict" label="Financial verdict" placeholder="Passed, conditional, blocked…" />
<TextAreaField control={form.control} name="technicalNotes" label="Technical notes" />
<TextAreaField control={form.control} name="financialNotes" label="Financial notes" />
</FieldGrid>
</Section>
</FormSection>
<TextAreaField control={form.control} name="rejectionReason" label="Rejection reason" description="Rejections teach the sourcing team. Leave blank unless the deal is rejected." />
</SheetBody>
<SheetActions pending={save.isPending} onCancel={() => onOpenChange(false)} label={record ? 'Save supply deal' : 'Create supply deal'} />
@@ -835,7 +837,7 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<SelectField control={form.control} name="securityTier" label="Security tier" options={SECURITY_TIERS.map((value) => ({ value, label: label(value) }))} />
<SwitchField control={form.control} name="isContiguous" label="Contiguous block" description="Not one GPU count split across halls." />
</FieldGrid>
<Section title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
<FormSection title="Term and envelope" description="Contracted GPU-hours are stored as entered, not derived: ramp periods, maintenance windows and holdbacks are real and no formula predicts them.">
<FieldGrid>
<TextField control={form.control} name="startsAt" label="Starts" type="datetime-local" />
<TextField control={form.control} name="endsAt" label="Ends" type="datetime-local" />
@@ -844,8 +846,8 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<TextField control={form.control} name="currency" label="Currency" maxLength={3} />
<TextField control={form.control} name="oversubscriptionPct" label="Oversubscription allowance (%)" inputMode="decimal" description="Leave blank unless the contract permits selling above the envelope." />
</FieldGrid>
</Section>
<Section title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
</FormSection>
<FormSection title="Contractual liability" description="What we owe whether or not we draw the capacity. These fields are what make idle capacity worth alerting on.">
<FieldGrid>
<TextField control={form.control} name="minimumSpend" label="Minimum spend" inputMode="decimal" prefix="$" />
<TextField control={form.control} name="takeOrPayFloorPct" label="Take-or-pay floor (%)" inputMode="decimal" />
@@ -855,7 +857,7 @@ export function CommitmentSheet({ open, onOpenChange, identity, defaultAccountId
<SwitchField control={form.control} name="isAutoRenew" label="Auto-renews" description="Renewal alerting depends on this being honest." />
<TextAreaField control={form.control} name="notes" label="Commercial notes" className="sm:col-span-2" description="Caveats a seller would need before promising this capacity." />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Record commitment" />
</form>
@@ -970,12 +972,12 @@ export function LogActivitySheet({ open, onOpenChange, identity, defaultAccountI
<TextField control={form.control} name="subject" label="Subject" placeholder="Pricing call on the Q4 renewal" className="sm:col-span-2" />
<TextAreaField control={form.control} name="body" label="Detail" description={ACTIVITY_TYPE_HINTS[type]} className="sm:col-span-2" />
</FieldGrid>
<Section title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
<FormSection title="What it was about" description="Optional, and worth setting: a call attached to a deal and a person is the difference between a timeline and a diary.">
<FieldGrid>
<SelectField control={form.control} name="relatedDeal" label="Related deal" optional options={dealOptions} />
<SelectField control={form.control} name="contactId" label="Contact involved" optional options={contactOptions.map((row) => ({ value: row.contact.id, label: `${row.contact.fullName}${row.contact.title ? ` · ${row.contact.title}` : ''}` }))} />
</FieldGrid>
</Section>
</FormSection>
</SheetBody>
<SheetActions pending={save.isPending} disabled={!writable} onCancel={() => onOpenChange(false)} label="Log activity" />
</form>
@@ -988,12 +990,18 @@ function RecordSheet({ open, onOpenChange, category, title, description, childre
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden border-border p-0 sm:max-w-xl">
<SheetHeader className="shrink-0 gap-1 px-5 pb-4 pt-5 pr-14 text-left sm:px-6 sm:pr-14">
{/*
`band` is the primitive's standard top band — the overlay header
padding, the rule against the body and the top safe-area inset, which
this sheet was hand-rolling at its own values. `pr-14` stays: the
dismiss control is absolutely positioned over this row, and a long
title running under it is the one thing the band cannot know about.
*/}
<SheetHeader band className="pr-14">
<Badge className="mb-1 w-fit" tone="neutral">{category}</Badge>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<Separator />
{children}
</SheetContent>
</Sheet>
@@ -1009,8 +1017,8 @@ function SheetActions({ pending, onCancel, label: actionLabel, disabled = false
<>
<Separator />
<div className="flex shrink-0 flex-col-reverse gap-2 px-5 pb-[calc(1rem+var(--safe-bottom))] pt-4 sm:flex-row sm:justify-end sm:px-6">
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>Cancel</Button>
<Button type="submit" className="h-11" disabled={pending || disabled}>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={pending || disabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" aria-hidden /> : null}
{pending ? 'Saving…' : actionLabel}
</Button>
@@ -1027,7 +1035,7 @@ function SheetActions({ pending, onCancel, label: actionLabel, disabled = false
*/
function PermissionNotice({ children }: { children: React.ReactNode }) {
return (
<div role="status" className="flex gap-3 rounded-lg border border-border bg-surface-2 p-4 text-sm text-muted">
<div role="status" className="flex gap-3 rounded-md bg-surface-2 p-4 text-sm text-muted">
<Lock className="mt-0.5 size-4 shrink-0" aria-hidden />
<p>{children}</p>
</div>
@@ -1038,15 +1046,22 @@ function FieldGrid({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
/**
* A titled group of fields inside a record sheet.
*
* The heading, its size and its description are the primitive's now. This was
* a 14px/600 heading over a 12px `text-muted-foreground` note — a sixth
* section heading, and the only place in the product still reaching for
* shadcn's colour name — so a sheet's groups read a size smaller than the
* panels on the page behind it. The rule and the top inset stay here: they
* separate one group from the next, which is this sheet's layout, not the
* heading's job.
*/
function FormSection({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-6">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-semibold">{title}</h3>
{description ? <p className="text-xs leading-relaxed text-muted-foreground">{description}</p> : null}
</div>
{children}
</section>
<div className="border-t border-border pt-6">
<Section title={title} description={description}>{children}</Section>
</div>
);
}
@@ -1082,7 +1097,7 @@ function SelectField<T extends FieldValues>({ control, name, label: fieldLabel,
<FormItem className={className}>
<FormLabel>{fieldLabel}</FormLabel>
<Select value={String(field.value || (optional ? 'none' : ''))} onValueChange={(value) => field.onChange(value === 'none' ? '' : value)}>
<FormControl><SelectTrigger className="h-11"><SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} /></SelectTrigger></FormControl>
<FormControl><SelectTrigger><SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} /></SelectTrigger></FormControl>
<SelectContent><SelectGroup>
{optional ? <SelectItem value="none">None</SelectItem> : null}
{options.map((option) => <SelectItem key={option.value} value={option.value} disabled={option.disabled}>{option.label}</SelectItem>)}
@@ -1097,7 +1112,7 @@ function SelectField<T extends FieldValues>({ control, name, label: fieldLabel,
function SwitchField<T extends FieldValues>({ control, name, label: fieldLabel, description }: { control: Control<T>; name: FieldPath<T>; label: string; description?: string }) {
return (
<FormField control={control} name={name} render={({ field }) => (
<FormItem className="flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border p-3">
<FormItem className="flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border border-border p-3">
<div className="flex flex-col gap-1"><FormLabel>{fieldLabel}</FormLabel>{description ? <FormDescription>{description}</FormDescription> : null}</div>
<FormControl><Switch checked={Boolean(field.value)} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
+37 -4
View File
@@ -58,6 +58,19 @@ export function Shell() {
} as React.CSSProperties
}
>
{/*
First in the DOM, so the first Tab of a fresh page offers it. Without
it a keyboard reader crosses the header, the whole sidebar and the
Piggy dock — measured at over a hundred stops on /piggy — before
reaching the thing they came for.
*/}
<a
href="#page-content"
className="sr-only text-sm font-medium text-fg focus:not-sr-only focus:absolute focus:left-2 focus:top-[max(0.5rem,var(--safe-top))] focus:z-50 focus:flex focus:min-h-11 focus:items-center focus:rounded-lg focus:bg-surface focus:px-4 focus:shadow-lg focus:ring-2 focus:ring-brand"
>
Skip to content
</a>
<AppHeader />
<div className="flex w-full min-w-0 flex-1">
@@ -69,11 +82,16 @@ export function Shell() {
// their own `md:pb-0` on top of this; keeping `lg` here means they
// still have their padding between md and lg, where the tab bar is
// very much still on screen.
className="pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0"
className="pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0"
>
<div
id="page-content"
tabIndex={-1}
className={cn(
'mx-auto w-full min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-8',
// The one page canvas inset in the product: 16 / 24 / 32 across,
// 24 / 32 down. Pages set their own rhythm inside it and nothing
// else may set a page margin.
'mx-auto w-full min-w-0 px-4 py-6 focus-visible:outline-none sm:px-6 lg:px-8 lg:py-8',
// With Piggy docked the middle pane is already a column in a
// three-column layout; capping it at 7xl and centring it again
// strands the content between two gutters it does not need.
@@ -95,6 +113,20 @@ export function Shell() {
/**
* The phone tab bar. Unchanged in look and behaviour — it is the thing this
* product is best at and the rebuild had no business touching it.
*
* With one exception, and it was a real defect. `WorkspaceRoute` and
* `SidebarInset` both give the bar's 72px padding reserve back under
* `(max-height: 500px)` — a landscape phone, or a phone with the keyboard up,
* cannot spend a fifth of its screen on a bar it can reach by turning the
* handset back — and both files' comments said the bar itself stood down at the
* same height. It did not: it stayed `fixed` at the bottom with nothing holding
* content clear of it, so at 852x393 it painted over the Piggy composer's
* safety line and the lower 23px of the 44px Send button. Confirmed by
* hit-testing, not by a screenshot: `elementFromPoint` at the Send button's
* centre returned a tab-bar link.
*
* Standing down is safe because navigation does not go with it — `SidebarTrigger`
* is in the header on every viewport below `lg` and opens the same nav sheet.
*/
function MobileTabBar({ items }: { items: NavItem[] }) {
return (
@@ -102,6 +134,7 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
className={cn(
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
'supports-[backdrop-filter]:bg-surface/80',
'[@media(max-height:500px)]:hidden',
)}
style={{ paddingBottom: 'var(--safe-bottom)' }}
aria-label="Primary"
@@ -112,13 +145,13 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
key={item.to}
to={item.to}
end={item.to === '/'}
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-xs font-medium text-muted"
>
{({ isActive }) => (
<>
<span
className={cn(
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors duration-1 ease-enter',
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
)}
>
@@ -6,7 +6,7 @@
* here — so the UI's job is to be legible, and disabling the option would only
* hide a refusal the server is going to make anyway with a better message.
*/
import { useState, type ReactNode } from 'react';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -18,6 +18,7 @@ import {
} from '@pig/core';
import { api } from '@/lib/api';
import { Button, Input } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import {
Dialog,
DialogContent,
@@ -91,7 +92,7 @@ export function AddResourceDialog({
create.mutate();
}}
>
<Field label="Share link" htmlFor="learn-url">
<FormField label="Share link">
<Input
id="learn-url"
value={url}
@@ -100,24 +101,24 @@ export function AddResourceDialog({
autoComplete="off"
spellCheck={false}
/>
</Field>
<Field label="Title" htmlFor="learn-title">
</FormField>
<FormField label="Title">
<Input
id="learn-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</Field>
<Field label="Summary" htmlFor="learn-summary">
</FormField>
<FormField label="Summary">
<Input
id="learn-summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="What someone learns from it"
/>
</Field>
</FormField>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<Field label="Track" htmlFor="learn-track">
<FormField label="Track">
<NativeSelect
id="learn-track"
value={track}
@@ -127,8 +128,8 @@ export function AddResourceDialog({
label: LEARN_TRACK_LABELS[value],
}))}
/>
</Field>
<Field label="Visibility" htmlFor="learn-visibility">
</FormField>
<FormField label="Visibility">
<NativeSelect
id="learn-visibility"
value={visibility}
@@ -138,9 +139,9 @@ export function AddResourceDialog({
label: value === 'code' ? 'Anyone with the code' : 'Members only',
}))}
/>
</Field>
</FormField>
</div>
<Field label="Length in minutes" htmlFor="learn-minutes">
<FormField label="Length in minutes">
<Input
id="learn-minutes"
value={minutes}
@@ -148,7 +149,7 @@ export function AddResourceDialog({
inputMode="decimal"
placeholder="Optional"
/>
</Field>
</FormField>
<div className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
@@ -167,39 +168,22 @@ export function AddResourceDialog({
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
</div>
);
}
function NativeSelect({
id,
value,
onChange,
options,
...field
}: {
id: string;
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
id={id}
{...field}
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-11 w-full min-w-0 rounded-lg border border-border bg-surface px-3 text-base text-fg focus-visible:border-accent"
@@ -57,7 +57,7 @@ export function ArchiveControl({
aria-label={confirming ? `Confirm archiving ${title}` : `Archive ${title}`}
className={cn(
'tap inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium',
'bg-surface/90 backdrop-blur-sm transition-colors disabled:opacity-50',
'bg-surface/90 backdrop-blur-sm transition-colors duration-1 ease-enter disabled:opacity-50',
confirming ? 'text-danger hover:bg-danger/10' : 'text-muted hover:bg-surface-2 hover:text-fg',
className,
)}
@@ -137,7 +137,7 @@ export function LearnPoster({
className={cn(
'inline-flex items-center justify-center rounded-full border border-border',
'bg-surface/85 text-fg shadow-sm backdrop-blur-sm',
'transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105',
'transition-transform duration-1 ease-enter group-hover:scale-105 group-focus-visible:scale-105',
compact ? 'size-9' : 'size-14',
)}
aria-hidden
@@ -41,7 +41,7 @@ export function LearnTrackPanel({
return (
<Card className="flex min-w-0 flex-col gap-5 p-5 sm:p-6">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
<h2 className="text-base font-semibold leading-tight">{heading}</h2>
<p className="min-w-0 max-w-2xl text-sm leading-6 text-muted">{description}</p>
</div>
@@ -24,7 +24,7 @@ export function LearnVideoCard({
}) {
const playback = learnPlayback(resource);
return (
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md">
<button
type="button"
onClick={() => onPlay(resource)}
@@ -9,7 +9,7 @@
*/
import { ChevronRight } from 'lucide-react';
import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui';
import { Badge, Card, Label } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl';
import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster';
@@ -35,7 +35,7 @@ export function LearnWalkthroughList({
const playback = learnPlayback(resource);
return (
<li key={resource.id} className="min-w-0">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md sm:flex-row">
<button
type="button"
onClick={() => onPlay(resource)}
@@ -54,9 +54,7 @@ export function LearnWalkthroughList({
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="nums text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-accent-fg">
Step {index + 1}
</p>
<Label className="nums">Step {index + 1}</Label>
<h3 className="min-w-0 break-words font-semibold leading-snug">
{resource.title}
</h3>
+257 -182
View File
@@ -24,9 +24,11 @@
import { useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { AlertTriangle, ChevronDown } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { compactNumber, get, relativeTime } from '@/lib/api';
import { Badge, Button, Skeleton, cn } from '@/components/ui';
import { Button, EmptyState, Section, Skeleton, Stat, cn } from '@/components/ui';
import { RunStatusBadge, TaskStateBadge } from '@/components/status';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
// -------------------------------------------------------------- the wire
@@ -96,13 +98,21 @@ const EXACT = new Intl.NumberFormat('en-US');
* A month of Piggy costs about a penny and a single turn costs three
* ten-thousandths of one, so a fixed two decimal places would render the entire
* panel as `$0.00` and quietly answer "what is the credit doing?" with
* "nothing". Precision widens as the number shrinks, and never past six places,
* where the underlying figure stops being meaningful anyway.
* "nothing". Precision instead widens as the number shrinks, to **two
* significant figures** and never past six decimal places.
*
* Two figures, not the six the panel used to print: `$0.000483` claims a
* precision the reader cannot use and cannot check, and a column of them reads
* as noise rather than as money. Anyone who does want the exact number has it —
* every figure here carries its raw micro-cent value in a title attribute, and
* that is the audit trail, not the rendering.
*/
function decimalsFor(dollars: number): number {
const size = Math.abs(dollars);
if (size === 0 || size >= 1) return 2;
return size >= 0.01 ? 4 : 6;
// floor(log10) is the position of the leading digit; one place past it is the
// second significant figure.
return Math.min(6, 1 - Math.floor(Math.log10(size)));
}
/*
@@ -139,21 +149,66 @@ function sharedDecimals(...microCents: number[]): number {
}
/**
* A provider's error, as a sentence rather than as its wire format.
* What went wrong, in the words the person at the keyboard already heard.
*
* `agent_runs.error` is deliberately the raw upstream reason — the chat stream
* sanitises what the browser is told and the ledger keeps the truth, which is
* the right division. But this panel then rendered that truth verbatim, so a
* rate limit arrived in the product as
* `429: {"message":"Rate limit reached. Please retry shortly.","type":…,"code":…}`,
* a JSON document from a third party sitting in PIG's own interface. The
* message is pulled out where the body is JSON and the status is kept, because
* "429" is the part an operator acts on; the whole of it stays one hover away.
* `agent_runs.error` is deliberately the raw reason — the chat stream sanitises
* what the browser is told and the ledger keeps the truth, which is the right
* division. But this panel rendered that truth verbatim, so PIG's own audit
* surface showed a turn stopping as
* `turn stopped by the model_calls ceiling: 8 model calls, 5457 tokens, ceiling 8`
* and a rate limit as a JSON document from a third party. Both had already been
* explained to the same reader, in English, in the transcript a moment earlier.
*
* So the prefixes the relay writes are mapped back to the sentences the relay
* emits (`chat-server.ts` → `reportBreach` / `reportInferenceFailure`). Prose is
* left alone — an error that is already a sentence is somebody's considered
* wording and this table is not an improvement on it. The raw string is always
* one hover away in `title`, which is what makes the mapping safe.
*/
const ERROR_SENTENCES: readonly {
match: RegExp;
say: (groups: RegExpExecArray) => string;
}[] = [
{
match: /^turn stopped by the model_calls ceiling: (\d+) model calls/,
say: ([, calls]) =>
`Piggy stopped after ${calls} step${calls === '1' ? '' : 's'}, which is the most one ` +
'question may take, so this answer is incomplete.',
},
{
match: /^turn stopped by the tokens ceiling: \d+ model calls, (\d+) tokens/,
say: ([, tokens]) =>
`Piggy reached the size limit for a single question (${Number(tokens).toLocaleString(
'en-GB',
)} tokens), so this answer is incomplete.`,
},
{
// The AbortError, which is what Stop and a closed tab both leave behind.
// The badge beside it already says "Stopped by you"; this says what it cost.
match: /^This operation was aborted/,
say: () => 'Stopped before Piggy finished the answer.',
},
];
/** The shapes a rate limit arrives in. Matches `isRateLimited` in the relay. */
const RATE_LIMITED = /\b429\b|rate.?limit|rate_limited|too many requests|resourceexhausted/i;
function readableError(error: string): string {
const match = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(error.trim());
if (!match) return error;
const [, status, payload] = match;
const trimmed = error.trim();
for (const { match, say } of ERROR_SENTENCES) {
const found = match.exec(trimmed);
if (found) return say(found);
}
// `429: {"message":…}` and friends: a status code and a provider's JSON body,
// which is the one shape that is never anybody's considered wording.
const wire = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(trimmed);
if (!wire) return error;
if (RATE_LIMITED.test(trimmed)) {
return 'The inference endpoint was rate limiting us, so this turn was turned away. Waiting a few seconds and asking again usually clears it.';
}
const [, status, payload] = wire;
try {
const body = JSON.parse(payload!) as { message?: unknown; error?: unknown };
const message =
@@ -162,6 +217,7 @@ function readableError(error: string): string {
: typeof body.error === 'string'
? body.error
: null;
// The status is kept: "500" is the part an operator acts on.
return message ? `${status}: ${message}` : error;
} catch {
// Not JSON after all. Showing it unchanged beats showing nothing.
@@ -195,78 +251,18 @@ function humanise(value: string): string {
return value.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
}
type Tone = 'neutral' | 'accent' | 'positive' | 'warning' | 'danger' | 'info';
/**
* A run's status is free text from the ledger, so an unrecognised value is
* shown as it is in a neutral badge rather than being forced into one of the
* four we know. A status this panel has never heard of is information.
*/
const RUN_TONES: Record<string, Tone> = {
running: 'info',
// Deliberately not `positive`. Almost every row succeeds, and a column of
// green makes the one aborted turn no easier to find than the twenty that
// were fine — which is the only reason anybody scans this list.
succeeded: 'neutral',
aborted: 'warning',
failed: 'danger',
};
const TASK_TONES: Record<PiggyTaskSummary['state'], Tone> = {
running: 'info',
queued: 'accent',
scheduled: 'neutral',
// Same reasoning as RUN_TONES: colour is for what needs a person.
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
// ------------------------------------------------------------- primitives
function Section({
title,
count,
children,
}: {
title: string;
count?: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="card min-w-0">
<h3>
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className={cn(
'flex min-h-[44px] w-full items-center gap-2 rounded-lg px-4 py-2 text-left',
'text-xs font-semibold uppercase tracking-wide text-muted',
'transition-colors hover:bg-surface-2',
)}
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', open ? '' : '-rotate-90')}
aria-hidden
/>
<span className="flex-1">{title}</span>
{count == null ? null : <span className="nums text-muted">{count}</span>}
</button>
</h3>
{open ? <div className="px-4 pb-4">{children}</div> : null}
</section>
);
}
function Empty({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-xs leading-relaxed text-muted">
{children}
</p>
);
/**
* The copy module owns the sentence; an empty state needs it as a heading and a
* body. Split once here rather than restated, so the panel and Piggy's front
* door cannot end up describing the ledger in two different ways.
*/
function firstSentence(line: string): { title: string; description?: string } {
const at = line.indexOf('. ');
return at === -1
? { title: line }
: { title: line.slice(0, at), description: line.slice(at + 2) };
}
/**
@@ -280,7 +276,7 @@ function Meta({ parts }: { parts: (ReactNode | null)[] }) {
const kept = parts.filter((part): part is ReactNode => part != null && part !== '');
if (kept.length === 0) return null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{kept.map((part, index) => (
<span key={index}>{part}</span>
))}
@@ -288,18 +284,58 @@ function Meta({ parts }: { parts: (ReactNode | null)[] }) {
);
}
/**
* A run's reason, tinted by whether anybody has to do something about it.
*
* `danger` is reserved for a failure a person must resolve. A turn you stopped
* yourself, or one that ran into its own step ceiling, is a fact about the turn
* — so it reads as an inset note rather than as an alarm. A column in which
* every ended turn is red is a column nobody reads.
*/
function RunReason({ status, error }: { status: string; error: string }) {
const alarming = status === 'failed';
return (
<p
className={cn(
'mt-1.5 flex items-start gap-1.5 break-words rounded-md px-2 py-1.5 text-xs leading-relaxed',
alarming ? 'bg-danger/10 text-danger' : 'bg-surface-2 text-muted',
)}
// The whole of it, for an operator who needs the provider's own words.
title={error}
>
{alarming ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden /> : null}
<span className="min-w-0">{readableError(error)}</span>
</p>
);
}
// ------------------------------------------------------------------- rows
/**
* Is the thread's name just the run's label again?
*
* A conversation is titled from its opening question, so on the turn that
* started it the two strings are the same one and the row printed it twice —
* once as the heading and once, clipped, at the end of the meta line. Compared
* by prefix, with the server's own truncation mark stripped, because a title
* cut at 120 characters ends in an ellipsis the label it was cut from does not.
*/
function sameWords(label: string, title: string): boolean {
const trim = (value: string) => value.trim().toLowerCase().replace(/(\.\.\.|…)$/, '');
const one = trim(label);
const other = trim(title);
return one.startsWith(other) || other.startsWith(one);
}
function RunRow({ run }: { run: PiggyRunSummary }) {
const tone = RUN_TONES[run.status] ?? 'neutral';
const duration = formatDuration(run.durationMs);
const tokens =
run.inputTokens == null && run.outputTokens == null
? null
: `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;
return (
<li className="border-t border-border py-3 first:border-t-0 first:pt-1">
const body = (
<>
<div className="flex items-start justify-between gap-2">
{/* Clamped rather than truncated to one line: two lines is enough to
tell two similar questions apart, and a turn's whole prompt can be a
@@ -311,9 +347,7 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
>
{run.label}
</p>
<Badge tone={tone} className="shrink-0 capitalize">
{run.status}
</Badge>
<RunStatusBadge status={run.status} className="shrink-0" />
</div>
{run.summary ? (
@@ -325,16 +359,7 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
</p>
) : null}
{run.error ? (
<p
className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger"
// The whole of it, for an operator who needs the provider's own words.
title={run.error}
>
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{readableError(run.error)}</span>
</p>
) : null}
{run.error ? <RunReason status={run.status} error={run.error} /> : null}
<Meta
parts={[
@@ -350,28 +375,52 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
run.kind === 'task' && run.taskKind ? humanise(run.taskKind) : null,
// Present only when the run was somebody else's — see the server type.
run.principal ? run.principal.name : null,
/*
* Only the caller's own conversations resolve to a link — the server
* refuses to name anybody else's — so an admin reading the workspace
* ledger sees the run without a doorway into a private transcript.
*/
run.conversation ? (
<Link
run.conversation && !sameWords(run.label, run.conversation.title) ? (
<span
key="conversation"
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
// `inline-block` is load-bearing: `max-width` and `overflow` do
// nothing on a non-replaced inline box, so the truncation here was
// inert and a run whose title is a question with a UUID in it
// rendered 624px wide inside a 320px rail — clipped mid-word by
// the column rather than ellipsised.
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 hover:underline"
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 group-hover/run:underline"
title={run.conversation.title}
>
{run.conversation.title}
</Link>
</span>
) : null,
]}
/>
</>
);
/*
* Only the caller's own conversations resolve to a link — the server refuses
* to name anybody else's — so an admin reading the workspace ledger sees the
* run without a doorway into a private transcript.
*
* The whole row is the target, not the thread name at the end of the meta
* line. That name was a 14px-tall link at the bottom of a hundred-pixel row,
* which is a 44px rule broken by the one control in this panel that goes
* anywhere; and the row already reads as a unit, so the visible affordance
* was in the wrong place as well as the wrong size.
*/
return (
<li className="border-t border-border first:border-t-0">
{run.conversation ? (
<Link
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
aria-label={`Open the conversation “${run.conversation.title}`}
className={cn(
'group/run -mx-2 block min-h-11 rounded-md px-2 py-3',
'transition-colors duration-1 ease-enter hover:bg-surface-2',
)}
>
{body}
</Link>
) : (
<div className="py-3">{body}</div>
)}
</li>
);
}
@@ -379,14 +428,12 @@ function RunRow({ run }: { run: PiggyRunSummary }) {
function TaskRow({ task }: { task: PiggyTaskSummary }) {
const outstanding = task.state === 'queued' || task.state === 'scheduled' || task.state === 'running';
return (
<li className="border-t border-border py-3 first:border-t-0 first:pt-1">
<li className="border-t border-border py-3 first:border-t-0">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
{humanise(task.kind)}
</p>
<Badge tone={TASK_TONES[task.state]} className="shrink-0 capitalize">
{task.state}
</Badge>
<TaskStateBadge state={task.state} className="shrink-0" />
</div>
{task.reason ? (
@@ -398,12 +445,7 @@ function TaskRow({ task }: { task: PiggyTaskSummary }) {
</p>
) : null}
{task.error ? (
<p className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{task.error}</span>
</p>
) : null}
{task.error ? <RunReason status="failed" error={task.error} /> : null}
<Meta
parts={[
@@ -470,15 +512,20 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
? sharedDecimals(spend.todayMicroCents, spend.monthMicroCents)
: 2;
const runsEmpty = firstSentence(piggyLine(piggyCopy.activityEmpty));
/*
* Not a landmark. The workspace already wraps this in an `<aside>` called
* "Piggy activity" on a wide screen, and in a Sheet titled "Activity" on a
* phone, so a complementary landmark of the same name nested inside it gave a
* screen-reader user two doors into one panel.
*/
return (
<aside
aria-label="Piggy activity"
className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}
>
<div className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}>
{/* First, not last: a ledger that could not be read must say so before it
shows anything that looks like a figure. */}
{activity.isError ? (
<div className="card min-w-0 p-4">
<div className="card order-1 min-w-0 p-4 sm:p-5">
<p className="flex items-start gap-2 text-sm text-danger">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span className="min-w-0 break-words">
@@ -498,56 +545,46 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
</div>
) : null}
<div className="card min-w-0 p-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Spend</h2>
<span className="text-[11px] text-muted">US dollars</span>
</div>
{/*
Spend sits under the runs on a narrow screen. What people open this
panel for is what Piggy just did; the month's spend is a figure they
check occasionally and it was pushing the first run below the fold of a
393px sheet. On a wide screen the rail is tall enough for both, and the
figure reads better at the top of the column.
*/}
<Section
title="Spend"
level={3}
action={<span className="text-xs text-muted">US dollars</span>}
className="card order-3 p-4 sm:order-2 sm:p-5"
>
{/*
Nothing here falls back to zero. A figure the panel could not read is
an em dash, never `$0.00`: on a spend surface those two are opposite
claims, and only one of them is true.
*/}
{spend ? (
<div className="mt-2 grid grid-cols-2 gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">Today</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.todayMicroCents)}
>
{spendMoney(spend.todayMicroCents, spendDigits)}
</div>
</div>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">This month</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.monthMicroCents)}
>
{spendMoney(spend.monthMicroCents, spendDigits)}
</div>
</div>
</div>
) : activity.isError ? (
<div className="mt-2 grid grid-cols-2 gap-3">
{['Today', 'This month'].map((label) => (
<div key={label} className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">{label}</div>
<div className="text-lg font-semibold leading-tight text-muted"></div>
</div>
))}
{spend || activity.isError ? (
<div className="grid grid-cols-2 gap-3">
<SpendFigure
label="Today"
microCents={spend ? spend.todayMicroCents : null}
digits={spendDigits}
/>
<SpendFigure
label="This month"
microCents={spend ? spend.monthMicroCents : null}
digits={spendDigits}
/>
</div>
) : (
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)}
{spend ? (
<p className="mt-2 text-[11px] leading-relaxed text-muted">
<p className="mt-2 text-xs leading-relaxed text-muted">
{spend.turns > 0 ? (
<>
<span className="nums">{EXACT.format(spend.turns)}</span> turns this month,
@@ -562,9 +599,15 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
)}
</p>
) : null}
</div>
</Section>
<Section title="Recent runs" count={activity.data ? runs.length : undefined}>
<Section
title="Recent runs"
level={3}
count={activity.data ? runs.length : undefined}
collapsible
className="card order-2 p-4 sm:order-3 sm:p-5"
>
{/*
`activity.data`, not `isPending`: after a failed read the query is
neither pending nor holding rows, and keying the empty state off
@@ -573,19 +616,16 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
*/}
{!activity.data ? (
activity.isError ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : runs.length === 0 ? (
<Empty>
Nothing has run yet. Ask Piggy a question and the turn appears here with its model,
its tokens and what it cost.
</Empty>
<EmptyState size="inline" title={runsEmpty.title} description={runsEmpty.description} />
) : (
<>
<ul className="flex flex-col">
@@ -607,21 +647,28 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
)}
</Section>
<Section title="Queue" count={activity.data ? tasks.length : undefined}>
<Section
title="Queue"
level={3}
count={activity.data ? tasks.length : undefined}
collapsible
className="card order-4 p-4 sm:p-5"
>
{!activity.data ? (
activity.isError ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
) : (
<div className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : tasks.length === 0 ? (
<Empty>
No background work is queued. Enrichment, renewal watches and supplier research are
written here as tasks before Piggy runs them, and stay with their result afterwards.
</Empty>
<EmptyState
size="inline"
title="No background work is queued"
description="Enrichment, renewal watches and supplier research are written here as tasks before Piggy runs them, and stay with their result afterwards."
/>
) : (
<ul className="flex flex-col">
{tasks.map((task) => (
@@ -630,6 +677,34 @@ export function PiggyActivityPanel({ className }: { className?: string }) {
</ul>
)}
</Section>
</aside>
</div>
);
}
/**
* One spend figure, with its exact value where a doubter can find it.
*
* The `title` carries the raw micro-cent integer, which is the audit trail: the
* rendered figure is a rounding of a number stored in millionths of a cent, and
* a money surface that cannot be checked against its own source is a claim
* rather than a record.
*/
function SpendFigure({
label,
microCents,
digits,
}: {
label: string;
microCents: number | null;
digits: number;
}) {
const text = spendMoney(microCents, digits);
return (
<Stat
size="md"
surface="bare"
label={label}
value={microCents == null ? text : <span title={spendTitle(microCents)}>{text}</span>}
/>
);
}
+191 -220
View File
@@ -17,85 +17,25 @@
* The five states come from `ApprovalStep` in lib/piggy-chat, which owns the
* transitions. This file renders them and reports a decision; it decides nothing
* about the change itself.
*
* Colour follows the product's rule rather than this card's own instincts: the
* only state drawn in a status colour is the one that has stopped and is waiting
* for a person. A settled card — applied, rejected — returns to the ordinary
* border, and the confirmation keeps exactly one positive mark. A transcript in
* which every approved write is a green block is a transcript where the one card
* that still needs answering is invisible.
*/
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ChevronRight,
Loader2,
ShieldAlert,
TriangleAlert,
XCircle,
} from 'lucide-react';
import { ArrowRight, CheckCircle2, Loader2, ShieldAlert, TriangleAlert, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
import { Badge, Button, Card, cn } from '@/components/ui';
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
import { ApprovalStateBadge } from '@/components/status';
import { RecordLink, recordHref } from '@/components/RecordLink';
import { Button, Card, Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
export type PiggyApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
// ------------------------------------------------------------------ routing
/**
* Where a record of each kind can be opened.
*
* `/accounts/:id` is the only per-record route PIG has, so an account link opens
* the record and everything else lands on the list that contains it — which at
* least puts the reader in front of the row they just changed. When the other
* detail routes land, each of these becomes a one-line edit; `recordHref` is the
* only place a record id becomes a URL.
*/
const RECORD_ROUTES: Record<string, string> = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
task: '/calendar',
};
function recordHref(record: NonNullable<PiggyProposedChange['record']>): string | null {
const base = RECORD_ROUTES[record.type];
if (!base) return null;
return record.type === 'account' ? `${base}/${record.id}` : base;
}
/** Whether the link opens the record itself or merely the list holding it. */
function opensRecord(type: string): boolean {
return type === 'account';
}
// ------------------------------------------------------------------- naming
/**
* Named for the reader, not for the model.
*
* The generic fallback turns `pig_update_record_fields` into "Update record
* fields", which is close enough that only the tools whose identifiers read
* badly need an entry. The caption exists so two cards proposing different
* writes on the same record are told apart at a glance.
*/
const TOOL_LABELS: Record<string, string> = {
pig_log_activity: 'Log activity',
pig_create_contact: 'Create contact',
pig_update_deal_stage: 'Update deal stage',
pig_update_record_fields: 'Update record fields',
pig_create_task: 'Create task',
};
function toolLabel(tool: string): string {
return (
TOOL_LABELS[tool] ??
tool
.replace(/^pig_/, '')
.replaceAll('_', ' ')
.replace(/^\w/, (letter) => letter.toUpperCase())
);
}
// --------------------------------------------------------------------- card
export function PiggyApprovalCard({
@@ -147,11 +87,29 @@ export function PiggyApprovalCard({
dispatched.current = false;
});
/**
* Where the keyboard goes when the buttons stop existing.
*
* Applying removes the pair the user just pressed, and focus falls to
* `<body>` — 121 Tab presses from the transcript on /piggy, and the outcome
* of the write announced to nobody in particular. Focus moves here instead,
* onto the live region that says what happened, so the answer to "did it
* land?" is both spoken and one Tab from the record link that proves it.
*/
const statusRef = useRef<HTMLDivElement>(null);
const decided = useRef(false);
useEffect(() => {
if (!decided.current || state === 'pending') return;
decided.current = false;
statusRef.current?.focus();
}, [state]);
const answerable = state === 'pending' || state === 'failed';
const decide = (decision: PiggyApprovalDecision) => {
if (!answerable || dispatched.current) return;
dispatched.current = true;
decided.current = true;
setChoice(decision);
onDecide(decision);
};
@@ -167,11 +125,16 @@ export function PiggyApprovalCard({
if (event.repeat) event.preventDefault();
};
const href = change.record ? recordHref(change.record) : null;
const recordLabel = change.record?.label ?? change.record?.id ?? '';
const record = change.record;
const linkable = Boolean(record && recordHref(record.type, record.id));
// The note explains why a card appeared at all, so it retires once the change
// is settled and the question is no longer live.
const showForcedNote = Boolean(change.forcedConfirm) && state !== 'applied' && state !== 'rejected';
const showActions = state === 'pending' || state === 'submitting' || state === 'failed';
// An applied card carries its record inside the confirmation sentence, so the
// footer link would be the same destination twice in two consecutive rows.
const showFooterLink = linkable && state !== 'applied';
const hasFooterRow = showActions || showFooterLink;
return (
<Card
@@ -181,28 +144,28 @@ export function PiggyApprovalCard({
aria-labelledby={headingId}
className={cn(
'w-full overflow-hidden',
// Warning is spent on the one state that has stopped and is waiting for
// a person; `submitting` keeps it because the question is still open
// until the relay answers, and a border that changes twice in a second
// reads as a flicker rather than as progress.
state === 'pending' || state === 'submitting'
? 'border-warning/50'
: state === 'applied'
? 'border-positive/40'
: state === 'failed'
? 'border-danger/50'
: 'border-border bg-surface-2/40',
: state === 'rejected'
? 'border-border bg-surface-2/40'
: 'border-border',
)}
>
<div className="flex items-start gap-2.5 p-3 sm:p-4">
<div className="flex items-start gap-2 p-4 sm:p-5">
<StateIcon state={state} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
{toolLabel(change.tool)}
</p>
<Label>{piggyToolLabel(change.tool)}</Label>
{/* The summary is the headline: everything below it is evidence for
this one sentence, so it is the only thing set at full weight. */}
<h4 id={headingId} className="mt-0.5 break-words text-sm font-semibold leading-snug">
<h4 id={headingId} className="mt-1 break-words text-sm font-semibold leading-snug">
{change.summary}
</h4>
</div>
<StateBadge state={state} choice={choice} />
<ApprovalStateBadge state={state} decision={choice} />
</div>
{showForcedNote ? <ForcedConfirmNote kind={change.kind} /> : null}
@@ -215,41 +178,53 @@ export function PiggyApprovalCard({
only record of what was declined, which is exactly what an audit asks
for.
*/
<details className="group border-t border-border">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-1 px-3 text-xs text-muted hover:text-fg sm:px-4 [&::-webkit-details-marker]:hidden">
<ChevronRight
className="size-3.5 transition-transform group-open:rotate-90"
aria-hidden
/>
What was proposed
</summary>
<Disclosure
summary="What was proposed"
className="border-t border-border"
summaryClassName="px-4 text-xs font-normal text-muted sm:px-5"
>
<FieldList fields={change.fields} settled />
</details>
</Disclosure>
) : (
<div className="border-t border-border">
<FieldList fields={change.fields} settled={false} />
</div>
)}
<div className="flex flex-col border-t border-border p-3 sm:p-4">
<div className="flex flex-col border-t border-border p-4 sm:p-5">
{/*
One live region, mounted for the life of the card. A status element
that appears at the same moment as its text is announced unreliably,
and this is exactly the transition — pending to applied — that a
screen-reader user must not miss. Empty while the card is waiting,
which is why the spacing hangs off the child rather than off a `gap`:
an empty region must not leave a hole above the buttons.
*/}
<div role="status" aria-live="polite" className="[&>*]:mb-3">
<StatusLine state={state} choice={choice} record={change.record} />
</div>
One live region, mounted for the life of the card and never empty: a
status element that appears at the same moment as its text is
announced unreliably, and this is exactly the transition — pending to
applied — that a screen-reader user must not miss. The spacing hangs
off the child rather than off a `gap` so that a settled card, whose
footer holds nothing else, does not end in a band of dead space.
{error ? (
<p className="mb-3 flex items-start gap-2 rounded-lg bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{error}</span>
</p>
) : null}
`tabIndex={-1}` is the target of the focus move above; it is never in
the tab order.
*/}
<div
ref={statusRef}
tabIndex={-1}
role="status"
aria-live="polite"
className={cn('min-w-0 outline-none', hasFooterRow && '[&>*]:mb-3')}
>
{/*
An error is the status. Left to the generic line as well, a failed
card stated the same fact three times — the badge, "The change was
not applied", and the reason — and a card that repeats itself reads
as a card that is guessing.
*/}
{error ? (
<p className="flex items-start gap-2 rounded-md bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{error}</span>
</p>
) : (
<StatusLine state={state} choice={choice} record={record} />
)}
</div>
{/*
The record and the decision share a row: the link is the one thing a
@@ -258,85 +233,75 @@ export function PiggyApprovalCard({
the footer to a single line on a phone. It wraps above them when the
dock is too narrow for both.
*/}
<div className="flex flex-wrap items-center justify-end gap-2">
{href ? (
<Link
to={href}
// `mr-auto` rather than `justify-between` on the row: when the pair
// of buttons wraps to its own line in a narrow dock, the row must
// still hold them at the right edge, and `between` would strand a
// lone wrapped item at the left.
className="mr-auto inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-md text-xs text-muted underline-offset-4 hover:text-fg hover:underline focus-visible:text-fg focus-visible:ring-brand"
title={
opensRecord(change.record?.type ?? '')
? `Open ${recordLabel}`
: `Open the list containing ${recordLabel}`
}
>
<span className="truncate">
{state === 'applied' ? 'Open ' : 'Check '}
{recordLabel || 'the record'}
</span>
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
</Link>
) : null}
{hasFooterRow ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{showFooterLink && record ? (
<RecordLink
type={record.type}
id={record.id}
label={record.label ?? record.id}
verb={answerable || state === 'submitting' ? 'Check' : 'Open'}
// A new tab, always, on this card. The escape hatch was
// destroying the proposal it exists to help verify: a same-tab
// navigation unmounts the transcript and takes the pending card
// with it, so the reader came back to no question at all.
newTab
// `mr-auto` rather than `justify-between` on the row: when the
// pair of buttons wraps to its own line in a narrow dock, the
// row must still hold them at the right edge, and `between`
// would strand a lone wrapped item at the left.
className="mr-auto"
/>
) : null}
{state === 'pending' || state === 'submitting' || state === 'failed' ? (
/*
The decision sits last, after the evidence, and never under the
reader's eye while they are still reading the diff. Reject comes
first so the hand travelling rightwards ends on the deliberate
action rather than passing over it, and Apply carries the only
filled treatment on the card. Nothing is autofocused: the card
arrives mid-stream, and a button that grabs focus while someone is
typing turns their next Enter into a write.
*/
/*
Full width below `sm` so the two buttons split a phone row evenly,
content width above it so they sit as a pair at the right of the
footer — and so that in a 22rem dock the pair wraps to its own line
intact rather than stacking one button above the other.
*/
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button
type="button"
variant="outline"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('reject')}
/*
The app's global focus ring is `ring-accent`, which is the
*subtle* accent — on a white card it is very nearly invisible.
Everywhere else that is a cosmetic loss; here it would leave a
keyboard user unable to see which of Apply and Reject they are
about to press, so both buttons ask for the full-strength
accent instead.
*/
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'reject' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Reject
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('apply')}
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'apply' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{state === 'failed' ? 'Try again' : 'Apply'}
</Button>
</div>
) : null}
</div>
{showActions ? (
/*
The decision sits last, after the evidence, and never under the
reader's eye while they are still reading the diff. Reject comes
first so the hand travelling rightwards ends on the deliberate
action rather than passing over it, and Apply carries the only
filled treatment on the card. Nothing is autofocused: the card
arrives mid-stream, and a button that grabs focus while someone is
typing turns their next Enter into a write.
Full width below `sm` so the two buttons split a phone row evenly,
content width above it so they sit as a pair at the right of the
footer — and so that in a 22rem dock the pair wraps to its own line
intact rather than stacking one button above the other.
*/
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button
type="button"
variant="outline"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('reject')}
className="min-w-[6rem] flex-1 sm:flex-none"
>
{state === 'submitting' && choice === 'reject' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Reject
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('apply')}
className="min-w-[6rem] flex-1 sm:flex-none"
>
{state === 'submitting' && choice === 'apply' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{state === 'failed' ? 'Try again' : 'Apply'}
</Button>
</div>
) : null}
</div>
) : null}
</div>
</Card>
);
@@ -344,10 +309,17 @@ export function PiggyApprovalCard({
// ------------------------------------------------------------------- pieces
/**
* The card's mark, coloured only while the card is waiting.
*
* A settled outcome is a process fact, and process facts get no colour here —
* the single positive mark this card is allowed to spend belongs on the
* confirmation sentence, next to the record the change landed on.
*/
function StateIcon({ state }: { state: PiggyApprovalState }) {
const className = 'mt-0.5 size-4 shrink-0';
if (state === 'applied') {
return <CheckCircle2 className={cn(className, 'text-positive')} aria-hidden />;
return <CheckCircle2 className={cn(className, 'text-muted')} aria-hidden />;
}
if (state === 'rejected') return <XCircle className={cn(className, 'text-muted')} aria-hidden />;
if (state === 'failed') {
@@ -356,26 +328,6 @@ function StateIcon({ state }: { state: PiggyApprovalState }) {
return <ShieldAlert className={cn(className, 'text-warning')} aria-hidden />;
}
function StateBadge({
state,
choice,
}: {
state: PiggyApprovalState;
choice: PiggyApprovalDecision | null;
}) {
if (state === 'applied') return <Badge tone="positive">Applied</Badge>;
if (state === 'rejected') return <Badge tone="neutral">Rejected</Badge>;
if (state === 'failed') return <Badge tone="danger">Not applied</Badge>;
if (state === 'submitting') {
return <Badge tone="neutral">{choice === 'reject' ? 'Rejecting' : 'Applying'}</Badge>;
}
return (
<Badge tone="warning" className="shrink-0">
Needs you
</Badge>
);
}
/**
* Why a card appeared in a mode that promised not to ask.
*
@@ -386,7 +338,10 @@ function StateBadge({
*/
function ForcedConfirmNote({ kind }: { kind: string }) {
return (
<p className="mx-3 flex items-start gap-2 rounded-lg bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-4">
// The bottom margin matches the header's own padding above it: without it
// the note sat flush against the divider under it and read as part of the
// diff rather than as a note about why the card exists.
<p className="mx-4 mb-4 flex items-start gap-2 rounded-md bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-5 sm:mb-5">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">
Auto mode stopped here on purpose. A {kindNoun(kind)} change always needs a person, whatever
@@ -409,7 +364,13 @@ function StatusLine({
choice: PiggyApprovalDecision | null;
record?: PiggyProposedChange['record'];
}) {
if (state === 'pending') return null;
// Pending says the thing the reader most needs to be sure of, and says it in
// the live region so that the card's arrival is heard rather than merely
// drawn. The badge says a person is needed; this says what has happened so
// far, which is nothing.
if (state === 'pending') {
return <p className="text-xs text-muted">Nothing has changed yet. Piggy is waiting for your answer.</p>;
}
if (state === 'submitting') {
return (
@@ -421,9 +382,19 @@ function StatusLine({
}
if (state === 'applied') {
// The one positive mark on the card, spent here rather than on the border
// or the badge, and spent beside the record so the confirmation and the
// proof of it are the same sentence.
return (
<p className="text-xs text-positive">
Applied to PIG{record?.label ? ` on ${record.label}` : ''}.
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-fg">
<CheckCircle2 className="size-3.5 shrink-0 text-positive" aria-hidden />
{/* The trailing space is for the announcement, not the layout: the flex
gap separates the words on screen, and without it a screen reader
reads "Applied to PIG onDEMO — Northwind Robotics". */}
<span>Applied to PIG{record?.label ? ' on ' : '.'}</span>
{record ? (
<RecordLink type={record.type} id={record.id} label={record.label ?? record.id} newTab />
) : null}
</p>
);
}
@@ -433,8 +404,8 @@ function StatusLine({
}
// `failed` covers both a write PIG refused and a turn that ended before the
// decision could be delivered. The reason under this line tells them apart;
// what both have in common is the only thing worth stating up front.
// decision could be delivered. Reached only when no reason came with it — the
// reason replaces this line when there is one.
return <p className="text-xs text-danger">The change was not applied.</p>;
}
@@ -446,7 +417,7 @@ function FieldList({
settled: boolean;
}) {
return (
<dl className="flex flex-col gap-2.5 px-3 pb-3 pt-3 sm:px-4">
<dl className="flex flex-col gap-2 p-4 sm:p-5">
{fields.map((field, index) => (
// Keyed by position as well as label: nothing stops a tool proposing two
// rows with the same label, and a duplicate key drops one of them.
@@ -474,8 +445,8 @@ function FieldRow({
}) {
return (
<div className="min-w-0">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{field.label}</dt>
<dd className="mt-0.5 min-w-0">
<Label as="dt">{field.label}</Label>
<dd className="mt-1 min-w-0">
{field.previous === undefined ? (
<span
className={cn('block break-words text-sm leading-5', settled ? 'text-muted' : 'text-fg')}
@@ -1,9 +1,13 @@
/**
* Piggy's history rail: every conversation this person has had, newest first.
*
* Three decisions here are worth stating, because each replaces something more
* Four decisions here are worth stating, because each replaces something more
* obvious that would have been wrong.
*
* **A thread with nothing in it is not shown.** See `conversations` below: a
* row exists from the moment New is pressed, so the rail was mostly abandoned
* drafts sharing one derived title.
*
* **Recency buckets, not a flat list.** History is scanned, not read — the
* question is "where was that thing I asked on Tuesday", and a wall of relative
* timestamps answers it one row at a time. Today / Yesterday / This week /
@@ -20,7 +24,8 @@
* **No `window.confirm` for the delete.** It blocks the event loop, so an
* answer still streaming into another conversation stalls behind a modal the
* browser drew, and it cannot name the thread being destroyed in a way anyone
* would read. The Dialog primitive does both.
* would read. `AlertDialog` does both, and refuses to be dismissed by a click
* landing somewhere else.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -37,15 +42,17 @@ import { toast } from 'sonner';
import type { PiggyConversationSummary } from '@pig/core';
import { api, get, patch, post, shortDate } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { Button, EmptyState, Input, Skeleton, cn } from '@/components/ui';
import { Button, EmptyState, Input, Label, Skeleton, cn } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
DropdownMenu,
DropdownMenuContent,
@@ -273,6 +280,24 @@ function formatWhen(bucket: Bucket, value: string): string {
return shortDate(date);
}
/**
* A thread's name, short enough to be read as a question.
*
* Titles are derived from the opening prompt and run to the server's
* 120-character cap, so a confirmation headed with one in full is three lines
* of somebody's own question with "?" stuck on the end — a heading nobody
* finishes before they decide. Cut at a word boundary; the row behind the
* dialog still carries the whole of it.
*/
function shortTitle(title: string, max = 64): string {
const trimmed = title.trim();
if (trimmed.length <= max) return trimmed;
const cut = trimmed.slice(0, max);
const space = cut.lastIndexOf(' ');
const kept = space > max * 0.6 ? cut.slice(0, space) : cut;
return `${kept.replace(/[\s.,;:—–-]+$/, '')}`;
}
/** The letter the collapsed rail shows. Punctuation and emoji are skipped. */
function railInitial(title: string): string {
const letter = title.match(/[\p{L}\p{N}]/u);
@@ -310,7 +335,49 @@ export function PiggyConversationList({
const [renamingId, setRenamingId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<PiggyConversationSummary | null>(null);
const conversations = query.data ?? NO_CONVERSATIONS;
/**
* The dialog animates out over 240ms, and it is still on screen for all of
* them. Reading `pendingDelete` directly meant the heading became `Delete
* “”?` the instant either button was pressed — the confirmation forgetting
* what it had just asked about, in front of the person who answered it.
*/
const lastPendingDelete = useRef<PiggyConversationSummary | null>(null);
if (pendingDelete) lastPendingDelete.current = pendingDelete;
const deleting = pendingDelete ?? lastPendingDelete.current;
/**
* The ⋯ button the confirmation was opened from.
*
* The overlay primitive restores focus to whatever held it when the dialog
* mounted, which here is the dropdown menu — a node that has been removed
* from the document by the time anyone answers. So the row hands over its own
* button and the dialog is told explicitly where to go back to; without it,
* Escape dropped a keyboard user on `<body>`, a hundred-odd tab stops from
* the row they were working on.
*/
const deleteOpener = useRef<HTMLButtonElement | null>(null);
/** Where focus goes when the row it came from no longer exists. */
const newConversationRef = useRef<HTMLButtonElement>(null);
/**
* Threads with nothing said in them are not history.
*
* A conversation row is created the moment somebody presses New, and again
* whenever a turn is refused before a word is stored — so the demo book holds
* 58 empty threads against 14 real ones, and the rail people scan to find
* Tuesday's question is four-fifths abandoned drafts with the same derived
* title. Filtered here rather than on the server because the rows are real
* and something else may legitimately want them; this is a reading decision.
*
* The open thread is always kept. A conversation created a second ago has no
* messages yet, and it must not disappear from under the person typing in it.
*/
const conversations = useMemo(() => {
const all = query.data ?? NO_CONVERSATIONS;
const said = all.filter((entry) => entry.messageCount > 0 || entry.id === activeId);
return said.length === all.length ? all : said;
}, [activeId, query.data]);
/**
* Recomputed when the list changes rather than on a timer. The boundary only
@@ -377,9 +444,11 @@ export function PiggyConversationList({
<div className="mx-auto my-1.5 h-px w-6 bg-border" aria-hidden />
)
) : (
<h3 className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted">
/* h4, not h3: these name a run of rows inside the rail, and the
workspace's own headings sit above them. */
<Label as="h4" className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3">
{group.label}
</h3>
</Label>
)}
<ul className={cn('flex flex-col', rail ? 'items-center gap-1' : 'gap-px')}>
{group.items.map((conversation) =>
@@ -409,7 +478,10 @@ export function PiggyConversationList({
rename.mutate({ id: conversation.id, title });
}
}}
onRequestDelete={() => setPendingDelete(conversation)}
onRequestDelete={(opener) => {
deleteOpener.current = opener;
setPendingDelete(conversation);
}}
/>
),
)}
@@ -445,6 +517,7 @@ export function PiggyConversationList({
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={newConversationRef}
type="button"
variant={activeId === null ? 'secondary' : 'ghost'}
size="icon"
@@ -458,6 +531,7 @@ export function PiggyConversationList({
</Tooltip>
) : (
<Button
ref={newConversationRef}
type="button"
variant="outline"
className={cn(
@@ -490,36 +564,51 @@ export function PiggyConversationList({
</div>
</nav>
<Dialog
{/*
The destructive choice comes FIRST in the DOM and last on the screen.
A screen reader reads a footer in source order, so the consequence has
to arrive before the way out of it; `AlertDialogAction`'s own `order`
classes put "Keep it" back on the left where the platform puts it. The
title names the thread, because "this conversation" is not something
anyone can check before agreeing to destroy it.
*/}
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete this conversation?</DialogTitle>
<DialogDescription className="break-words">
{pendingDelete?.title} and everything said in it will be removed. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
type="button"
variant="danger"
disabled={remove.isPending}
onClick={() => void confirmDelete()}
>
<AlertDialogContent
onCloseAutoFocus={(event) => {
// After a delete the row's button is gone, and focusing a detached
// node silently lands on <body>. The list's own New conversation
// button is the nearest thing that certainly still exists — and is
// where `confirmDelete` has just sent the reader anyway.
const back = deleteOpener.current?.isConnected
? deleteOpener.current
: newConversationRef.current;
if (!back) return;
event.preventDefault();
back.focus();
}}
>
<AlertDialogHeader>
<AlertDialogTitle className="break-words">
Delete {deleting ? shortTitle(deleting.title) : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
The thread and everything said in it will be removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction disabled={remove.isPending} onClick={() => void confirmDelete()}>
<Trash2 className="size-4" aria-hidden />
Delete conversation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
<AlertDialogCancel>Keep it</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipProvider>
);
}
@@ -547,8 +636,11 @@ function ConversationRow({
onStartRename: () => void;
onCancelRename: () => void;
onCommitRename: (title: string) => void;
onRequestDelete: () => void;
onRequestDelete: (opener: HTMLButtonElement | null) => void;
}) {
/* Handed to the confirmation so it knows where to send focus back to. */
const actionsRef = useRef<HTMLButtonElement>(null);
if (renaming) {
return (
<li className="px-1 py-1">
@@ -584,8 +676,8 @@ function ConversationRow({
onClick={() => onSelect(conversation.id)}
aria-current={active ? 'true' : undefined}
className={cn(
'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left',
'transition-colors duration-1 ease-enter',
active ? 'bg-accent-subtle text-accent-fg' : 'hover:bg-surface-2',
)}
>
@@ -602,7 +694,7 @@ function ConversationRow({
>
{conversation.title}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted">
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted">
{running ? (
<>
<RunningDot />
@@ -630,13 +722,20 @@ function ConversationRow({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={actionsRef}
type="button"
aria-label={`Actions for ${conversation.title}`}
// Clipped, because a conversation's title is its opening question
// and these run to 120 characters — "Actions for List the three
// commitments closest to expiry as a markdown table with columns
// Provider, GPU, Ends, Idle hours, Margin…" is a label nobody
// listens to the end of.
aria-label={`Actions for ${shortTitle(conversation.title)}`}
className={cn(
'absolute right-0.5 top-0.5 flex size-11 items-center justify-center rounded-lg',
'text-muted transition hover:bg-border hover:text-fg',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'data-[state=open]:opacity-100',
'text-muted transition duration-1 ease-enter hover:bg-border hover:text-fg',
// Focus reveals it as well as ringing it: the button is invisible
// until hover, and hover is not how a keyboard reaches it.
'focus-visible:opacity-100 data-[state=open]:opacity-100',
// Hidden until hovered only where hovering is possible. On a touch
// screen there is no hover, so the same rule would hide rename and
// delete for good.
@@ -654,7 +753,7 @@ function ConversationRow({
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 text-danger focus:text-danger"
onSelect={() => onRequestDelete()}
onSelect={() => onRequestDelete(actionsRef.current)}
>
<Trash2 aria-hidden />
Delete
@@ -728,7 +827,7 @@ function RenameField({
}
}}
/>
<p className="px-1 text-[11px] leading-4 text-muted">Enter to save · Escape to cancel</p>
<p className="px-1 text-xs leading-4 text-muted">Enter to save · Escape to cancel</p>
</div>
);
}
@@ -757,8 +856,8 @@ function RailRow({
aria-current={active ? 'true' : undefined}
aria-label={conversation.title}
className={cn(
'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold',
'transition-colors duration-1 ease-enter',
// A solid fill, not the subtle tint the wide list uses. At 44px
// there is no title to carry the selection, so the square itself
// has to be unmistakable — and `accent-subtle` against
+14 -5
View File
@@ -145,7 +145,13 @@ export function PiggyConversation({
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}
// `scroll-pb-14` keeps the last 56px of the scrollport out of the
// resting position of anything the browser scrolls to itself — a
// focused follow-up chip, a revealed step, the tail of an answer. The
// jump-to-latest pill floats in that band, and without the padding it
// came to rest on top of the final line of the answer it had just
// brought into view.
className={cn('min-h-0 flex-1 scroll-pb-14 overflow-y-auto overscroll-contain', className)}
role="log"
aria-label="Piggy conversation"
// Announce the finished answer rather than each token: a live region
@@ -194,14 +200,17 @@ export function PiggyConversationScrollButton(): ReactElement | null {
onClick={scroll.scrollToLatest}
aria-label="Jump to the latest message"
className={cn(
'absolute inset-x-0 bottom-3 z-10 mx-auto rounded-full border border-border',
// Right-aligned, not centred. Centred it sat over the middle of the
// measure — which is where the sentence is — and a reader scrolled up
// mid-answer had a disc parked on the words. The right gutter is empty
// in every surface this panel is used in, from the 22rem dock to the
// full page.
'absolute bottom-3 right-3 z-10 rounded-full border border-border',
// The `secondary` fill, left opaque. A translucent disc ghosted the
// sentence it covered in light mode and disappeared into the panel
// altogether in dark; `surface-2` reads against `surface` in both.
'text-muted shadow-lg hover:text-fg',
// `mx-auto` between `inset-x-0` centres it without a transform, which
// the entrance animation below needs for itself.
'animate-in fade-in zoom-in-95',
'animate-in fade-in zoom-in-95 duration-2 ease-enter',
)}
>
<ArrowDown className="size-4" aria-hidden />
@@ -12,6 +12,8 @@ import { useEffect, useRef, useState } from 'react';
import { Check, Copy, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import type { TranscriptMessage } from '@/lib/piggy-chat';
import { piggyModeSummary } from '@/components/piggy/mode-control';
import { usePiggyModelLabel } from '@/components/piggy/model-picker';
import { Badge, Button, cn } from '@/components/ui';
/** How long the copy button admits it worked before returning to its label. */
@@ -34,6 +36,19 @@ export function PiggyMessageActions({
const state = stateLabel(message);
const usage = formatUsage(message);
const modelLabel = usePiggyModelLabel(message.model);
/*
* What this turn was allowed to do, recorded on the turn itself.
*
* PIG's safety argument is that nothing lands until a person presses Apply,
* and until now the transcript held no record of which permission each turn
* ran under — so a header reading "Read only" could sit above two write
* proposals made ten minutes earlier and nothing in the thread contradicted
* it. The mode is a fact about a turn, not about the control, so it belongs
* beside the model that answered.
*/
const mode = message.mode ? piggyModeSummary(message.mode) : null;
const ModeIcon = mode?.icon;
// Only Piggy's words are worth a copy button. A user turn reaches this
// footer too — a question the relay refused carries the `failed` chip — and
// offering to copy back what they typed a second ago is noise.
@@ -45,7 +60,7 @@ export function PiggyMessageActions({
// Nothing to press and nothing to report is a row of whitespace under every
// message. There is nothing to say, so say nothing.
if (message.pending) return null;
if (!copyable && !retryable && !state && !usage && !message.model) return null;
if (!copyable && !retryable && !state && !usage && !message.model && !mode) return null;
const handleCopy = async () => {
// `navigator.clipboard` is absent outside a secure context, which is not a
@@ -75,20 +90,36 @@ export function PiggyMessageActions({
<div className="group/actions mt-1.5 flex flex-col gap-0.5">
{/* The run line keeps its own row rather than sharing one with the
buttons, and comes first so that it stays against the answer it
describes: at 22rem the buttons' reserved width truncated the model id
to "nvidia/nemotron-3-nan…", which defeats the point of showing it. */}
{state || message.model || usage ? (
<p className="flex min-w-0 items-baseline gap-1.5 text-[11px] leading-4 text-muted">
{state ? <Badge className="shrink-0 px-2 text-[11px] font-normal">{state}</Badge> : null}
{message.model ? (
describes: at 22rem the buttons' reserved width truncated the model
name away entirely, which defeats the point of showing it. It wraps
rather than truncating now, because the mode is on it and "was this
turn allowed to write?" is not a fact a narrow column may drop. */}
{state || mode || modelLabel || usage ? (
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-4 text-muted">
{state ? <Badge className="shrink-0 px-2 font-normal">{state}</Badge> : null}
{mode && ModeIcon ? (
// Neutral, not coloured: a mode is a setting, not an outcome, and a
// footer where every turn is amber is a footer nobody reads. Auto
// takes the same warning tint the mode control gives it, on the
// glyph only — it is the one mode that can write unattended.
<Badge className="shrink-0 px-2 font-normal" title={`Piggy ran this turn in ${mode.label}`}>
<ModeIcon aria-hidden className={cn('size-3', message.mode === 'auto' && 'text-warning')} />
{mode.label}
</Badge>
) : null}
{modelLabel ? (
// Sans, and the catalogue's own name for the model rather than the
// wire id: `nvidia/nemotron-3-super-120b` set in monospace under a
// sales answer was the product talking to itself. The full id stays
// in `title`, so nothing is lost — it is just no longer shouted.
// `truncate` only shrinks a flex child that is allowed to: without
// `min-w-0` the model id sets the row's minimum width and pushes
// the counts off the side of the dock.
<span className="min-w-0 truncate font-mono" title={message.model}>
{message.model}
// `min-w-0` the name sets the row's minimum width and pushes the
// counts off the side of the dock.
<span className="min-w-0 truncate" title={message.model}>
{modelLabel}
</span>
) : null}
{message.model && usage ? <span aria-hidden>·</span> : null}
{modelLabel && usage ? <span aria-hidden>·</span> : null}
{usage ? (
<span className="shrink-0 tabular-nums" title={exactUsage(message)}>
{usage}
+76 -34
View File
@@ -3,7 +3,7 @@
*
* This is the only control in PIG that decides whether a language model may
* write to the company's book, so it is written to be read rather than to be
* clever. Three things follow from that and are deliberate:
* clever. Four things follow from that and are deliberate:
*
* names — the segments say "Read only", "Ask first" and "Auto", not
* `read_only` / `confirm` / `auto`. The enum is the wire's language
@@ -12,6 +12,10 @@
* selected NOW, and changes as the selection does. A toggle whose
* meaning lives in documentation is a toggle people set once and then
* misremember.
* the keyboard — the arrows move focus and do NOT select. The ARIA pattern
* says they should, and for a preference it would be right; for a
* permission it meant that arrowing across to read what Auto does
* turned Auto on. See `keyboardAt`.
* the exceptions — `auto` still stops at a contract, a commitment, an
* allocation and anything compliance-shaped. That is `requiresApproval`'s
* rule, and if the control does not say so, the first person to choose
@@ -32,7 +36,7 @@ import {
} from '@pig/core';
import { PIGGY_DEFAULT_MODE } from '@/lib/piggy-chat';
import { useOptionalIdentity } from '@/lib/identity';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
// ------------------------------------------------------------------- copy
@@ -132,6 +136,21 @@ export function PiggyModeControl({
const describedBy = useId();
const buttons = useRef(new Map<PiggyMode, HTMLButtonElement>());
/**
* Where the keyboard is, which is not the same as what is chosen.
*
* The ARIA radiogroup pattern normally selects whatever the arrow keys land
* on. That is right for a preference and wrong for a permission: arrowing
* across to read what Auto does was granting an agent unattended write access
* to the book, and the sentence explaining the consequence appeared *because*
* the consequence had already been accepted. Here the arrows move focus, the
* sentence updates to describe what is under the cursor, and Space or Enter
* is what commits. Null means the keyboard is elsewhere and the tabstop
* belongs to the selected segment, so tabbing back in returns to the choice
* in force rather than to wherever the last arrow press stopped.
*/
const [keyboardAt, setKeyboardAt] = useState<PiggyMode | null>(null);
/**
* What is drawn as selected. Not necessarily what the parent holds: a stored
* `auto` outlives the capability that justified it, so someone whose write
@@ -150,40 +169,60 @@ export function PiggyModeControl({
const choices = MODE_OPTIONS.filter((option) => canWrite || option.value === 'read_only');
/** Roving tabstop: the keyboard's position if it has one, else the choice. */
const roving: PiggyMode = keyboardAt ?? selected;
const moveTo = useCallback((next: PiggyMode | undefined) => {
if (!next) return;
setKeyboardAt(next);
buttons.current.get(next)?.focus();
}, []);
const step = useCallback(
(direction: 1 | -1) => {
const index = choices.findIndex((option) => option.value === selected);
const next = choices[(index + direction + choices.length) % choices.length];
if (!next) return;
onChange(next.value);
buttons.current.get(next.value)?.focus();
const index = choices.findIndex((option) => option.value === roving);
moveTo(choices[(index + direction + choices.length) % choices.length]?.value);
},
[choices, onChange, selected],
[choices, moveTo, roving],
);
const active = optionFor(selected);
/**
* The sentence describes what the keyboard is on, not what is chosen — so
* someone arrowing across Auto reads its consequence before deciding, which
* is the whole point of no longer selecting on focus.
*/
const active = optionFor(roving);
/** True while the keyboard is reading a mode that has not been chosen. */
const previewing = roving !== selected;
return (
<div className={cn('flex min-w-0 flex-col', compact ? 'gap-1.5' : 'gap-2')}>
{compact ? null : (
<span className="text-xs font-medium uppercase tracking-wide text-muted">
What Piggy may do
</span>
)}
{compact ? null : <Label>What Piggy may do</Label>}
<div
role="radiogroup"
aria-label="What Piggy may do"
aria-describedby={describedBy}
className="grid grid-cols-3 gap-1 rounded-xl border border-border bg-surface-2 p-1"
className="grid grid-cols-3 gap-1 rounded-lg border border-border bg-surface-2 p-1"
onBlur={(event) => {
// Leaving the group hands the tabstop back to the chosen segment, so
// the next Tab in lands on the mode in force rather than on whichever
// one the reader stopped over last time.
if (!event.currentTarget.contains(event.relatedTarget)) setKeyboardAt(null);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
step(1);
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
step(-1);
} else if (event.key === 'Home') {
event.preventDefault();
moveTo(choices[0]?.value);
} else if (event.key === 'End') {
event.preventDefault();
moveTo(choices[choices.length - 1]?.value);
}
}}
>
@@ -203,18 +242,20 @@ export function PiggyModeControl({
aria-checked={isSelected}
// Roving tabstop: a radio group is one stop in the tab order, and
// the arrow keys move within it.
tabIndex={isSelected ? 0 : -1}
tabIndex={option.value === roving ? 0 : -1}
disabled={disabled}
title={disabled ? NO_WRITE_REASON : option.sentence}
onClick={() => onChange(option.value)}
onClick={() => {
setKeyboardAt(option.value);
onChange(option.value);
}}
className={cn(
'flex min-h-[44px] min-w-0 items-center justify-center rounded-lg',
'font-medium transition-colors touch-manipulation select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent',
'flex min-h-[44px] min-w-0 items-center justify-center rounded-md',
'font-medium transition-colors duration-1 ease-enter touch-manipulation select-none',
// Tight enough that "Read only" survives whole in a dock
// narrower than the 22rem one; the label is what makes this
// control legible, so it is the last thing allowed to truncate.
compact ? 'gap-1 px-1 text-[11px]' : 'gap-1.5 px-2 text-xs sm:text-sm',
compact ? 'gap-1 px-1 text-xs' : 'gap-1.5 px-2 text-xs sm:text-sm',
isSelected
? 'bg-surface text-fg shadow-sm'
: 'text-muted hover:text-fg disabled:hover:text-muted',
@@ -238,32 +279,33 @@ export function PiggyModeControl({
{/*
Announced on change, because the consequence arrives a beat after the
press and a screen-reader user gets no colour to tell them the tone of
the panel changed.
the panel changed. It follows the keyboard rather than the choice, so
arrowing across Auto reads its consequence — which is the only way to
find out, now that arrowing no longer turns it on.
*/}
<div id={describedBy} aria-live="polite" className="min-w-0">
<div id={describedBy} aria-live="polite" className="min-w-0 text-xs leading-snug">
{active.consequential ? (
<p
className={cn(
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10',
compact ? 'px-2 py-1.5 text-[11px]' : 'px-2.5 py-2 text-xs',
'leading-snug text-fg',
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 text-fg',
compact ? 'px-2 py-1.5' : 'px-2.5 py-2',
)}
>
<TriangleAlert aria-hidden className="mt-px h-3.5 w-3.5 shrink-0 text-warning" />
<span>
{previewing ? <span className="font-medium">{active.label}: </span> : null}
{active.sentence} <span className="font-medium">{GUARDED_SENTENCE}</span>
{previewing ? ' Press Enter to choose it.' : null}
</span>
</p>
) : (
<p className={cn('leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
<p className="text-muted">
{previewing ? <span className="font-medium text-fg">{active.label}: </span> : null}
{active.sentence}
{previewing ? ' Press Enter to choose it.' : null}
</p>
)}
{canWrite ? null : (
<p className={cn('mt-1 leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
{NO_WRITE_REASON}
</p>
)}
{canWrite ? null : <p className="mt-1 text-muted">{NO_WRITE_REASON}</p>}
</div>
</div>
);
+42 -16
View File
@@ -22,7 +22,11 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronsUpDown, Sparkles } from 'lucide-react';
// `Cpu`, not `Sparkles`: Sparkles used to mean both "Piggy" and "model", and a
// glyph that means two things means neither. Piggy is `PiggyMark` everywhere
// now, so this control needs a neutral mark of its own — and inference running
// on Prime Intellect's own silicon is the thing this menu is about.
import { ChevronsUpDown, Cpu } from 'lucide-react';
import type { PiggyModelOption } from '@pig/core';
import { fetchPiggyModels } from '@/lib/piggy-chat';
import { useIdentityQuery } from '@/lib/identity';
@@ -35,7 +39,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge, Button, Skeleton, cn } from '@/components/ui';
import { Badge, Button, Label, Skeleton, cn } from '@/components/ui';
// ------------------------------------------------------------------ catalogue
@@ -320,6 +324,28 @@ function shortLabel(label: string): string {
return words.length >= LONG_LABEL_WORDS ? words.slice(-2).join(' ') : label;
}
/**
* The human name for a model id, for surfaces that only have the id.
*
* The transcript footer used to print `nvidia/nemotron-3-super-120b` in
* monospace under every answer — the raw wire value, in the one typeface this
* product reserves for machine text, on the line meant to tell a sales lead
* which model answered them. The catalogue already carries the name the picker
* shows two inches above it, so the footer says the same thing the menu says.
*
* The fallback drops the provider prefix rather than inventing capitalisation:
* a deployment can list a model this browser's cached catalogue has never
* seen, and a guessed name is worse than an honest identifier.
*/
export function usePiggyModelLabel(modelId: string | undefined): string | undefined {
const { models } = usePiggyModels();
if (!modelId) return undefined;
const known = models.find((model) => model.id === modelId);
if (known) return known.label;
const slash = modelId.lastIndexOf('/');
return slash === -1 ? modelId : modelId.slice(slash + 1);
}
export interface PiggyModelPickerProps {
/** The chosen model id, or null to follow the deployment default. */
value: string | null;
@@ -403,7 +429,7 @@ export function PiggyModelPicker({
aria-label="The model list is unavailable"
title={error?.message ?? 'Piggy did not return a model list.'}
>
<Sparkles className="size-4 shrink-0 text-muted" aria-hidden />
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
{compact ? null : <span className="text-muted">Model unavailable</span>}
</Button>
);
@@ -422,12 +448,12 @@ export function PiggyModelPicker({
compact ? 'max-w-[11rem] px-2' : 'max-w-[18rem] px-2.5 text-sm',
)}
>
<Sparkles className="size-4 shrink-0 text-muted" aria-hidden />
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
<span className="truncate text-fg">
{compact ? shortLabel(inForce.label) : inForce.label}
</span>
{!compact && isDeploymentDefault ? (
<span className="shrink-0 text-[11px] text-muted">Default</span>
<span className="shrink-0 text-xs text-muted">Default</span>
) : null}
<ChevronsUpDown className="size-3.5 shrink-0 text-muted" aria-hidden />
</Button>
@@ -444,11 +470,11 @@ export function PiggyModelPicker({
sideOffset={6}
className="z-[60] w-[min(26rem,calc(100vw-1.5rem))] p-1.5"
>
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
Model
<span className="font-normal normal-case tracking-normal">
{models.length} available
</span>
<DropdownMenuLabel className="px-2 pb-1.5 pt-1">
<Label className="flex items-baseline justify-between gap-2">
Model
<span className="normal-case tracking-normal">{models.length} available</span>
</Label>
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={inForce.id} onValueChange={handleSelect}>
@@ -465,17 +491,17 @@ export function PiggyModelPicker({
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium text-fg">{model.label}</span>
{model.id === defaultModelId ? (
<Badge tone="neutral" className="px-1.5 py-0 text-[10px]">
<Badge tone="neutral" className="px-1.5 py-0">
Default
</Badge>
) : null}
{model.id === cheapestId ? (
<Badge tone="positive" className="px-1.5 py-0 text-[10px]">
<Badge tone="positive" className="px-1.5 py-0">
Cheapest
</Badge>
) : null}
{topTierIds.has(model.id) ? (
<Badge tone="accent" className="px-1.5 py-0 text-[10px]">
<Badge tone="accent" className="px-1.5 py-0">
Most capable
</Badge>
) : null}
@@ -483,13 +509,13 @@ export function PiggyModelPicker({
{model.hint ? (
<p className="whitespace-normal text-xs leading-snug text-muted">{model.hint}</p>
) : null}
<p className="nums whitespace-normal text-[11px] leading-snug text-muted">
<p className="nums whitespace-normal text-xs leading-snug text-muted">
{formatRates(model)} · {formatContext(model.contextWindow)} context
</p>
</div>
<div className="flex shrink-0 flex-col items-end pl-1 text-right">
<span className="nums text-sm font-medium text-fg">{formatQuote(model)}</span>
<span className="text-[10px] leading-tight text-muted">per 100 questions</span>
<span className="text-xs leading-tight text-muted">per 100 questions</span>
</div>
</DropdownMenuRadioItem>
))}
@@ -497,7 +523,7 @@ export function PiggyModelPicker({
<DropdownMenuSeparator />
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-[11px] leading-snug text-muted">
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-xs leading-snug text-muted">
Every model here is served by Prime Intellect inference on one API key. Prices are
estimated from a measured question about {TYPICAL_INPUT_TOKENS.toLocaleString('en-US')}{' '}
tokens in and {TYPICAL_OUTPUT_TOKENS} out.
+22 -21
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Brain, ChevronRight } from 'lucide-react';
import { Brain } from 'lucide-react';
import { cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
/**
* How long after the last reasoning token the panel folds itself away.
@@ -81,10 +82,11 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
if (!started && !streaming) return null;
return (
<details
<Disclosure
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
onOpenChange={setOpen}
className="mb-2 text-xs text-muted"
summaryClassName="pr-2 text-xs"
/*
* The transcript around this is `role="log" aria-live="polite"`, and a
* live region announces its whole subtree. Auto-opening the panel
@@ -93,30 +95,29 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
* overrides the inherited politeness for this subtree only.
*/
aria-live="off"
/*
* On the `<details>` rather than on the summary, because that is where
* the primitive's own props land — and a click anywhere in this panel,
* summary or body, is the user attending to it, which is exactly the
* signal the automatic open and close must stand down for.
*/
onClick={() => {
touched.current = true;
}}
summary={
<span className="flex min-w-0 items-center gap-2">
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
</span>
}
>
<summary
// `list-none` and the WebKit rule between them remove the native
// triangle, which a flex summary drops in Chrome but keeps in Firefox —
// so without both the disclosure marker exists in one browser only.
className="flex min-h-11 cursor-pointer list-none items-center gap-2 py-2 pr-2 font-medium transition-colors hover:text-fg [&::-webkit-details-marker]:hidden"
onClick={() => {
touched.current = true;
}}
>
<ChevronRight
className={cn('size-3.5 shrink-0 transition-transform', open && 'rotate-90')}
aria-hidden
/>
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
</summary>
{/* Withheld until the first token so the gap between "Thinking" and
anything to read is empty space rather than an empty rail. */}
{started ? (
<div
ref={bodyRef}
className={cn(
'ml-1 animate-in fade-in border-l border-border py-1 pl-3',
'ml-1 animate-in fade-in border-l border-border py-1 pl-3 duration-2 ease-enter',
// Capped only while it writes. An auto-opened panel is one the user
// did not ask for, so it must not push the answer off screen; a
// panel they opened themselves is one they mean to read to the end.
@@ -126,7 +127,7 @@ export function PiggyReasoning({ text, streaming }: { text: string; streaming: b
<p className="whitespace-pre-wrap leading-5">{text}</p>
</div>
) : null}
</details>
</Disclosure>
);
}
+16 -8
View File
@@ -21,7 +21,7 @@ import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import { isValidElement } from 'react';
import { ArrowUpRight } from 'lucide-react';
import { Streamdown, type Components, type ExtraProps } from 'streamdown';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
/** Fenced blocks carry their language as `language-sql` on the `code` element. */
const LANGUAGE_CLASS = /language-([\w-]+)/;
@@ -60,12 +60,18 @@ const MARKDOWN_COMPONENTS: Components = {
* this renders in a 22rem dock as often as on a full page, and a document
* h1 at that width reads as a shout.
*/
h1: ({ children }) => <h1 className="pt-2 text-lg font-semibold tracking-tight">{children}</h1>,
h2: ({ children }) => <h2 className="pt-2 text-[0.9375rem] font-semibold tracking-tight">{children}</h2>,
h1: ({ children }) => <h1 className="pt-2 text-base font-semibold tracking-tight">{children}</h1>,
// h1 and h2 share the section-heading step deliberately. The scale has one
// size for "this owns the block below it", and `##` is what a model reaches
// for first — set a step down from `#` it read as a bolded sentence rather
// than as the heading of the table under it.
h2: ({ children }) => <h2 className="pt-2 text-base font-semibold">{children}</h2>,
h3: ({ children }) => <h3 className="pt-1 text-sm font-semibold">{children}</h3>,
h4: ({ children }) => <h4 className="pt-1 text-sm font-medium">{children}</h4>,
h5: ({ children }) => <h5 className="pt-1 text-sm font-medium text-muted">{children}</h5>,
h6: ({ children }) => <h6 className="pt-1 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>,
h6: ({ children }) => (
<h6 className="pt-1 text-xs font-medium uppercase tracking-[0.06em] text-muted">{children}</h6>
),
ul: ({ children }) => <ul className="list-disc space-y-1 pl-5 marker:text-muted">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-1 pl-5 marker:text-muted">{children}</ol>,
@@ -115,7 +121,7 @@ const MARKDOWN_COMPONENTS: Components = {
<div className="scroll-x rounded-lg border border-border">
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
the scroller rather than squash the columns when it is not. */}
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">{children}</table>
<table className="w-max min-w-full border-collapse text-left text-sm leading-5">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
@@ -123,10 +129,12 @@ const MARKDOWN_COMPONENTS: Components = {
// A row highlight is what lets you keep your place across a table that is
// wider than the pane and has been scrolled sideways.
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
// The column heading is the product's one micro label, so a renewals table in
// an answer and a renewals table on /contracts are read at the same size.
th: ({ children, style, align }) => (
<th className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted" style={alignStyle(style, align)}>
<Label as="th" className="whitespace-nowrap px-3 py-2 align-bottom" style={alignStyle(style, align)}>
{children}
</th>
</Label>
),
td: ({ children, style, align }) => (
<td className="nums px-3 py-2 align-top" style={alignStyle(style, align)}>
@@ -167,7 +175,7 @@ function CodeFence({ className, children }: ComponentProps<'code'> & ExtraProps)
return (
<div className="overflow-hidden rounded-lg border border-border bg-surface-2">
{language ? (
<div className="border-b border-border px-3 py-1.5 font-mono text-[11px] lowercase text-muted">{language}</div>
<div className="border-b border-border px-3 py-1.5 font-mono text-xs lowercase text-muted">{language}</div>
) : null}
<pre className="scroll-x p-3 text-xs leading-5">
<code className="font-mono">{codeText(children)}</code>
+234 -192
View File
@@ -13,12 +13,14 @@
* payload one further click down for anyone who wants to check the sentence
* against it.
*/
import { useRef, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { CheckCircle2, ChevronRight, Loader2, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { CheckCircle2, CircleSlash, Loader2, XCircle } from 'lucide-react';
import { money, unitPrice } from '@/lib/api';
import type { ToolStep } from '@/lib/piggy-chat';
import { cn } from '@/components/ui';
import { isPiggyWriteTool, piggyToolLabel } from '@/lib/piggy-tool-labels';
import { RecordLink } from '@/components/RecordLink';
import { Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
import { usePiggyConversationReveal } from './conversation';
/**
@@ -36,24 +38,19 @@ const RAW_PAYLOAD_MAX_CHARS = 20_000;
// ------------------------------------------------------------------ routing
/**
* Where a record of each kind can be opened.
* The record kinds a payload can name, and the noun each is called by.
*
* Contacts point at /accounts because PIG has no contacts route — the accounts
* page carries both views — and everything else points at its list.
* The URLs themselves live in `components/RecordLink`, which is the one place a
* record id becomes a URL and the one place that knows an account chip opens
* the record while everything else lands on the list holding it. This table is
* what remains: the vocabulary a summary sentence is written in.
*
* A contact is the case worth stating: it would want `/accounts/:accountId`,
* and the summariser reads contacts out of collections that carry the contact's
* own id and not its account's, so a per-record contact link would point at an
* account that does not exist.
*/
const RECORD_ROUTES = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
} as const;
type RecordKind = keyof typeof RECORD_ROUTES;
const RECORD_LABELS: Record<RecordKind, string> = {
const RECORD_LABELS = {
account: 'account',
contact: 'contact',
demand_deal: 'demand deal',
@@ -61,153 +58,175 @@ const RECORD_LABELS: Record<RecordKind, string> = {
contract: 'contract',
commitment: 'capacity commitment',
allocation: 'allocation',
};
} as const;
interface RecordLink {
type RecordKind = keyof typeof RECORD_LABELS;
interface EvidenceRecord {
kind: RecordKind;
id: string;
label: string;
}
/**
* The single place a record id becomes a URL.
*
* `/accounts/:id` now exists, so an account chip opens the record itself —
* which is the whole promise of the evidence row, and why the id has been
* carried this far rather than dropped at the summariser. Nothing else has a
* per-record route yet, so those chips still land on the list, which at least
* puts the reader in front of the row. A contact is the case worth stating: it
* would want `/accounts/:accountId`, and the summariser reads contacts out of
* collections that carry the contact's own id and not its account's, so
* appending it here would build a URL to an account that does not exist.
*/
function recordHref(link: RecordLink): string {
return link.kind === 'account'
? `${RECORD_ROUTES.account}/${link.id}`
: RECORD_ROUTES[link.kind];
}
// ------------------------------------------------------------------ evidence
interface Evidence {
/** One line a human reads instead of the payload. */
headline: string | null;
/** The records the answer rests on, each openable. */
links: RecordLink[];
links: EvidenceRecord[];
/** Records read but not linked, so the cap is admitted rather than hidden. */
hiddenLinkCount: number;
/**
* What a WRITE tool's call actually came to.
*
* A write that the user declined returns normally — the tool did its job,
* which was to ask — so its step is `succeeded` and it was drawn with the
* same green check as a lookup, above a card reading "Rejected. Nothing was
* changed." On the surface whose whole promise is that a person decides, the
* evidence row was contradicting the decision. `state` alone cannot tell
* these apart; the payload's own `status` can.
*/
outcome?: PigWriteStatus;
}
/** The three ways a write tool's call can end. Mirrors `PigWriteDetails`. */
type PigWriteStatus = 'applied' | 'declined' | 'refused';
const NO_EVIDENCE: Evidence = { headline: null, links: [], hiddenLinkCount: 0 };
/**
* A write that has stopped and is asking, drawn by the approval card instead.
*
* A write tool holds its own call open across the whole approval — so for as
* long as the question is on screen, this step is `running` and renders a
* spinning row saying "Log activity" directly above a card that says the same
* thing with a diff and two buttons. One of the two is the decision surface and
* the other is a spinner that reads as work in progress on a turn where nothing
* is progressing. The step returns the moment the decision resolves it.
*
* The one case this over-reaches is auto mode, where a write runs without ever
* asking: its row is withheld for the second or so the call takes, then appears
* complete with its duration. That is the cheaper of the two mistakes — the
* duplicate row is on the default mode and on the product's most important
* card, the silent second is on a mode that has already said it will not ask.
*/
export function isParkedWriteStep(step: ToolStep): boolean {
return step.state === 'running' && isPiggyWriteTool(step.name);
}
export function PiggyToolStep({ step }: { step: ToolStep }) {
const reveal = usePiggyConversationReveal();
const evidence = describeStep(step);
const input = formatArguments(step.arguments);
const payload = step.state === 'succeeded' ? formatPayload(step.result) : null;
const stepRef = useRef<HTMLDetailsElement>(null);
const rawRef = useRef<HTMLDetailsElement>(null);
const reveal = usePiggyConversationReveal();
/**
* A transcript pinned to its newest message treats an unfolded step as new
* content and scrolls past it, so the evidence the user asked to see leaves
* the screen. `open` still holds its pre-click value inside a click handler,
* which is both the only moment we can tell an expansion from a collapse and
* the last moment before the growth is laid out. A collapse is left alone: it
* shrinks the transcript, which the follow handles correctly already.
*/
const revealOnExpand = (details: HTMLDetailsElement | null) => {
if (!details || details.open) return;
reveal(details);
};
if (isParkedWriteStep(step)) return null;
return (
<details ref={stepRef} className="group rounded-lg border border-border text-xs">
<summary
onClick={() => revealOnExpand(stepRef.current)}
className={cn(
'flex min-h-11 cursor-pointer list-none items-start gap-2 px-3 py-2',
// Safari draws its own disclosure triangle from a pseudo-element that
// `list-style: none` does not reach, which left two markers on the row.
'[&::-webkit-details-marker]:hidden',
)}
>
<StepIcon state={step.state} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{toolLabel(step.name)}</span>
{step.durationMs === undefined ? null : (
<span className="shrink-0 tabular-nums text-muted">{formatDuration(step.durationMs)}</span>
<Disclosure
// A transcript pinned to its newest message treats an unfolded step as
// new content and scrolls past it, so the evidence the user asked to see
// leaves the screen. `onExpand` fires inside the click, before the
// browser lays the expansion out, which is the only moment early enough
// to get in front of the follow.
onExpand={reveal}
className="rounded-lg border border-border text-xs"
// The chevron is nudged onto the first line's optical centre, the same
// half-step the state icon beside it takes, so a two-line headline does
// not leave the two glyphs at different heights.
summaryClassName="items-start px-3 text-xs font-normal [&>svg]:mt-0.5"
contentClassName="flex flex-col gap-3 border-t border-border p-3"
summary={
<span className="flex min-w-0 items-start gap-2">
<StepIcon state={step.state} outcome={evidence.outcome} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{piggyToolLabel(step.name)}
</span>
{step.durationMs === undefined ? null : (
<span className="shrink-0 tabular-nums text-muted">
{formatDuration(step.durationMs)}
</span>
)}
</span>
{evidence.headline ? (
// Clamped shut, whole when open: a calendar headline runs to
// several sentences, and a chip that tall stops being a chip.
<span
className={cn(
'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
step.state === 'failed' ? 'text-danger' : 'text-muted',
)}
>
{evidence.headline}
</span>
) : null}
</span>
</span>
}
>
{input ? (
<EvidenceSection title="Input">
<RawBlock text={input} />
</EvidenceSection>
) : null}
<EvidenceSection title="Output">
{step.state === 'running' ? (
<p className="text-muted">Waiting for PIG</p>
) : step.state === 'failed' ? (
// The reason is already in the header, unclamped once open, so
// repeating it here would print the same sentence twice in a row.
<p className="text-muted">Nothing was returned; the call did not complete.</p>
) : (
<div className="flex flex-col gap-2">
{evidence.links.length ? (
<RecordChips links={evidence.links} hidden={evidence.hiddenLinkCount} />
) : null}
{payload ? (
<Disclosure
onExpand={reveal}
summary="Raw payload"
summaryClassName="text-xs font-normal text-muted"
>
<RawBlock text={payload} />
</Disclosure>
) : (
<p className="text-muted">The tool returned no payload.</p>
)}
<ChevronRight
className="size-4 shrink-0 text-muted transition-transform group-open:rotate-90"
aria-hidden
/>
</div>
{evidence.headline ? (
// Clamped shut, whole when open: a calendar headline runs to several
// sentences, and a chip that tall stops being a chip.
<p
className={cn(
'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
step.state === 'failed' ? 'text-danger' : 'text-muted',
)}
>
{evidence.headline}
</p>
) : null}
</div>
</summary>
<div className="flex flex-col gap-3 border-t border-border p-3">
{input ? (
<Section title="Input">
<RawBlock text={input} />
</Section>
) : null}
<Section title="Output">
{step.state === 'running' ? (
<p className="text-muted">Waiting for PIG</p>
) : step.state === 'failed' ? (
// The reason is already in the header, unclamped once open, so
// repeating it here would print the same sentence twice in a row.
<p className="text-muted">Nothing was returned; the call did not complete.</p>
) : (
<div className="flex flex-col gap-2">
{evidence.links.length ? (
<RecordLinks links={evidence.links} hidden={evidence.hiddenLinkCount} />
) : null}
{payload ? (
<details ref={rawRef} className="group/raw">
<summary
onClick={() => revealOnExpand(rawRef.current)}
className="inline-flex min-h-11 cursor-pointer list-none items-center gap-1 text-muted hover:text-fg [&::-webkit-details-marker]:hidden"
>
<ChevronRight
className="size-3.5 transition-transform group-open/raw:rotate-90"
aria-hidden
/>
Raw payload
</summary>
<RawBlock text={payload} />
</details>
) : (
<p className="text-muted">The tool returned no payload.</p>
)}
</div>
)}
</Section>
</div>
</details>
)}
</EvidenceSection>
</Disclosure>
);
}
function StepIcon({ state }: { state: ToolStep['state'] }) {
const label = state === 'running' ? 'Running' : state === 'succeeded' ? 'Succeeded' : 'Failed';
/**
* What became of one call, in a glyph and a word.
*
* `outcome` overrides `state` for the write tools, and it has to: a proposal
* the user declined is a call that RETURNED, so its step is `succeeded` and it
* was drawn "Succeeded" with a positive check directly above the card saying
* nothing had changed. Declined and refused are neither successes nor failures
* — nothing went wrong and nothing was written — so they take the neutral mark
* the decided colour table gives every other process outcome.
*/
function StepIcon({ state, outcome }: { state: ToolStep['state']; outcome?: PigWriteStatus }) {
const unwritten = state === 'succeeded' && (outcome === 'declined' || outcome === 'refused');
const label = unwritten
? 'Not saved'
: state === 'running'
? 'Running'
: state === 'succeeded'
? 'Succeeded'
: 'Failed';
return (
<span className="mt-0.5 shrink-0">
{state === 'running' ? (
<Loader2 className="size-4 animate-spin text-muted" aria-hidden />
) : unwritten ? (
<CircleSlash className="size-4 text-muted" aria-hidden />
) : state === 'succeeded' ? (
<CheckCircle2 className="size-4 text-positive" aria-hidden />
) : (
@@ -218,11 +237,19 @@ function StepIcon({ state }: { state: ToolStep['state'] }) {
);
}
/** Labelled without a heading: a transcript full of `h4`s wrecks heading navigation. */
function Section({ title, children }: { title: string; children: ReactNode }) {
/**
* A named group inside a step, labelled without a heading.
*
* Deliberately not the shared `Section`: this renders inside `role="log"`,
* where every step would contribute an `h3` or `h4` and a transcript of forty
* of them turns heading navigation — the way a screen-reader user skims a
* page — into a list of "Input, Output, Input, Output". The label itself is the
* shared one, so it measures the same as every other micro label in PIG.
*/
function EvidenceSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section aria-label={title}>
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted">{title}</p>
<Label className="mb-1">{title}</Label>
{children}
</section>
);
@@ -230,30 +257,40 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
function RawBlock({ text }: { text: string }) {
return (
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-[11px] leading-4 text-muted">
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-xs leading-5 text-muted">
{text}
</pre>
);
}
function RecordLinks({ links, hidden }: { links: RecordLink[]; hidden: number }) {
/**
* The records the answer rests on, each openable.
*
* Same tail, same link, same honesty about where it lands as the approval
* card's escape hatch — this is the other half of the promise that an answer
* can be checked against its rows.
*
* `newTab`, for the same reason the approval card has it, and the reason is
* `RecordLink`'s own: "turn it on where leaving would destroy unsubmitted state
* — the pending approval card, and any link inside a streaming transcript."
* This is a link inside a streaming transcript. Measured with a proposal on
* screen, following one of these chips in-tab unmounted the transcript, and
* Back returned to the empty starter state — no card, no answer, and no notice
* that a decision had been abandoned. The chip that exists so an answer can be
* checked was destroying the thing being checked.
*/
function RecordChips({ links, hidden }: { links: EvidenceRecord[]; hidden: number }) {
return (
<ul className="flex flex-wrap gap-1.5" aria-label="Records read">
{links.map((link) => (
<li key={`${link.kind}:${link.id}`} className="min-w-0 max-w-full">
<Link
to={recordHref(link)}
// The title has to follow the href: promising a list and opening a
// record is the sort of small lie that stops a chip being trusted.
title={
link.kind === 'account'
? `Open the account ${link.label}`
: `Open the ${RECORD_LABELS[link.kind]} list`
}
className="flex min-h-11 max-w-full items-center rounded-md border border-border px-2 text-muted hover:bg-surface-2 hover:text-fg"
>
<span className="truncate">{link.label}</span>
</Link>
<RecordLink
type={link.kind}
id={link.id}
label={link.label}
newTab
className="border border-border px-2 hover:bg-surface-2 hover:no-underline"
/>
</li>
))}
{hidden > 0 ? (
@@ -279,6 +316,14 @@ function summariseResult(result: unknown): Evidence {
const payload = asRecord(result);
if (!payload) return NO_EVIDENCE;
// A write tool first: its payload is `{ tool, kind, status, recordId?,
// reason? }` and carries no `headline`, no subject and no collections, so it
// fell all the way through to `composeHeadline(null, [])` — an empty string.
// The measured result was a row reading "Succeeded / Log activity / 1.4s"
// with nothing under it, above a card saying the change had been rejected.
const written = writeOutcome(payload);
if (written) return written;
// The page tools compose the sentence they want quoted and the system prompt
// tells the model to quote it, so deriving a second summary here would put a
// subtly different reading of the same numbers next to the model's. They drop
@@ -314,6 +359,39 @@ function summariseResult(result: unknown): Evidence {
};
}
/**
* A write tool's own account of itself.
*
* The three statuses come straight from `PigWriteDetails` in the agent, and the
* sentence deliberately says the same thing the approval card two elements
* below says. Two surfaces describing one decision have to agree; before this
* they did not, and the one that disagreed was the one wearing a green check.
*
* `reason` is quoted rather than paraphrased for `refused`, because the reason
* is a permission the person holds ("you cannot change a contract") and only
* the server knows which one it was.
*/
function writeOutcome(payload: Record<string, unknown>): Evidence | null {
const status = asString(payload.status);
if (status !== 'applied' && status !== 'declined' && status !== 'refused') return null;
// `tool` and `kind` are the shape's fingerprint: a read payload could carry a
// `status` column off a record row (a contract's status is "executed"), and
// that must not be read as a write outcome.
if (!asString(payload.tool)) return null;
const reason = asString(payload.reason);
const headline =
status === 'applied'
? 'Saved to PIG.'
: status === 'declined'
? 'Not saved. You declined this change.'
: reason
? `Not saved. ${reason}`
: 'Not saved.';
return { headline, links: [], hiddenLinkCount: 0, outcome: status };
}
/**
* The rows behind a headline, where the tool kept their ids.
*
@@ -324,8 +402,8 @@ function summariseResult(result: unknown): Evidence {
* that the records behind an answer can be opened. The page tools are untouched:
* they carry no `results` or `renewals`, so they still summarise to a sentence.
*/
function readHeadlineLinks(payload: Record<string, unknown>): RecordLink[] {
const links: RecordLink[] = [];
function readHeadlineLinks(payload: Record<string, unknown>): EvidenceRecord[] {
const links: EvidenceRecord[] = [];
// A search hit names its own type, because a search spans five tables.
for (const row of asArray(payload.results)) {
const record = asRecord(row);
@@ -439,10 +517,10 @@ const COLLECTIONS: readonly Collection[] = [
function readCollections(payload: Record<string, unknown>): {
counts: string[];
links: RecordLink[];
links: EvidenceRecord[];
} {
const counts: string[] = [];
const links: RecordLink[] = [];
const links: EvidenceRecord[] = [];
for (const collection of COLLECTIONS) {
const rows = payload[collection.key];
@@ -550,42 +628,6 @@ function formatPayload(value: unknown): string | null {
: text;
}
// -------------------------------------------------------------------- naming
/**
* Named for the reader, not for the model.
*
* The generic fallback turns `pig_get_calendar_ahead` into "Get Calendar
* Ahead", which is the tool's identifier with the underscores taken out. The
* eleven tools interactive chat can actually be given get a name instead —
* `createInteractivePigTools` is the list this must keep up with, and the four
* lookup tools were the ones reading as "Get Record By Id" until they landed
* here.
*/
const TOOL_LABELS: Record<string, string> = {
pig_get_record: 'Record in focus',
pig_get_account_lifecycle: 'Account lifecycle',
pig_get_workspace_summary: 'Workspace summary',
pig_get_margin_summary: 'Margin book',
pig_get_idle_capacity: 'Idle capacity',
pig_get_pipeline: 'Open pipeline',
pig_get_calendar_ahead: 'Calendar ahead',
pig_search_records: 'Record search',
pig_get_record_by_id: 'Record lookup',
pig_list_renewals: 'Renewal deadlines',
pig_list_inventory: 'Provider inventory',
};
function toolLabel(name: string): string {
return (
TOOL_LABELS[name] ??
name
.replace(/^pig_/, '')
.replaceAll('_', ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase())
);
}
/**
* Below this the transcript stops quoting a figure and admits a floor instead.
*
@@ -638,5 +680,5 @@ function asArray(value: unknown): unknown[] {
/** A record type the transcript knows how to open, or nothing. */
function asRecordKind(value: unknown): RecordKind | null {
const key = asString(value);
return key !== null && key in RECORD_ROUTES ? (key as RecordKind) : null;
return key !== null && key in RECORD_LABELS ? (key as RecordKind) : null;
}
@@ -151,7 +151,10 @@ export function PiggyModeButton({
disabled={disabled}
className={cn(
'min-w-0 gap-1.5 font-medium',
compact ? 'h-11 px-2 text-[11px]' : 'h-11 px-2.5 text-xs',
// `text-xs` in both branches: 11px is the micro-LABEL size, and this
// is a control's own word, not a label naming one. It was the last
// non-Label 11px string in the product. Only the padding gives.
compact ? 'h-11 px-2 text-xs' : 'h-11 px-2.5 text-xs',
)}
aria-label={`What Piggy may do: ${label}`}
>
@@ -16,12 +16,13 @@
* the transcript does not get to overrule them.
*/
import { useMemo, useState } from 'react';
import { CheckCircle2, CircleSlash, FileText, TriangleAlert } from 'lucide-react';
import { CheckCircle2, CircleAlert, CircleSlash, FileText, Loader2, TriangleAlert } from 'lucide-react';
import type { PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, TranscriptMessage } from '@/lib/piggy-chat';
import { compactNumber } from '@/lib/api';
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
import { PiggyActivityPanel, spendMoney, spendTitle } from '@/components/piggy/activity-panel';
import { Badge, cn } from '@/components/ui';
import { Badge, EmptyState, Section, Stat, cn } from '@/components/ui';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function PiggyWorkspaceRail({
@@ -47,24 +48,13 @@ export function PiggyWorkspaceRail({
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
>
<div className="shrink-0 border-b border-border p-2">
{/* The primitive's own palette is shadcn's, where `bg-muted` is a
surface. In PIG `muted` is the muted TEXT colour, so an unstyled
TabsList paints a mid-grey slab with unreadable labels on it. Every
other Tabs in the app carries the same three overrides; they are the
house pattern rather than a local fix. */}
<TabsList className="grid w-full grid-cols-2 border border-border bg-surface p-1">
<TabsTrigger
value="chat"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
This chat
</TabsTrigger>
<TabsTrigger
value="activity"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
Activity
</TabsTrigger>
{/* No palette overrides and no height override: PIG's own treatment and
the 44px floor are in the primitive now. The rail's triggers were
36px, which is the one place in Piggy the touch rule was actually
being broken. */}
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chat">This chat</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
</div>
{/* `mt-0` undoes the primitive's default gap: the tab strip already has a
@@ -137,38 +127,41 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
if (!summary.turns) {
return (
<div className="p-4 text-sm leading-6 text-muted">
<p className="font-medium text-fg">Nothing asked yet.</p>
<p className="mt-1">
Every record Piggy reads and every change it proposes will be listed here as the
conversation goes on, so an answer can be checked against the rows behind it.
</p>
</div>
<EmptyState
title="Nothing asked yet"
description="Every record Piggy reads and every change it proposes is listed here, so an answer can be checked against the rows behind it."
/>
);
}
return (
<div className="flex flex-col gap-4 p-3">
<dl className="grid grid-cols-2 gap-2">
<Figure label="Answers" value={String(summary.turns)} />
<Figure
<div className="flex flex-col gap-6 p-3">
<div className="grid grid-cols-2 gap-2">
<Stat size="sm" surface="inset" label="Answers" value={String(summary.turns)} />
<Stat
size="sm"
surface="inset"
label="Tool calls"
value={String(summary.tools.reduce((total, tool) => total + tool.runs, 0))}
/>
<Figure
<Stat
size="sm"
surface="inset"
label="Tokens"
value={`${compactNumber(summary.inputTokens)} / ${compactNumber(summary.outputTokens)}`}
hint="in / out"
/>
<Figure
label="Spend"
value={spendMoney(summary.costMicroCents)}
title={spendTitle(summary.costMicroCents)}
/>
</dl>
{/* The exact figure hangs off a wrapper because the tile itself takes no
`title`: sub-cent spend is rounded for reading and must still be
recoverable to the micro-cent, which is the number an operator
reconciles against. */}
<div className="min-w-0" title={spendTitle(summary.costMicroCents)}>
<Stat size="sm" surface="inset" label="Spend" value={spendMoney(summary.costMicroCents)} />
</div>
</div>
{summary.approvals.length ? (
<Section title="Changes">
<Section title="Changes" tone="micro" level={3}>
<ul className="flex flex-col gap-1.5">
{summary.approvals.map((approval) => (
<li key={approval.change.id}>
@@ -183,7 +176,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
proposed, and calling `pig_log_activity` a record read would be a small
lie on the one panel whose job is the audit trail. */}
{summary.tools.length ? (
<Section title="Tools used">
<Section title="Tools used" tone="micro" level={3}>
<ul className="flex flex-col gap-1">
{summary.tools.map((tool) => (
<li
@@ -192,7 +185,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
>
<FileText aria-hidden className="size-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-fg" title={tool.name}>
{toolLabel(tool.name)}
{piggyToolLabel(tool.name)}
</span>
{tool.failures ? (
<Badge tone="danger">{tool.failures} failed</Badge>
@@ -201,7 +194,7 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
</li>
))}
</ul>
<p className="mt-2 text-[11px] leading-4 text-muted">
<p className="mt-2 text-xs leading-5 text-muted">
Open a step in the transcript to see what each of these returned.
</p>
</Section>
@@ -210,39 +203,6 @@ function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
);
}
function Figure({
label,
value,
hint,
title,
}: {
label: string;
value: string;
hint?: string;
title?: string;
}) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</dt>
<dd className="nums mt-0.5 truncate text-sm font-semibold text-fg" title={title}>
{value}
{hint ? <span className="ml-1 text-[11px] font-normal text-muted">{hint}</span> : null}
</dd>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="min-w-0">
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted">
{title}
</h3>
{children}
</section>
);
}
/**
* A proposed change, at rail width.
*
@@ -258,7 +218,7 @@ function ChangeRow({ approval }: { approval: ApprovalStep }) {
<Icon aria-hidden className={cn('mt-0.5 size-3.5 shrink-0', state.className)} />
<div className="min-w-0 flex-1">
<p className="text-xs leading-5 text-fg">{approval.change.summary}</p>
<p className="mt-0.5 text-[11px] leading-4 text-muted">
<p className="mt-0.5 text-xs leading-5 text-muted">
{state.label}
{recordLabel(approval.change) ? ` · ${recordLabel(approval.change)}` : ''}
</p>
@@ -267,13 +227,21 @@ function ChangeRow({ approval }: { approval: ApprovalStep }) {
);
}
/**
* The same table the approval card is drawn from, and for the same reason:
* colour marks what needs a person, not what happened. This is an index of
* changes, so a column in which every applied write is green leaves the one row
* still waiting for an answer indistinguishable from the four above it.
*/
const CHANGE_STATES: Record<
ApprovalStep['state'],
{ label: string; icon: typeof CheckCircle2; className: string }
> = {
pending: { label: 'Waiting for you', icon: TriangleAlert, className: 'text-warning' },
submitting: { label: 'Sending your decision', icon: TriangleAlert, className: 'text-warning' },
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-positive' },
// A circle for "act on this", a triangle for "this went wrong" — the same two
// shapes `components/status.tsx` spends, so the scan works without hue.
pending: { label: 'Waiting for you', icon: CircleAlert, className: 'text-warning' },
submitting: { label: 'Sending your decision', icon: Loader2, className: 'animate-spin text-muted' },
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-muted' },
rejected: { label: 'Rejected', icon: CircleSlash, className: 'text-muted' },
failed: { label: 'Not applied', icon: TriangleAlert, className: 'text-danger' },
};
@@ -283,16 +251,3 @@ function recordLabel(change: PiggyProposedChange): string | null {
return change.record.label ?? change.record.type.replaceAll('_', ' ');
}
/**
* `pig_get_workspace_summary` → "Workspace summary".
*
* Deliberately mechanical rather than a second copy of the label table in
* `piggy/tool.tsx`: that one exists to name a step in the transcript, where the
* exact wording matters and a missing entry is visible. Here the name is a
* grouping key in a list of counts, and a table kept in two files is a table
* that disagrees with itself the first time a tool is renamed.
*/
function toolLabel(name: string): string {
const words = name.replace(/^pig_/, '').replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : name;
}
@@ -1,11 +1,21 @@
/**
* The first thing anyone sees after signing in.
* The blank transcript, on every Piggy surface.
*
* There used to be two of these. The workspace had this one; the dock, the
* sheet and the phone drawer had a second, narrower one that offered three
* read openers, showed a Sparkles glyph and never once mentioned that Piggy
* can write — the product's headline capability, missing from the surface most
* people keep open all day. One agent gets one front door, so this is now it,
* and `narrow` is what the 22rem column asks for instead of a second file.
*
* It has one job that the old blank transcript did not have: Piggy can write
* now, and nobody will discover that by typing into a box. So the openers are
* in two columns — what it can find out, and what it can get done — and the
* second column says plainly that a change is proposed and waits for a person.
*
* Every sentence here comes from `lib/piggy-copy`. It is written once because
* it was written three times and had already drifted.
*
* The read openers come from `piggySuggestions`, which picks them by the one
* read tool this context resolves to, so every line is one Piggy can ground.
* The write openers are held here because there is no equivalent table for them
@@ -23,9 +33,10 @@
*/
import { ArrowRight, PenLine, Search } from 'lucide-react';
import type { PiggyChatContext, PiggyMode } from '@pig/core';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { piggySuggestions } from '@/lib/piggy-suggestions';
import { PiggyMark } from '@/components/PiggyMark';
import { cn } from '@/components/ui';
import { Label, cn } from '@/components/ui';
/**
* Openers that end in a change to the book.
@@ -69,7 +80,11 @@ export function PiggyWorkspaceStarters({
* the write tools still withheld.
*/
onAskWithChange: (text: string) => void;
/** The middle column is under ~40rem: stack the two groups. */
/**
* The surface is under ~26rem — the dock, the phone drawer, the workspace's
* middle column on a phone. Stacks the two groups, halves the openers and
* takes the short form of every sentence.
*/
narrow?: boolean;
}) {
/*
@@ -89,41 +104,53 @@ export function PiggyWorkspaceStarters({
return (
// `flex-1` rather than `h-full`: the conversation viewport's content element
// is sized by its children, so a percentage height resolves to nothing.
// Centred where there is room to spare, and tight where there is not: at
// 393x852 the six-line version needs every one of these 40 pixels to land
// whole above the composer.
// Centred where there is room to spare, and airless where there is not: on
// a narrow surface this front door has to land whole above the composer at
// 393x852 and in the dock's 22rem column, and it is measured to.
<div
className={cn(
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
narrow ? 'gap-4 py-1' : 'gap-6 py-6',
narrow ? 'gap-3' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
<PiggyMark className={cn('text-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
{/* The agent's mark in the agent's colour. Accent is identity in this
product and never meaning, and this is the one place on the front
door where the identity is the subject. */}
<PiggyMark className={cn('text-accent-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
<h2
className={cn(
'font-semibold tracking-tight',
narrow ? 'mt-2' : 'mt-3',
narrow ? 'text-base' : 'text-lg sm:text-xl',
// Sentence case at the section-heading step in the dock, at the
// page-title step on the full workspace. Nothing between the two:
// the 18px it used to sit at belongs to no step in the scale.
narrow ? 'text-base' : 'text-xl',
)}
>
Ask across the book and now, act on the answer.
{piggyLine(piggyCopy.headline, narrow)}
</h2>
<p className={cn('mt-1.5 max-w-xl text-muted', narrow ? 'text-xs leading-5' : 'text-sm leading-6')}>
{/* Narrow keeps the boundary and drops the mechanism: the headline has
already said a change waits for approval, and the note under the
write openers says it again where it is about to matter. Repeating
it a third time in a 22rem column costs three lines the openers
need to land above the composer. */}
<p className={cn('max-w-xl text-sm text-muted', narrow ? 'mt-1 leading-5' : 'mt-1.5 leading-6')}>
{narrow
? 'Piggy reads your PIG records through scoped tools. Switched to Ask first, it drafts changes for you to approve.'
: 'Piggy reads your PIG records through scoped tools, with no shell, filesystem or browser. Switched to Ask first, it also drafts changes: each one arrives as a card you read and approve, and nothing reaches the book until you do.'}
? piggyLine(piggyCopy.capability, true)
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
</p>
</div>
<div className={cn('grid gap-4', narrow ? 'grid-cols-1' : 'sm:grid-cols-2')}>
<div className={cn('grid', narrow ? 'grid-cols-1 gap-3' : 'gap-4 sm:grid-cols-2')}>
<StarterGroup
narrow={narrow}
icon={<Search aria-hidden className="size-3.5" />}
title="Look something up"
title={piggyLine(piggyCopy.readGroupTitle, narrow)}
/* Dropped on a phone, where the two columns are stacked and every
line costs: the hero above has just said the same thing, and the
note that has to survive is the one about writing. */
note={narrow ? null : 'Answered from your records, with the rows it read attached.'}
note={narrow ? null : piggyLine(piggyCopy.readGroupNote, narrow)}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
@@ -133,15 +160,17 @@ export function PiggyWorkspaceStarters({
</StarterGroup>
<StarterGroup
narrow={narrow}
icon={<PenLine aria-hidden className="size-3.5" />}
title="Get something done"
note={
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
note={piggyLine(
canWrite
? mode === 'read_only'
? 'These switch Piggy to Ask first: it proposes the change, you press Apply. It asks which record if your line does not say.'
: 'Piggy shows you exactly what it would write, and asks which record if your line does not say.'
: 'Your access does not allow changing records, so Piggy can only read.'
}
? piggyCopy.writeGroupNote.readOnly
: piggyCopy.writeGroupNote.askFirst
: piggyCopy.writeGroupNote.noAccess,
narrow,
)}
>
{writes.map((suggestion) => (
<StarterButton
@@ -162,21 +191,31 @@ function StarterGroup({
icon,
title,
note,
narrow,
children,
}: {
icon: React.ReactNode;
title: string;
note: string | null;
/** Measured, not guessed: see the note on the drawer's height below. */
narrow: boolean;
children: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
/*
* The gaps close on a narrow surface rather than the content thinning.
* Measured at 393x852 the phone drawer gives this front door 458px and it
* wanted 498, and the 40px it was over came out of white space rather than
* out of an opener — a column of two questions is the whole point of the
* second column, and one of them is not a choice.
*/
<section className={cn('flex min-w-0 flex-col', narrow ? 'gap-1.5' : 'gap-2')}>
<Label as="h3" className="flex items-center gap-1.5">
{icon}
{title}
</h3>
<div className="flex flex-col gap-1.5">{children}</div>
{note ? <p className="text-[11px] leading-4 text-muted">{note}</p> : null}
</Label>
<div className={cn('flex flex-col', narrow ? 'gap-1' : 'gap-1.5')}>{children}</div>
{note ? <p className="text-xs leading-4 text-muted">{note}</p> : null}
</section>
);
}
@@ -13,11 +13,6 @@
* imported because the browser cannot import from the API package, and every
* field is optional-tolerant on read for the same reason: a row written by an
* older build must reopen as a slightly plainer message, never as a blank pane.
*
* Standing caveat, stated where it will be found: NOTHING WRITES THESE ROWS YET.
* `PiggyConversationService.appendMessage` exists and is tested, and the chat
* relay does not call it see the report. So today every stored conversation
* reopens empty, and this module is the half of the loop that is ready.
*/
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';
@@ -16,20 +16,21 @@
* conversation 230px and keeps the threads reachable in one click.
* < 1024 one column. Both rails become sheets on header buttons, the
* composer keeps the floor of the box, and nothing is stacked above
* the transcript except a header that stays two rows tall.
* the transcript except the header two rows on a phone held
* upright, one on any screen under 560px tall, because there the
* second row is a fifth of everything the reader came for.
*
* The transcript, the composer and the approval cards are `PiggyChatPanel`
* the same component the dock and the phone drawer use so this file is a
* layout and a set of decisions about conversations, not a second chat client.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
AlertTriangle,
History,
Info,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
@@ -38,7 +39,8 @@ import {
} from 'lucide-react';
import type { PiggyChatContext } from '@pig/core';
import { get, post } from '@/lib/api';
import { useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
import { useHasVerticalRoom, useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyContext } from '@/lib/piggy-context';
import type { PiggyConversation, TranscriptMessage } from '@/lib/piggy-chat';
import { PiggyChatPanel, PiggyUnavailable, usePiggyStatus } from '@/components/PiggyChat';
@@ -71,6 +73,12 @@ const ACTIVITY_COLUMN_BREAKPOINT = 1536;
/** Where the history rail is worth showing expanded by default. */
const WIDE_HISTORY_BREAKPOINT = 1280;
/**
* Tailwind `sm`. Above it the title row has room for the mode and model
* controls beside the thread's name; below it, it has not.
*/
const SHARED_TITLE_ROW_BREAKPOINT = 640;
const HISTORY_STORAGE_KEY = 'pig.piggy.workspace.history';
const ACTIVITY_STORAGE_KEY = 'pig.piggy.workspace.activity';
@@ -141,13 +149,25 @@ export function PiggyWorkspace() {
queryKey: conversationKey(activeId ?? ''),
queryFn: () => get<StoredPiggyConversation>(`/api/piggy/conversations/${activeId}`),
enabled: Boolean(activeId),
// The transcript is immutable history plus whatever this tab has since
// added, so refetching it under a live conversation would replace what is
// on screen with what the server had before this turn started.
staleTime: Infinity,
/*
* Always read fresh. The relay appends every turn to this conversation, so
* a cached copy taken before the last three questions is a transcript with
* three turns missing which is what "come back to a thread you were just
* in" looks like. Refetching under a live conversation is safe because the
* hook stops adopting the seed the moment this session sends anything (see
* `touched` in usePiggyConversation).
*/
staleTime: 0,
retry: false,
});
// Stable while the fetch's payload is, so the thread's seed effect is not
// handed a new array on every parent render.
const storedTranscript = useMemo(
() => (detail.data ? toTranscript(detail.data.messages) : undefined),
[detail.data],
);
if (status.isLoading) {
return (
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
@@ -175,26 +195,33 @@ export function PiggyWorkspace() {
);
return (
/*
* The thread is FIRST in the DOM and second on screen.
*
* Measured from a cold load of /piggy: reaching the composer cost 72 Tab
* presses, 38 of them the conversation rail nineteen rows, each with its
* own action button and "Skip to content" landed immediately BEFORE the
* rail rather than after it, so the one affordance built to fix this walked
* straight into the thing that caused it. It grows with use: 123
* conversations exist in this workspace already.
*
* Reordering rather than removing tab stops, because every one of those
* stops is a control somebody needs: the rename and delete actions are
* reachable by keyboard only through the per-row button, and taking it out
* of tab order would trade a long walk for a dead end. `order` is the same
* instrument the delete dialog already uses to put the destructive action
* first in the DOM and second on screen declared reading order and
* declared visual order are allowed to differ, and this is what for.
*
* It also puts the transcript before the navigation for a screen reader,
* which is the arrangement skip links exist to approximate.
*/
<div className="flex h-full min-h-0 w-full overflow-hidden">
{isMobile ? null : (
<aside
className={cn(
'flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
// The rail sets its own 3.75rem when collapsed; only the expanded
// width is the parent's to decide.
historyExpanded && 'w-[17rem]',
)}
aria-label="Piggy conversations"
>
{list}
</aside>
)}
<PiggyWorkspaceThread
key={activeId ?? `new-${newThread}`}
conversationId={activeId}
title={detail.data?.title ?? null}
initialMessages={detail.data ? toTranscript(detail.data.messages) : undefined}
initialMessages={storedTranscript}
loading={Boolean(activeId) && detail.isLoading}
loadError={detail.isError ? detail.error : null}
autoSend={pendingAsk?.id === activeId ? pendingAsk.message : undefined}
@@ -211,13 +238,35 @@ export function PiggyWorkspace() {
activityInColumn={hasActivityColumn}
/>
{isMobile ? null : (
<aside
className={cn(
// `-order-1` puts it back on the left. See the note above.
'-order-1 flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
// The rail sets its own 3.75rem when collapsed; only the expanded
// width is the parent's to decide.
historyExpanded && 'w-[17rem]',
)}
aria-label="Piggy conversations"
>
{list}
</aside>
)}
{/* Both workspace sheets are 20rem the same width as the activity
column beside the transcript. Two overlays on one screen at 19 and
21rem is a difference nobody can name and everybody can see. */}
<Sheet open={historySheet} onOpenChange={setHistorySheet}>
<SheetContent side="left" className="flex w-[19rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="sr-only">
<SheetContent side="left" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
{/* Visible, not `sr-only`. A sheet that slides in over the
transcript with no name on it asks the reader to work out what
they opened from the contents. `pr-14` keeps the sentence clear
of the dismiss control the primitive pins to the corner. */}
<SheetHeader band className="pr-14">
<SheetTitle>Conversations</SheetTitle>
<SheetDescription>Your Piggy history. Pick one to carry on.</SheetDescription>
<SheetDescription>Pick one up from where it stopped.</SheetDescription>
</SheetHeader>
{isMobile ? list : null}
<div className="min-h-0 flex-1">{isMobile ? list : null}</div>
</SheetContent>
</Sheet>
@@ -265,6 +314,31 @@ function PiggyWorkspaceThread({
activityInColumn: boolean;
}) {
const isMobile = useIsMobile();
/*
* Whether this viewport can afford a second header row.
*
* `useIsMobile` is width-only, so a phone in landscape and a phone with the
* keyboard up both got the two-row header built for a 393x852 portrait
* screen and were left with a measured 40px of transcript on the pane the
* whole page exists to show. Below 560px tall the controls fold back onto
* the title row, where they still fit because a landscape phone is wide.
*/
const hasHeaderRoom = useHasVerticalRoom();
/**
* Whether the title row is wide enough to carry the controls as well.
*
* Folding them up is only an improvement where there is width to fold into:
* a landscape phone has 852px and loses nothing, a portrait phone with the
* keyboard up has 393px and would crush the thread's name to four
* characters to save one row. That screen keeps the second row.
*
* Measured, and left alone deliberately: the controls `flex-wrap`, so forcing
* them onto a 393px title row beside three 44px icon buttons does not fold
* anything it wraps them and rebuilds the same two rows with a crushed
* title as well. The transcript budget on that screen is reclaimed from the
* composer and this header's own padding instead; see below and `PiggyChat`.
*/
const canShareTitleRow = useMediaQuery(`(min-width: ${SHARED_TITLE_ROW_BREAKPOINT}px)`);
const { conversation, controls } = usePiggyChatSession({
context: WORKSPACE_CONTEXT,
initialMessages,
@@ -385,10 +459,26 @@ function PiggyWorkspaceThread({
const controlsRow = (
<PiggyControls controls={controls} compact disabled={busy} />
);
/** The controls take a row of their own only on a tall, narrow screen. */
const controlsOnSecondRow = isMobile && (hasHeaderRoom || !canShareTitleRow);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="shrink-0 border-b border-border bg-surface px-2 py-2 sm:px-3">
{/* `lg:px-4` puts the first glyph about 30px from the pane edge, which is
the CRM's own `lg:px-8` content inset once the icon button's optical
padding is counted. The workspace is allowed a toolbar; it is not
allowed to start 8px from an edge every other page starts 32px from. */}
<header
className={cn(
'shrink-0 border-b border-border bg-surface px-2 sm:px-3 lg:px-4',
// Tighter where the transcript is counted in tens of pixels. The
// controls are still 44px; only the air around them gives. `py-0.5`
// rather than `py-1` because a 393x390 keyboard-up screen has 390px
// to divide between a 56px app bar, this header, the composer and the
// thread — and four of those pixels are 1% of the transcript.
hasHeaderRoom ? 'py-2' : 'py-0',
)}
>
<div className="flex min-w-0 items-center gap-2">
{showHistoryToggle ? (
<IconButton
@@ -408,15 +498,15 @@ function PiggyWorkspaceThread({
conversation" would put the sidebar's button's own words in the
title bar. The workspace is called Piggy until the first
question names the thread. */}
<h1 className="truncate text-sm font-semibold tracking-tight">{title ?? 'Piggy'}</h1>
<p className="hidden truncate text-[11px] leading-4 text-muted sm:block">
{conversationId
? 'Running on Prime Agent, with your PIG records and nothing else.'
: 'New conversation. It is filed under your history as soon as you ask.'}
<h1 className="truncate text-base font-semibold leading-tight tracking-tight">
{title ?? 'Piggy'}
</h1>
<p className="hidden truncate text-xs leading-5 text-muted sm:block">
{piggyLine(conversationId ? piggyCopy.threadSaved : piggyCopy.threadNew, isMobile)}
</p>
</div>
{isMobile ? null : controlsRow}
{controlsOnSecondRow ? null : controlsRow}
{showHistoryToggle ? null : (
<IconButton label="Start a new conversation" onClick={onNew}>
@@ -439,12 +529,21 @@ function PiggyWorkspaceThread({
</div>
{/* On a phone the controls take the second row rather than shrinking:
"Ask first" and a model name cannot share 393px with a title. */}
{isMobile ? <div className="mt-2">{controlsRow}</div> : null}
"Ask first" and a model name cannot share 393px with a title. On a
short viewport they do share it, because 52px of chrome is a fifth
of the transcript there. */}
{controlsOnSecondRow ? (
<div className={hasHeaderRoom ? 'mt-2' : 'mt-0.5'}>{controlsRow}</div>
) : null}
</header>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-bg">
{/* No background of its own. The transcript well IS the canvas: the
shell's `.app-canvas` wash sits behind everything, and painting a
flat `bg-bg` over it made the one full-bleed surface in the product
the only place the canvas could not be seen three planes
(rail, canvas, card) collapsing into two. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{loading ? (
<ThreadSkeleton />
) : loadError ? (
@@ -472,10 +571,15 @@ function PiggyWorkspaceThread({
on this page is a badge reading "piggy". The turn still carries
the context the conversation was created with it. */
className="min-h-0 flex-1"
/* Focus follows the thread across the remount that filing it
causes. The first question of a new conversation creates the
stored row, which puts an id in the URL, which rebuilds this
subtree under a new key and the caret the user had just
typed into landed on `<body>`, so the obvious next thing to do
was to reach for the mouse. */
autoFocusComposer={Boolean(autoSend)}
emptyState={
<div className="flex min-h-0 flex-1 flex-col gap-3">
{conversationId ? <ResumedNotice /> : null}
<PiggyWorkspaceStarters
<PiggyWorkspaceStarters
context={WORKSPACE_CONTEXT}
mode={controls.mode}
canWrite={controls.canWrite}
@@ -485,8 +589,7 @@ function PiggyWorkspaceThread({
even with both rails out at 1280 the middle keeps ~700px,
which is two 340px cards. Only the phone stacks them. */
narrow={isMobile}
/>
</div>
/>
}
/>
)}
@@ -503,10 +606,10 @@ function PiggyWorkspaceThread({
</div>
<Sheet open={activitySheet} onOpenChange={setActivitySheet}>
<SheetContent side="right" className="flex w-[21rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="border-b border-border px-4 py-3 pr-14 text-left">
<SheetTitle className="text-sm">Activity</SheetTitle>
<SheetDescription className="text-xs">
<SheetContent side="right" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
<SheetHeader band className="pr-14">
<SheetTitle>Activity</SheetTitle>
<SheetDescription>
What this conversation has touched, and what the workspace has run.
</SheetDescription>
</SheetHeader>
@@ -534,27 +637,6 @@ function askingConversation(
return { ...conversation, send: ask };
}
/**
* A stored conversation that opens with nothing in it.
*
* Which is every stored conversation today: the transcript tables exist and
* `appendMessage` is tested, and the chat relay does not call it yet so a
* thread reopened tomorrow is a title and no words. Saying so is the only
* honest option; showing the front door's openers with no explanation would
* read as history that had been lost.
*/
function ResumedNotice() {
return (
<p className="mx-auto flex w-full max-w-3xl items-start gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs leading-5 text-muted">
<Info aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span>
Nothing is stored in this thread yet Piggy does not write transcripts to your history in
this build. Ask below and it carries on from here.
</span>
</p>
);
}
function ThreadSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>
+481
View File
@@ -0,0 +1,481 @@
/**
* Every status a PIG record can be in, drawn once.
*
* The audit found this file's contents scattered across five others: account
* side and relationship state in `Account.tsx` and again in `Growth.tsx` with
* different tones for the same value, contract status in `Contracts.tsx` and
* again in `Account.tsx` disagreeing about whether "expired" is red, run and
* task state in `activity-panel.tsx`, renewal urgency in `Contracts.tsx`. Two
* implementations of the same badge do not merely duplicate; they contradict,
* and a reader who learns that green means good on one page and nothing on the
* next has learned that colour here is decoration.
*
* So the tones follow one rule, taken from the design direction, and the rule
* is stricter than what any of the five did:
*
* positive a business figure that is good capacity actually running, a
* deal won, an expansion open
* warning something needs a person NOW a notice window already open
* danger a loss, or a failure a person must resolve an account gone, a
* run that failed, an authorisation that has lapsed
* info a neutral time or system fact running, queued, out for signature
* neutral done, fine, nothing to do
*
* The consequence, and it is the point: **process outcomes get no colour.**
* "Executed", "Succeeded", "Closed lost", "Terminated" are all neutral. A
* ledger where every success is green is a ledger nobody scans, and the one
* row that needs a person is invisible in a column of colour.
*
* Colour is never the only signal. Every badge here carries a word, and the
* states that need a person or record a failure carry a mark as well, so the
* scan works without hue: a **circle** for "act on this", a **triangle** for
* "this went wrong". Two different shapes, not two colours of the same one
* lucide's `AlertTriangle` is an alias of `TriangleAlert`, so the obvious pair
* drew the identical glyph twice and the distinction existed only in the hue it
* was supposed to be independent of. Nothing else gets an icon: a badge set
* where every chip has a glyph is a badge set with no emphasis left to spend.
*/
import { CircleAlert, TriangleAlert } from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
SUPPLY_STAGE_LABELS,
type AccountSide,
type ContractStatus,
type CustomerRelationshipState,
type DemandStage,
type GrowthFacet,
type SupplyStage,
} from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { shortDate } from '@/lib/api';
/** The tones `Badge` understands. `accent` is deliberately absent: the accent
* is PIG's identity colour and never carries meaning. */
export type StatusTone = 'neutral' | 'positive' | 'warning' | 'danger' | 'info';
/**
* The two marks, sized to the 12px badge text.
*
* Lucide ships icons at 24px and `Badge` does not size its children, so an
* unsized glyph in a badge renders twice the height of the word beside it
* which is what `Contracts.tsx` and `Growth.tsx` were both doing.
*/
function ActMark() {
return <CircleAlert className="size-3.5" aria-hidden />;
}
function FailMark() {
return <TriangleAlert className="size-3.5" aria-hidden />;
}
/** Underscored enum value to a readable word, for the values with no label map. */
function humanise(value: string): string {
const words = value.replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : value;
}
// --------------------------------------------------------------- account side
/**
* Which side of the book an account sits on.
*
* All three are neutral. Side is identity, not status nothing about being a
* supplier is good or bad or needs anybody and the words already tell them
* apart. `Account.tsx` and `Accounts.tsx` were both painting supply blue and
* both sides accent, which spent two of the five tones on a fact that changes
* nothing a reader would do.
*/
export const SIDE_TONES: Record<AccountSide, StatusTone> = {
supply: 'neutral',
demand: 'neutral',
both: 'neutral',
};
export const SIDE_LABELS: Record<AccountSide, string> = {
supply: 'Buy-side',
demand: 'Sell-side',
both: 'Both sides',
};
export function SideBadge({ side, className }: { side: AccountSide; className?: string }) {
return (
<Badge tone={SIDE_TONES[side]} className={cn('shrink-0', className)}>
{SIDE_LABELS[side]}
</Badge>
);
}
// -------------------------------------------------------- customer lifecycle
/**
* Where a customer relationship stands.
*
* `deployed` is the only good one: capacity is actually running, which is the
* figure the business is built on. `former_customer` is a loss and says so.
* `prospect` and `contracted` are stages on the way, and a stage is not news.
*/
export const RELATIONSHIP_TONES: Record<CustomerRelationshipState, StatusTone> = {
prospect: 'neutral',
contracted: 'neutral',
deployed: 'positive',
former_customer: 'danger',
};
export const RELATIONSHIP_LABELS: Record<CustomerRelationshipState, string> = {
prospect: 'Prospect',
contracted: 'Contracted',
deployed: 'Deployed',
former_customer: 'Former customer',
};
export function RelationshipBadge({
state,
className,
}: {
state: CustomerRelationshipState;
className?: string;
}) {
return (
<Badge tone={RELATIONSHIP_TONES[state]} className={className}>
{RELATIONSHIP_LABELS[state]}
</Badge>
);
}
// ---------------------------------------------------------------- growth facet
/**
* Why an account is on the Growth page.
*
* The old version coloured all six, which made the page a mosaic. Here a
* deadline and a risk are the only things that get a person's attention, and
* the hygiene facets a coverage gap, stale data are grey. Stale data was
* `warning` before, competing for the eye with `at_risk` on the same row.
*/
export const FACET_TONES: Record<GrowthFacet, StatusTone> = {
expansion_candidate: 'positive',
renewal_due: 'warning',
at_risk: 'danger',
idle_supply_match: 'info',
coverage_gap: 'neutral',
data_stale: 'neutral',
};
export const FACET_LABELS: Record<GrowthFacet, string> = {
expansion_candidate: 'Expansion candidate',
renewal_due: 'Renewal due',
at_risk: 'At risk',
idle_supply_match: 'Idle supply match',
coverage_gap: 'Coverage gap',
data_stale: 'Data stale',
};
export function FacetBadge({ facet, className }: { facet: GrowthFacet; className?: string }) {
return (
<Badge tone={FACET_TONES[facet]} className={className}>
{facet === 'renewal_due' ? <ActMark /> : facet === 'at_risk' ? <FailMark /> : null}
{FACET_LABELS[facet]}
</Badge>
);
}
// -------------------------------------------------------------------- deals
/**
* Pipeline stage, both sides.
*
* Won and live are business-good; everything in flight is process and stays
* grey, which is the change from the accent-coloured pipeline the pages drew
* before. `churned` is red where `closed_lost` is not: losing a relationship
* is a loss worth marking, losing one deal out of a pipeline of them is the
* ordinary shape of the job.
*/
export const DEMAND_STAGE_TONES: Record<DemandStage, StatusTone> = {
qualification: 'neutral',
legal: 'neutral',
scoping: 'neutral',
proposal: 'neutral',
procurement: 'neutral',
poc: 'neutral',
deployment: 'neutral',
expansion: 'neutral',
closed_won: 'positive',
closed_lost: 'neutral',
};
export const SUPPLY_STAGE_TONES: Record<SupplyStage, StatusTone> = {
sourced: 'neutral',
qualifying: 'neutral',
technical_diligence: 'neutral',
financial_diligence: 'neutral',
pricing: 'neutral',
contracting: 'neutral',
onboarding: 'neutral',
live: 'positive',
renewal: 'warning',
churned: 'danger',
rejected: 'neutral',
};
export type DealStageBadgeProps =
| { side: 'demand'; stage: DemandStage; className?: string }
| { side: 'supply'; stage: SupplyStage; className?: string };
/**
* Not named in the direction's component table, but the audit counted deal
* stage among the domains drawn several ways, and `Account.tsx` holds two
* private tone functions for it. It belongs with the rest of the vocabulary.
*/
export function DealStageBadge(props: DealStageBadgeProps) {
const { tone, label } =
props.side === 'demand'
? { tone: DEMAND_STAGE_TONES[props.stage], label: DEMAND_STAGE_LABELS[props.stage] }
: { tone: SUPPLY_STAGE_TONES[props.stage], label: SUPPLY_STAGE_LABELS[props.stage] };
return (
<Badge tone={tone} className={props.className}>
{props.side === 'supply' && props.stage === 'renewal' ? <ActMark /> : null}
{label}
</Badge>
);
}
// ---------------------------------------------------------------- contracts
/**
* Paper state.
*
* `executed` is neutral, which is the tone change most likely to be questioned.
* It is a process outcome the paper is signed, nothing follows from it today
* and on the contracts list most rows are executed, so colouring it green
* paints the whole table and leaves the expiring one indistinguishable.
* `out_for_signature` is `info` rather than `warning` because it is waiting on
* the counterparty, not on us.
*/
export const CONTRACT_STATUS_TONES: Record<ContractStatus, StatusTone> = {
draft: 'neutral',
in_review: 'neutral',
in_negotiation: 'neutral',
out_for_signature: 'info',
executed: 'neutral',
expired: 'danger',
terminated: 'neutral',
};
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
draft: 'Draft',
in_review: 'In review',
in_negotiation: 'Negotiating',
out_for_signature: 'For signature',
executed: 'Executed',
expired: 'Expired',
terminated: 'Terminated',
};
export function ContractStatusBadge({
status,
className,
}: {
status: ContractStatus;
className?: string;
}) {
return (
<Badge tone={CONTRACT_STATUS_TONES[status]} className={className}>
{status === 'expired' ? <FailMark /> : null}
{CONTRACT_STATUS_LABELS[status]}
</Badge>
);
}
// ------------------------------------------------------------------ renewals
/** Mirrors `RenewalState` in `apps/api/src/services/contracts.ts`, which the
* web app cannot import. Expiry minus notice days against today. */
export type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired';
export const RENEWAL_TONES: Record<RenewalState, StatusTone> = {
not_applicable: 'neutral',
scheduled: 'neutral',
due: 'warning',
expired: 'danger',
};
/**
* Renewal urgency, which is the one status in the product that is worth money
* on a deadline: a notice window that quietly opened last week is the most
* expensive thing in this book to miss.
*
* Only the two states that need a person are drawn as badges. A contract with
* no alarm, or one whose notice is months away, is fine print this appears in
* a column, and forty grey chips down a table are forty things to look past
* before finding the two amber ones. That restraint is `Contracts.tsx`'s
* original design and it is kept deliberately rather than regularised away.
*/
export function RenewalBadge({
state,
noticeAt,
className,
}: {
state: RenewalState;
/** The computed notice date, shown when it is still ahead. */
noticeAt?: string | Date | null;
className?: string;
}) {
if (state === 'due') {
return (
<Badge tone="warning" className={className}>
<ActMark />
Notice due
</Badge>
);
}
if (state === 'expired') {
return (
<Badge tone="danger" className={className}>
<FailMark />
Expired
</Badge>
);
}
return (
<span className={cn('text-xs text-muted', className)}>
{state === 'not_applicable'
? 'No alarm'
: noticeAt
? `Notice ${shortDate(noticeAt)}`
: 'Notice scheduled'}
</span>
);
}
// ------------------------------------------------------------ agent activity
/**
* A run's status is free text on the wire `agent_runs.status` is a `text`
* column so an unrecognised value is shown as it arrived, in neutral, rather
* than forced into one of the four we know. A status this panel has never heard
* of is information, not an error.
*
* `aborted` reads "Stopped by you" because that is what it means: the reader
* pressed Stop, or navigated away. "Aborted" describes the process; the person
* wants to know whether it was them.
*/
export const RUN_STATUS_TONES: Record<string, StatusTone> = {
running: 'info',
awaiting_approval: 'warning',
succeeded: 'neutral',
aborted: 'neutral',
failed: 'danger',
};
export const RUN_STATUS_LABELS: Record<string, string> = {
running: 'Running',
awaiting_approval: 'Needs you',
succeeded: 'Succeeded',
aborted: 'Stopped by you',
failed: 'Failed',
};
export function RunStatusBadge({ status, className }: { status: string; className?: string }) {
const tone = RUN_STATUS_TONES[status] ?? 'neutral';
return (
<Badge tone={tone} className={className}>
{status === 'failed' ? <FailMark /> : status === 'awaiting_approval' ? <ActMark /> : null}
{RUN_STATUS_LABELS[status] ?? humanise(status)}
</Badge>
);
}
/** Mirrors `PiggyTaskSummary['state']`: the three live states the queue derives
* plus `AGENT_TASK_OUTCOMES`. */
export type TaskState =
| 'running'
| 'queued'
| 'scheduled'
| 'succeeded'
| 'failed'
| 'skipped'
| 'cancelled';
export const TASK_STATE_TONES: Record<TaskState, StatusTone> = {
running: 'info',
queued: 'info',
scheduled: 'info',
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
export const TASK_STATE_LABELS: Record<TaskState, string> = {
running: 'Running',
queued: 'Queued',
scheduled: 'Scheduled',
succeeded: 'Succeeded',
failed: 'Failed',
skipped: 'Skipped',
cancelled: 'Cancelled',
};
export function TaskStateBadge({ state, className }: { state: TaskState; className?: string }) {
return (
<Badge tone={TASK_STATE_TONES[state]} className={className}>
{state === 'failed' ? <FailMark /> : null}
{TASK_STATE_LABELS[state]}
</Badge>
);
}
// ------------------------------------------------------------------ approval
/** The five states of a proposed write. Structurally identical to
* `PiggyApprovalState` in `piggy/approval-card.tsx`, which owns the
* transitions; declared here so this module does not depend on the card. */
export type ApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
/**
* The decided table from the design direction.
*
* `applied` is neutral, not green the confirmation the reader wants is a
* single positive check beside the record link in the card's status line, not a
* green block in a transcript where every approved write would then be green.
* `pending` is the only warning, and it is warning because it is the only state
* in the product where the agent has stopped and is waiting for a person.
*/
export const APPROVAL_STATE_TONES: Record<ApprovalState, StatusTone> = {
pending: 'warning',
submitting: 'neutral',
applied: 'neutral',
rejected: 'neutral',
failed: 'danger',
};
export function ApprovalStateBadge({
state,
/** Which way the person answered, so `submitting` can say which. */
decision,
className,
}: {
state: ApprovalState;
decision?: 'apply' | 'reject' | null;
className?: string;
}) {
const label =
state === 'pending'
? 'Needs you'
: state === 'submitting'
? decision === 'reject'
? 'Rejecting'
: 'Applying'
: state === 'applied'
? 'Applied'
: state === 'rejected'
? 'Rejected'
: 'Not applied';
return (
<Badge tone={APPROVAL_STATE_TONES[state]} className={cn('shrink-0', className)}>
{state === 'pending' ? <ActMark /> : state === 'failed' ? <FailMark /> : null}
{label}
</Badge>
);
}
+184
View File
@@ -0,0 +1,184 @@
/**
* A confirmation that has to be read.
*
* An alert dialog is not a dialog with different copy. It refuses to be
* dismissed by clicking past it, it opens with focus on the safe choice, and it
* names the thing it is about to destroy because the surface it replaces was
* a plain `Dialog` whose default focus landed on the delete button and whose
* scrim dismissed a decision the person had not made.
*
* Built on `@radix-ui/react-dialog` with `role="alertdialog"` rather than
* `@radix-ui/react-alert-dialog`, which is not a dependency of this app. The
* three behaviours that package adds are the three declared below, so the
* component is the contract, not the package.
*/
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { Button, type ButtonProps } from "@/components/ui"
import {
DialogDescription,
DialogHeader,
DialogOverlay,
DialogTitle,
useOverlayFocusRestore,
} from "@/components/ui/dialog"
const AlertDialog = DialogPrimitive.Root
const AlertDialogTrigger = DialogPrimitive.Trigger
const AlertDialogPortal = DialogPrimitive.Portal
const AlertDialogOverlay = DialogOverlay
/*
* The cancel control registers itself here so the content can put initial
* focus on it. It has to be a registration rather than "focus the first
* focusable", because the destructive action deliberately comes first in the
* DOM see AlertDialogFooter and a destructive button holding focus the
* moment the dialog opens is one Enter away from the thing the dialog exists
* to prevent.
*/
const AlertDialogCancelContext =
React.createContext<React.MutableRefObject<HTMLButtonElement | null> | null>(null)
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(
(
{
className,
children,
onCloseAutoFocus,
onOpenAutoFocus,
onInteractOutside,
...props
},
ref
) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<DialogPrimitive.Content
ref={focus.ref}
role="alertdialog"
onCloseAutoFocus={focus.onCloseAutoFocus}
onOpenAutoFocus={(event) => {
onOpenAutoFocus?.(event)
if (event.defaultPrevented) return
const cancel = cancelRef.current
if (!cancel) return
event.preventDefault()
cancel.focus()
}}
onInteractOutside={(event) => {
onInteractOutside?.(event)
// A decision is not made by clicking somewhere else.
event.preventDefault()
}}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
>
<AlertDialogCancelContext.Provider value={cancelRef}>
{children}
</AlertDialogCancelContext.Provider>
</DialogPrimitive.Content>
</AlertDialogPortal>
)
}
)
AlertDialogContent.displayName = "AlertDialogContent"
const AlertDialogHeader = DialogHeader
/**
* Destructive first in the DOM, last on the screen.
*
* A screen reader reads the footer in source order, and the consequence has to
* arrive before the escape from it. Sighted order is the platform convention
* safe choice on the left, the commit on the right and is restored with
* `order`, which changes the painting and not the reading.
*/
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = DialogTitle
const AlertDialogDescription = DialogDescription
/**
* The commit. Destructive by default: this component exists for the deletes,
* and a confirmation whose commit button looks like every other button is a
* confirmation nobody reads. Pass `variant` for the non-destructive cases.
*/
const AlertDialogAction = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "danger", type = "button", ...props }, ref) => (
<DialogPrimitive.Close asChild>
<Button
ref={ref}
type={type}
variant={variant}
className={cn("order-1 sm:order-2", className)}
{...props}
/>
</DialogPrimitive.Close>
)
)
AlertDialogAction.displayName = "AlertDialogAction"
const AlertDialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "outline", type = "button", ...props }, ref) => {
const registry = React.useContext(AlertDialogCancelContext)
const setRef = React.useCallback(
(node: HTMLButtonElement | null) => {
if (registry) registry.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref, registry]
)
return (
<DialogPrimitive.Close asChild>
<Button
ref={setRef}
type={type}
variant={variant}
className={cn("order-2 sm:order-1", className)}
{...props}
/>
</DialogPrimitive.Close>
)
}
)
AlertDialogCancel.displayName = "AlertDialogCancel"
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+121 -24
View File
@@ -12,6 +12,95 @@ const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
/**
* Focus capture and restore, for every overlay in the product.
*
* Radix returns focus to whatever opened the overlay only when it can still
* find it, and on several of PIG's surfaces it cannot: sheets opened
* programmatically have no `SheetTrigger` at all, and the palette's opener is a
* control the next route unmounts. Measured, Escape from nine overlays left
* focus on `<body>` on `/piggy` that is 121 Tab presses back to where the
* person was, which is a keyboard trap wearing a dismissal.
*
* The pattern was written once at `CommandPalette.tsx` and is hoisted here so
* every dialog, sheet, drawer and alert inherits it without a call-site change.
*
* The capture hangs off the content element's ref rather than the wrapper's
* first render, and that distinction is the whole component. `SheetContent`
* renders on every render of the page that declares it Radix gates on open
* *below* it, inside the portal so reading `document.activeElement` while our
* own function body runs reads it at page load, which is `<body>`, which is the
* bug being fixed. A ref callback fires only when the content genuinely mounts,
* and refs are attached earlier in the commit than the effect Radix's
* FocusScope uses to move focus into the overlay.
*/
export function useOverlayFocusRestore<T extends HTMLElement>(
forwardedRef: React.ForwardedRef<T>,
onCloseAutoFocus?: (event: Event) => void,
): {
ref: (node: T | null) => void
onCloseAutoFocus: (event: Event) => void
} {
const opener = React.useRef<HTMLElement | null>(null)
const captured = React.useRef(false)
const forwarded = React.useRef(forwardedRef)
forwarded.current = forwardedRef
// Deliberately stable: a ref callback that changed identity would be called
// with null and then the node again mid-open, and the second capture would
// read a control inside the overlay as the opener.
const ref = React.useCallback((node: T | null) => {
if (node) {
if (!captured.current) {
captured.current = true
const active = document.activeElement
opener.current =
active instanceof HTMLElement && active !== document.body ? active : null
}
} else {
// Armed for the next open. `opener` itself survives, because the close
// handler below runs in a passive effect cleanup — after React has
// already detached this ref.
captured.current = false
}
const target = forwarded.current
if (typeof target === "function") target(node)
else if (target) target.current = node
}, [])
const handleCloseAutoFocus = React.useCallback(
(event: Event) => {
onCloseAutoFocus?.(event)
// The call site wins. CommandPalette has its own restore for the case
// where choosing an item navigates away from the control that opened it.
if (event.defaultPrevented) return
const target = opener.current
// `isConnected` because closing may have navigated, leaving the opener
// detached — focusing a node in no document does nothing.
//
// The fallback is the page's own content landmark rather than Radix's,
// which lands on `<body>`. That case is real and now reachable: an
// account's contract row navigates to `/contracts?contract=…`, which
// opens the detail sheet on a page the opener never existed on, so
// dismissing it dropped the keyboard back to the top of the document.
// `#page-content` is the same target the skip link uses and already
// carries `tabIndex={-1}` for exactly this.
if (!target || !target.isConnected) {
const content = document.getElementById('page-content')
if (!content) return
event.preventDefault()
content.focus()
return
}
event.preventDefault()
target.focus()
},
[onCloseAutoFocus],
)
return { ref, onCloseAutoFocus: handleCloseAutoFocus }
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -19,7 +108,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
@@ -30,25 +119,29 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-1 top-1 flex size-[44px] items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border bg-surface p-5 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-2 top-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -57,7 +150,9 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
// Left-aligned at every width. The shadcn default centres below `sm`,
// so the same dialog read as a different component on a phone.
"flex flex-col gap-1 text-left",
className
)}
{...props}
@@ -71,7 +166,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
@@ -86,7 +181,9 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
// The section-heading role: 16px/600. An overlay title is not a page
// title, and overlay chrome does not vary by feature.
"text-base font-semibold leading-tight tracking-tight text-fg",
className
)}
{...props}
@@ -100,7 +197,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+92
View File
@@ -0,0 +1,92 @@
import { useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A fold, built on `<details>` so the browser gives it keyboard and
* screen-reader semantics for free.
*
* Four of these existed reasoning, tool steps, a rejected proposal, an
* activity group and each had independently rediscovered the same two
* browser facts:
*
* - `list-style: none` removes Chrome's marker but not Safari's, which draws
* its own from `::-webkit-details-marker`. Without both rules one browser
* shows two triangles.
* - A flex `<summary>` drops the native marker in Chrome and keeps it in
* Firefox, so the chevron has to be an element we render ourselves.
*
* `onExpand` receives the element at the one moment worth having: inside the
* click handler, where `open` still holds its pre-click value. That is how a
* transcript pinned to its newest message can tell an expansion from a
* collapse and scroll the revealed content back into view an expansion adds
* height below the fold, and a follow-the-tail scroller reads that as new
* content and jumps past the very thing the user asked to see.
*/
export function Disclosure({
summary,
children,
onExpand,
defaultOpen = false,
open,
onOpenChange,
className,
summaryClassName,
contentClassName,
...props
}: Omit<ComponentPropsWithoutRef<'details'>, 'onToggle' | 'open' | 'children' | 'className'> & {
summary: ReactNode;
children: ReactNode;
/** Called with the `<details>` element as it is about to open, never as it closes. */
onExpand?: (element: HTMLDetailsElement) => void;
defaultOpen?: boolean;
/** Supply with `onOpenChange` to drive the fold from outside. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
summaryClassName?: string;
contentClassName?: string;
}) {
const ref = useRef<HTMLDetailsElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const isOpen = open ?? uncontrolledOpen;
return (
<details
ref={ref}
open={isOpen}
onToggle={(event) => {
const next = event.currentTarget.open;
if (open === undefined) setUncontrolledOpen(next);
onOpenChange?.(next);
}}
className={cn('group min-w-0', className)}
{...props}
>
<summary
onClick={() => {
const element = ref.current;
// `open` is still the pre-click value here, so `false` means the
// click is about to open it.
if (element && !element.open) onExpand?.(element);
}}
className={cn(
'flex min-h-11 min-w-0 cursor-pointer list-none items-center gap-2 py-2',
'text-sm font-medium transition-colors duration-1 ease-enter hover:text-fg',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand',
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
'[&::-webkit-details-marker]:hidden',
summaryClassName,
)}
>
<ChevronRight
className="size-4 shrink-0 text-muted transition-transform duration-1 ease-enter group-open:rotate-90"
aria-hidden
/>
<span className="min-w-0 flex-1">{summary}</span>
</summary>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
</details>
);
}
+38 -23
View File
@@ -2,6 +2,7 @@ import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
import { useOverlayFocusRestore } from "@/components/ui/dialog"
const Drawer = ({
shouldScaleBackground = true,
@@ -35,22 +36,39 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
>(({ className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(
// The bottom inset is the drawer's own: it is anchored to the edge of
// the screen the home indicator sits on, and the drawer's last child
// is a composer. Measured at 393×852, its Send button finished 13px
// off-screen.
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-2xl border border-border bg-surface pb-[var(--safe-bottom)]",
className
)}
{...props}
>
{/*
A plain element, not `DrawerPrimitive.Handle`: vaul drags from
anywhere in the content that is not marked no-drag, and the handle
primitive brings its own hit area and sizing. This one is the grip
mark only.
*/}
<div
aria-hidden
className="mx-auto mt-4 h-2 w-[100px] shrink-0 rounded-full bg-border"
/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
})
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
@@ -58,7 +76,7 @@ const DrawerHeader = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
className={cn("flex min-w-0 flex-col gap-1 px-5 py-4 text-left", className)}
{...props}
/>
)
@@ -69,7 +87,7 @@ const DrawerFooter = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
className={cn("mt-auto flex flex-col gap-2 px-5 py-4", className)}
{...props}
/>
)
@@ -81,10 +99,7 @@ const DrawerTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className={cn("text-base font-semibold leading-tight text-fg", className)}
{...props}
/>
))
@@ -96,7 +111,7 @@ const DrawerDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+66
View File
@@ -0,0 +1,66 @@
import { cloneElement, isValidElement, useId, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui';
type ControlProps = { id?: string; 'aria-label'?: string; 'aria-describedby'?: string };
/**
* A labelled control.
*
* The id is cloned onto the *control*, never onto a wrapper: a `<label for>`
* pointing at a `<div>` associates with nothing, and the screen reader then
* reads an unlabelled input. Four copies of this wrapper existed, two of them
* byte-identical, and all four carried a subtler version of the same bug
* they gave the control `children.props.id ?? id` while pointing the label at
* `id` unconditionally, so any control that already had an id of its own ended
* up with a label addressing an element that did not exist.
*
* The hint is wired through `aria-describedby` rather than left as loose text
* beneath, because a hint that only sighted users receive is not a hint, it is
* decoration.
*/
export function FormField({
label,
hint,
children,
className,
}: {
label: string;
hint?: ReactNode;
children: ReactNode;
className?: string;
}) {
const generated = useId();
const hintId = `${generated}-hint`;
const element = isValidElement<ControlProps>(children) ? children : null;
const controlId = element?.props.id ?? generated;
const control = element
? cloneElement(element, {
id: controlId,
// The visible label is the accessible name via `for`/`id`; the
// `aria-label` is a belt-and-braces fallback for controls that render
// a button rather than a form element (Radix Select, for one) where
// `for` does not always carry.
'aria-label': element.props['aria-label'] ?? label,
'aria-describedby':
hint == null ? element.props['aria-describedby'] : (element.props['aria-describedby'] ?? hintId),
})
: children;
return (
<div className={cn('flex min-w-0 flex-col gap-1.5', className)}>
<Label as="label" htmlFor={controlId}>
{label}
</Label>
{control}
{hint == null ? null : (
<p id={hintId} className="min-w-0 text-xs text-muted">
{hint}
</p>
)}
</div>
);
}
+265 -16
View File
@@ -11,11 +11,15 @@ import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
forwardRef,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type LabelHTMLAttributes,
type ReactNode,
} from 'react';
@@ -42,8 +46,17 @@ export function cn(...inputs: ClassValue[]): string {
*/
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
'transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 ' +
'[&_svg]:shrink-0 ' +
/*
* The ring is declared here as well as in the global `:focus-visible` rule.
* A button that also carries a local `focus-visible:ring-*` class beat the
* base rule on specificity and painted the near-invisible subtle accent;
* declaring it in the cva puts it in the same cascade layer as those
* overrides, so `cn()` merging resolves it rather than the stylesheet.
*/
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand ' +
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
// touch-manipulation removes the 300ms tap delay that older mobile Safari
// applies while waiting to see whether a tap is a double-tap zoom.
'touch-manipulation select-none whitespace-nowrap',
@@ -179,28 +192,191 @@ export function Badge({
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
}
// --------------------------------------------------------------------- stat
// -------------------------------------------------------------- micro label
/**
* A single headline number.
* The one small-caps label in the product.
*
* There were five of these 10px, 11px and 12px, `tracking-wide`,
* `tracking-wider` and `tracking-[0.12em]`, `font-medium` and `font-semibold`
* across 58 hand-written call sites, which is why no two panels' labels
* lined up. 11px at 0.06em was the majority reading and the one that survives
* the sidebar rail's width.
*
* `as` exists because the same label is a `dt` in a definition list, a `th` in
* a table head and a `span` inside a flex row; rendering all three as a `div`
* is how a table stops being a table for a screen reader.
*/
const MICRO_LABEL =
'text-[11px] font-medium uppercase leading-tight tracking-[0.06em] text-muted';
export function Label({
as: Component = 'div',
className,
...props
}: LabelHTMLAttributes<HTMLElement> & {
as?: 'div' | 'span' | 'p' | 'dt' | 'th' | 'legend' | 'label' | 'h2' | 'h3' | 'h4';
}) {
return (
<Component className={cn(MICRO_LABEL, className)} {...props} />
);
}
// ------------------------------------------------------------------ section
/**
* A titled group of content: the panel heading, its count, its action, and
* optionally a chevron that folds it away.
*
* Six of these existed at 11px-uppercase through 16px-sentence-case, so a page
* built from three of them read as three products. `tone` is the only choice
* left: `panel` owns a card, `micro` names a group inside one.
*/
export function Section({
title,
description,
count,
action,
level = 3,
tone = 'panel',
collapsible = false,
defaultOpen = true,
className,
children,
}: {
title: ReactNode;
description?: ReactNode;
/** Rendered beside the title as a tabular figure. `0` still renders. */
count?: number;
action?: ReactNode;
level?: 2 | 3 | 4;
tone?: 'panel' | 'micro';
collapsible?: boolean;
defaultOpen?: boolean;
className?: string;
children?: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
const Heading = ({ 2: 'h2', 3: 'h3', 4: 'h4' } as const)[level];
const titleClass =
tone === 'panel' ? 'text-base font-semibold leading-tight text-fg' : MICRO_LABEL;
const heading = (
<>
<span className={cn('min-w-0 break-words', titleClass)}>{title}</span>
{count == null ? null : <span className="nums shrink-0 text-sm text-muted">{count}</span>}
</>
);
return (
<section className={cn('min-w-0', className)}>
<div className={cn('flex min-w-0 items-start gap-2', collapsible ? '' : 'py-0.5')}>
<Heading className="min-w-0 flex-1">
{collapsible ? (
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className="flex min-h-11 w-full items-center gap-2 rounded-lg text-left transition-colors duration-1 ease-enter hover:text-fg"
>
<ChevronRight
className={cn(
'size-4 shrink-0 text-muted transition-transform duration-1 ease-enter',
open && 'rotate-90',
)}
aria-hidden
/>
{heading}
</button>
) : (
<span className="flex min-w-0 items-baseline gap-2">{heading}</span>
)}
</Heading>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{description ? (
<p className={cn('mt-1 min-w-0 text-sm text-muted', collapsible && 'pl-6')}>{description}</p>
) : null}
{children != null && (!collapsible || open) ? (
<div className={cn('mt-2 min-w-0', tone === 'panel' && !collapsible && 'mt-3')}>
{children}
</div>
) : null}
</section>
);
}
// --------------------------------------------------------------------- stat
const statValueSizes = {
/** Inline tile inside a panel. */
sm: 'text-sm',
/** A figure a panel is about. */
md: 'text-lg',
/** The page's headline number. */
lg: 'text-2xl sm:text-3xl',
} as const;
const statSurfaces = {
/** The default, and the only one that existed: a card of its own. */
card: 'card p-4',
/** A tile on `bg-surface-2` inside a card. No border, no shadow. */
inset: 'rounded-md bg-surface-2 p-2.5',
/** No surface at all — the caller owns the container. */
bare: '',
} as const;
/**
* A single labelled number.
*
* `nums` applies tabular figures so a value does not jitter horizontally as it
* updates which it does, on a dashboard that refreshes while someone is
* looking at it.
*
* `min-w-0 break-words` is on the value, not left to the caller: this tile is
* always a grid child, a grid child refuses to shrink below its content, and
* the content is an unbreakable currency string. The measured failure was
* `GROSS MARGIN $658,194.3` a digit short on any phone under 400px, and
* the same figure colliding with the next card at 1440 with the Piggy dock
* open. A truncated financial figure is worse than no figure.
*
* That stopped the clipping and traded it for a second failure nobody measured,
* because `scrollWidth === clientWidth` is true of a number that has WRAPPED:
* `$658,194.` on one line and `37` on the next, at every width under about
* 220px a phone, and 1440 with the dock open. So the tile is now a container
* query context (`stat-tile`) and the `lg` figure carries `stat-figure-lg`,
* which steps 30 24 22 20 18 16px as the tile narrows. See the
* measured ladder in `index.css`. Every tile in a grid row is the same width,
* so a row steps together; `break-words` stays as the last-resort net.
*
* An absent value renders a muted em dash rather than the tone colour: "—" in
* danger red reads as a number that went wrong rather than one nobody has.
*/
export function Stat({
label,
value,
hint,
tone,
size = 'lg',
surface = 'card',
href,
className,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
tone?: 'positive' | 'warning' | 'danger' | 'default';
size?: 'sm' | 'md' | 'lg';
surface?: 'card' | 'inset' | 'bare';
/** Makes the whole tile a router link to the page that explains the figure. */
href?: string;
className?: string;
}) {
const toneClass =
tone === 'positive'
const absent = value == null || value === '' || value === '—' || value === '-';
const toneClass = absent
? 'text-muted'
: tone === 'positive'
? 'text-positive'
: tone === 'warning'
? 'text-warning'
@@ -208,14 +384,52 @@ export function Stat({
? 'text-danger'
: 'text-fg';
return (
<div className="card p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
const body = (
<>
<Label>{label}</Label>
<div
className={cn(
'nums min-w-0 break-words font-semibold leading-tight',
size === 'sm' ? 'mt-0.5' : 'mt-1',
statValueSizes[size],
// Only the page KPI step is container-scaled; `sm` and `md` are
// already small enough that no figure in the product wraps them.
size === 'lg' && 'stat-figure-lg',
toneClass,
)}
>
{absent ? '—' : value}
</div>
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
</div>
{hint ? <div className="mt-1 min-w-0 text-xs text-muted">{hint}</div> : null}
</>
);
const shell = cn(
'block min-w-0',
/*
* The container context is declared ONLY on the page-KPI step, and that is
* a measured constraint rather than tidiness. `container-type: inline-size`
* carries `contain: layout style inline-size`, which suppresses a box's
* content-based intrinsic contribution so putting it on every `Stat`
* changed how the `sm` and `md` tiles inside Growth's account cards
* negotiated width with the flex rows around them, and /growth began
* overflowing its viewport by 32px at 393 and 12px at 1440. Zero horizontal
* overflow is the product's oldest measured guarantee; a nicer number is
* not worth spending it. The `lg` tiles are grid children with declared
* tracks, where the contribution is not what decides the width.
*/
size === 'lg' && 'stat-tile',
statSurfaces[surface],
href && 'transition-colors duration-1 ease-enter hover:bg-surface-2',
className,
);
return href ? (
<Link to={href} className={shell}>
{body}
</Link>
) : (
<div className={shell}>{body}</div>
);
}
@@ -227,24 +441,59 @@ export function Skeleton({ className }: { className?: string }) {
// --------------------------------------------------------------- empty state
/**
* Nothing here, said once.
*
* Seven bespoke empty states stood beside this one, differing only in how much
* vertical room they took: a panel's worth of padding inside a 120px list row
* pushes the thing below it off the screen. `size` is that decision and the
* only one `panel` is the historic rendering and stays the default.
*/
export function EmptyState({
icon,
title,
description,
action,
size = 'panel',
className,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
/** `inline` inside a list or a card body, `panel` for a card, `page` for a route. */
size?: 'inline' | 'panel' | 'page';
className?: string;
}) {
const box =
size === 'inline' ? 'gap-2 px-4 py-6' : size === 'page' ? 'gap-4 px-6 py-20' : 'gap-3 px-6 py-12';
const titleClass =
size === 'inline'
? 'text-sm font-medium'
: size === 'page'
? 'text-base font-semibold'
: 'font-medium';
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<div
className={cn(
'flex min-w-0 flex-col items-center justify-center text-center',
box,
className,
)}
>
{icon ? <div className="text-muted">{icon}</div> : null}
<div>
<p className="font-medium">{title}</p>
<div className="min-w-0">
<p className={cn('break-words', titleClass)}>{title}</p>
{description ? (
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
<p
className={cn(
'mx-auto mt-1 max-w-sm break-words text-muted',
size === 'inline' ? 'text-xs' : 'text-sm',
)}
>
{description}
</p>
) : null}
</div>
{action}
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
/**
* The one route heading.
*
* Thirteen pages hand-rolled this block and drifted: the `<h1>` was 20px on
* most, 30px on Growth and Learn, five pages carried two `<h1>`s, and the gap
* to the first section below ran 16 / 20 / 24 / 40px depending on the file. A
* document has one title, and a product has one title size.
*
* `ask` is a separate slot from `actions` deliberately. "Ask Piggy" is not a
* page action it is the same agent on every page, and it sits in the same
* place on every page so a reader stops hunting for it among the buttons that
* do differ.
*/
export function PageHeader({
title,
description,
actions,
ask,
className,
}: {
title: ReactNode;
description?: ReactNode;
/** Page-specific controls, right-aligned at `sm` and up. */
actions?: ReactNode;
/** The `PiggyAskButton` for this page, kept in one consistent slot. */
ask?: ReactNode;
className?: string;
}) {
return (
<header
className={cn(
'flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className,
)}
>
<div className="min-w-0">
<h1 className="min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{title}
</h1>
{description ? (
<p className="mt-1 min-w-0 max-w-2xl text-sm text-muted">{description}</p>
) : null}
</div>
{actions || ask ? (
// `flex-wrap` rather than `whitespace-nowrap`: at 393px a two-button
// row plus the ask button overflows, and a page header is the last
// place that should be the thing which introduces horizontal scroll.
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2 sm:justify-end">
{actions}
{ask}
</div>
) : null}
</header>
);
}
+27 -5
View File
@@ -12,6 +12,17 @@ const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
/**
* The trigger is 44px, matching `Input` and `Button`, because it is the same
* kind of thing a thumb aims at. Stock shadcn ships it at 36px, which is where
* Settings' timezone select and every filter built on this primitive were
* failing PIG's own touch rule not because a call site chose 36px, but
* because nobody had chosen anything.
*
* The rest of the treatment is `Input`'s exactly: 12px radius because it is a
* control, `bg-surface` because a transparent field has no edge on a tinted
* canvas, and focus read as the border changing rather than an extra outline.
*/
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
@@ -19,7 +30,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-11 min-h-[44px] w-full items-center justify-between whitespace-nowrap rounded-lg border border-border bg-surface px-3 text-fg",
"transition-colors duration-1 ease-enter data-[placeholder]:text-muted focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -75,7 +88,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -105,7 +118,12 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
// A group name, so it takes the product's one micro-label treatment
// rather than looking like a selectable option set in bold.
className={cn(
"px-2 py-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted",
className
)}
{...props}
/>
))
@@ -118,7 +136,9 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// An option is a target too: a 30px row in an open listbox is as hard to
// hit as a 30px button, and this menu is how a phone changes a filter.
"relative flex min-h-11 w-full cursor-default select-none items-center rounded-md py-2 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
@@ -139,7 +159,9 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
// `bg-muted` here painted the muted TEXT colour: a near-black hairline in
// light theme, because shadcn's `muted` is a surface and PIG's is not.
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
+69 -30
View File
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useOverlayFocusRestore } from "@/components/ui/dialog"
const Sheet = SheetPrimitive.Root
@@ -21,7 +22,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
className
)}
{...props}
@@ -30,17 +31,23 @@ const SheetOverlay = React.forwardRef<
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
/*
* The top inset is written as `max(padding, --safe-top)` rather than the bare
* inset, matching how the app header and the Piggy sheet already do it: a bare
* `pt-[var(--safe-top)]` wins the cascade over the sheet's own padding and
* collapses the top inset to zero on every device without a notch.
*/
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"fixed z-50 gap-4 bg-surface p-5 shadow-lg transition data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-3 data-[state=closed]:duration-3 data-[state=open]:ease-enter data-[state=closed]:ease-exit",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
top: "inset-x-0 top-0 border-b pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
"inset-x-0 bottom-0 border-t pb-[max(1.25rem,var(--safe-bottom))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
"inset-y-0 right-0 h-full w-3/4 border-l pt-[max(1.25rem,var(--safe-top))] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
@@ -56,31 +63,61 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-2 top-2 flex h-11 w-11 items-center justify-center rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
>(({ side = "right", className, children, onCloseAutoFocus, ...props }, ref) => {
const focus = useOverlayFocusRestore(ref, onCloseAutoFocus)
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={focus.ref}
onCloseAutoFocus={focus.onCloseAutoFocus}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close
className={cn(
// `z-10` because a sheet's body is usually a full-bleed rail the
// call site paints itself, and a dismiss control underneath the
// content is a sheet with no way out on a phone.
"absolute right-2 z-10 flex size-11 items-center justify-center rounded-lg text-muted opacity-70 transition-colors duration-1 hover:bg-surface-2 hover:opacity-100 disabled:pointer-events-none",
// Most sheets are opened with `p-0` and lay out their own header,
// which means the content's safe-area padding is overridden at the
// call site. The dismiss control is the primitive's own, so it
// carries the inset itself — otherwise it opens under the notch,
// which is where it was measured.
side === "bottom" ? "top-2" : "top-[max(0.5rem,var(--safe-top))]"
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
})
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Render the header as the sheet's fixed top band: the standard overlay
* header padding, a rule against the body, and the top safe-area inset.
*
* Opt-in because the two families of sheet want opposite things. A sheet
* that keeps the content padding already has its inset and would get it
* twice; a sheet opened with `p-0` which is most of them owns its own
* bands and was hand-rolling these four classes each time, at four slightly
* different values.
*/
band?: boolean
}
const SheetHeader = ({ className, band = false, ...props }: SheetHeaderProps) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
"flex min-w-0 flex-col gap-1 text-left",
band &&
"shrink-0 border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]",
className
)}
{...props}
@@ -94,7 +131,7 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
@@ -108,7 +145,9 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
// 16px/600, the section-heading role, everywhere. Overlay chrome that
// varies by feature is how one product ends up looking like three.
className={cn("text-base font-semibold leading-tight text-fg", className)}
{...props}
/>
))
@@ -120,7 +159,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-sm text-muted", className)}
{...props}
/>
))
+7 -1
View File
@@ -367,7 +367,13 @@ export const SidebarGroupLabel = React.forwardRef<
ref={ref}
data-sidebar="group-label"
className={cn(
'flex h-8 shrink-0 items-center rounded-md px-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted/80',
// The Label spec, not a sixth micro-label variant. This heading is the
// most-repeated small-caps text in the product — four of them on all 16
// routes — and it was the last one off the scale: 10px/600/0.16em in
// `text-muted/80`, which measured 3.28:1 in light theme. `text-muted` at
// 11px/500/0.06em is the one definition everything else already uses,
// and it measures 4.83:1.
'flex h-8 shrink-0 items-center rounded-md px-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted',
'transition-[margin,opacity] duration-200 ease-linear',
// Pulled up rather than hidden, so the icons above and below do not
// jump as the label fades out.
+21 -5
View File
@@ -1,3 +1,19 @@
/**
* The table primitive, on PIG's palette.
*
* This file arrived verbatim from shadcn and stayed that way through the whole
* design pass, which mattered more here than anywhere else: `tailwind.config.js`
* deliberately aliases `muted` onto `--muted`, a TEXT grey, so shadcn's
* `hover:bg-muted/50` painted a 50%-opacity mid-grey slab across a hovered row.
* Measured, the row's own text on that slab was 2.42:1 in light and 2.58:1 in
* dark on `/accounts` and `/contracts`, the two pages a GTM lead lives in,
* and under the row-action icons that only reveal on hover. `surface-2` is the
* product's own "this row is under the pointer" plane and reads at full text
* contrast.
*
* `TableHead` also matches the 44px floor the sort buttons inside it already
* carry: at `h-10` the header cell was shorter than its own control.
*/
import * as React from "react"
import { cn } from "@/lib/utils"
@@ -20,7 +36,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-border [&_tr:hover]:bg-transparent", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
@@ -43,7 +59,7 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
"border-t bg-surface-2 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
@@ -58,7 +74,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-b border-border transition-colors duration-1 ease-enter hover:bg-surface-2 data-[state=selected]:bg-surface-2",
className
)}
{...props}
@@ -73,7 +89,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"h-11 px-2 text-left align-middle font-medium text-muted [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
@@ -102,7 +118,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn("mt-4 text-sm text-muted", className)}
{...props}
/>
))
+44 -17
View File
@@ -1,9 +1,26 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root
/**
* Radix tabs wearing PIG's palette and PIG's touch floor.
*
* Two things were wrong with the stock copy, and both were wrong at every call
* site rather than at any one of them.
*
* First the palette: shadcn's `bg-muted` is a surface token. In PIG `muted` is
* the muted TEXT colour, so an unstyled `TabsList` painted a mid-grey slab
* with unreadable labels on it. Every call site had independently written the
* same three overrides `border border-border bg-surface`,
* `data-[state=active]:bg-surface-2`, `text-muted` which is the signal that
* they belong here.
*
* Second the height: a 36px list holding 28px triggers cannot contain a 44px
* touch target, and the rail's tabs measured 36px on a phone. The floor is now
* in the primitive, the same way `buttonVariants` carries it.
*/
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -12,13 +29,15 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
// 52px, not 44px: the list pads its triggers by 4px on each side, so a
// 44px list would squeeze a 44px trigger down to 36px.
'inline-flex min-h-[52px] items-center justify-center rounded-lg border border-border bg-surface p-1 text-muted',
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -27,13 +46,21 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
// `min-w-11` beside `min-h-11`: the floor is a square, and the repair
// that added the height left the width alone — so the Accounts facet
// control's "All" tab measured 40x44 at every viewport. A short label is
// exactly the case a minimum exists for.
'inline-flex min-h-11 min-w-11 items-center justify-center whitespace-nowrap rounded-md px-3 py-2',
'text-sm font-medium text-muted transition-colors duration-1 ease-enter',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-surface-2 data-[state=active]:text-fg',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -42,12 +69,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
'mt-3 min-w-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+40 -19
View File
@@ -1,22 +1,43 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
/**
* The composer field, and the only multi-line input in the product.
*
* This was the last un-adapted shadcn primitive: a 6px radius, a transparent
* fill, a `shadow-sm` nothing else in PIG carries, and a 1px `ring-ring` focus
* treatment sitting directly beside a 12px-radius Send button on the control
* the whole company types into first, because `/` redirects to `/piggy`.
*
* It now matches `Input` from `@/components/ui` line for line: 12px radius
* because it is a control, `bg-surface` because a transparent field on a
* tinted canvas has no edge, and `focus-visible:border-accent` so focus reads
* as the border changing rather than a second outline appearing outside it.
* The global `:focus-visible` rule still paints the brand ring on top.
*
* `min-h-[60px]` is kept: this is a growing composer, and the auto-resize
* logic at its call sites measures against a floor it already assumes.
*/
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-fg',
'placeholder:text-muted focus-visible:border-accent',
'transition-colors duration-1 ease-enter',
'disabled:cursor-not-allowed disabled:opacity-50',
// The base stylesheet enforces a 16px minimum here so Safari does not
// zoom the viewport on focus; nothing may override it downward.
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils';
import { compactNumber, percent } from '@/lib/api';
/**
* How much of a capacity block is sold, how much is merely held, and how much
* is still sellable.
*
* Sold and held are drawn as separate segments because a full-looking bar made
* mostly of unconverted holds is a lie a seller would act on held hours are
* a claim someone can walk away from, sold hours are revenue.
*
* The track is `bg-surface-2`. One of the two copies of this bar had drifted to
* `bg-surface`, which inside the allocation sheet's `bg-surface-2` panel
* measured 1.02:1 against its own container: an invisible track, at the exact
* moment someone commits GPU-hours to a customer.
*/
export function UtilisationBar({
sold,
held,
total,
label,
className,
}: {
/** GPU-hours sold. */
sold: number;
/** GPU-hours held but not yet sold. */
held: number;
/** GPU-hours committed in total. Zero renders an empty track, not a full one. */
total: number;
/** What the bar is about, e.g. the block's name. Prefixes the spoken label. */
label?: string;
className?: string;
}) {
const soldPct = total > 0 ? sold / total : 0;
const heldPct = total > 0 ? held / total : 0;
const available = Math.max(0, total - sold - held);
// Held is clamped against sold so a book that has over-held a block renders a
// full bar rather than a segment running past the end of its track.
const soldWidth = Math.min(100, soldPct * 100);
const heldWidth = Math.max(0, Math.min(100 - soldWidth, heldPct * 100));
const spoken = `${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(available)} GPU-hours sellable`;
return (
<div
className={cn('flex h-2 min-w-0 overflow-hidden rounded-full bg-surface-2', className)}
role="img"
aria-label={label ? `${label}: ${spoken}` : spoken}
>
<div className="bg-primary" style={{ width: `${soldWidth}%` }} />
<div className="bg-primary/35" style={{ width: `${heldWidth}%` }} />
</div>
);
}
+25
View File
@@ -19,6 +19,20 @@ export const NAV_BREAKPOINT = 1024;
*/
export const DOCK_BREAKPOINT = 1280;
/**
* Below this the viewport is short, not small.
*
* Every other breakpoint in this file is a width, and that is why the Piggy
* workspace could hand a landscape phone a two-row header, a composer and a
* tab bar and leave 40px of transcript between them: the layout was correct
* for 393x852 and was being asked to draw 852x393.
*
* 560 is above both of the screens that need the room back a phone in
* landscape is about 390 tall, and a portrait phone with the keyboard up is
* about the same and comfortably below every phone held upright.
*/
export const SHORT_VIEWPORT_HEIGHT = 560;
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
@@ -44,3 +58,14 @@ export function useIsMobile(): boolean {
export function useHasDockRoom(): boolean {
return useMediaQuery(`(min-width: ${DOCK_BREAKPOINT}px)`);
}
/**
* True when the viewport is tall enough to spend a row on chrome.
*
* Anything that stacks a header, a scrollport and a composer inside the
* viewport has to ask this as well as "is this the phone layout?" the two
* questions have different answers on a phone held sideways.
*/
export function useHasVerticalRoom(): boolean {
return useMediaQuery(`(min-height: ${SHORT_VIEWPORT_HEIGHT}px)`);
}
+166 -8
View File
@@ -18,19 +18,59 @@
* definition of each colour and the CSS cannot drift from the TypeScript.
*/
:root {
--bg: 0 0% 100%;
/*
* The canvas is a shade off white while cards stay pure white. When both were
* 0 0% 100% a card's shadow was the only thing separating it from the page,
* so any surface that opts out of shadow the Piggy workspace does merged
* into one undifferentiated white field. Dark theme has always had this
* separation (4% canvas against 7% surface); light now reads as three planes
* too. Surface stays lighter than the canvas, so the card still lifts.
*/
--bg: 240 5% 98%;
--surface: 0 0% 100%;
--surface-2: 240 5% 97%;
--border: 240 6% 90%;
--fg: 240 10% 4%;
--muted: 240 4% 46%;
--positive: 160 84% 32%;
--warning: 32 95% 44%;
--danger: 0 72% 45%;
--info: 201 90% 40%;
/*
* Status hues in light theme are darkened to clear 4.5:1 as text on a white
* card AND on their own /10 fill the chip is the case that failed, because
* a tinted fill lifts the background under the very text it is tinting.
* "Needs you" on a pending approval measured 2.84:1 on its own chip, which
* made the one badge in the product that means "act now" the least legible
* thing on the page. Dark theme measured clean and is deliberately untouched.
*
* The first pass tuned these against WHITE and left `warning` at 4.35:1 and
* `positive` at 4.37:1 once the /10 fill was composited and a badge never
* renders on white, it renders on its own tint, which is exactly where the
* ratio is lowest. Worse, a tinted row inside a card sits on `--surface-2`,
* not on `--surface`, which takes another tenth off. Both are now measured
* against the darkest ground either actually lands on (`--surface-2` under
* the /10 fill), so the stated goal every tone clears 4.5:1 is true on
* the surfaces that ship rather than on a background the product never uses.
*/
--positive: 160 84% 24%; /* 6.03:1 on surface, 4.90:1 on a /10 chip over surface-2 */
--warning: 32 95% 31%; /* 5.76:1 on surface, 4.70:1 on a /10 chip over surface-2 */
--danger: 0 72% 45%; /* 5.83:1 on surface, 4.65:1 on its own tint — already clear */
--info: 201 90% 32%; /* 6.06:1 on surface, 4.92:1 on its own tint — already clear */
--shadow: 240 10% 4%;
/*
* Motion. Three durations and two easings, by role, so a component reaches
* for a name instead of inventing a number: every hand-written duration in
* the tree is one more thing that has to be re-decided in review.
*
* Reachable from Tailwind as duration-1/2/3 and ease-enter/ease-exit see
* the transitionDuration and transitionTimingFunction blocks in
* tailwind.config.js, which alias these rather than restating the values.
*/
--dur-1: 120ms; /* colour, opacity, hover, focus, disclosure chevron */
--dur-2: 180ms; /* popovers, dropdowns, tooltips, badges appearing */
--dur-3: 240ms; /* sheets, drawers, dialogs */
--ease-out: cubic-bezier(0.2, 0, 0, 1); /* enter */
--ease-in: cubic-bezier(0.4, 0, 1, 1); /* exit */
/* Safe-area insets, so layout can reference them even at zero. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -121,10 +161,17 @@
font-size: max(16px, 1rem);
}
/* A visible, consistent focus ring keyboard users need it, and the
default varies wildly between browsers. */
/*
* A visible, consistent focus ring keyboard users need it, and the default
* varies wildly between browsers.
*
* The ring is `brand`, not `accent`. Tailwind's `accent` is aliased to
* --accent-subtle (shadcn's hover surface), which measured 1.10:1 in light
* and 1.34:1 in dark against the canvas: a focus ring nobody could see, on
* every control in the product. --accent proper measures 15.9:1 / 18.6:1.
*/
:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-bg;
@apply outline-none ring-2 ring-brand ring-offset-2 ring-offset-bg;
}
/* Respect a reduced-motion preference rather than animating regardless. */
@@ -137,6 +184,21 @@
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/*
* Progress is the deliberate exemption. A spinner is a steady rotation, not
* the parallax or zoom the preference exists to suppress, and the blanket
* rule froze all 16 spinner sites one frame in including the seconds
* between pressing Apply and the write landing, where a stopped spinner
* reads as a hung request. It keeps spinning, slower.
*
* Higher specificity than the `*` rule above, so it wins between two
* !important declarations regardless of source order.
*/
.animate-spin {
animation-duration: 1.2s !important;
animation-iteration-count: infinite !important;
}
}
}
@@ -285,4 +347,100 @@
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
/*
* The page KPI figure fits its tile instead of breaking across two lines.
*
* `break-words` on `Stat` stopped the figure being CLIPPED measured
* `$658,194.3`, a digit short but it did it by breaking the number, so
* `GROSS MARGIN / $658,194. / 37` passed every `scrollWidth === clientWidth`
* check while reading as two numbers. On a brokerage instrument that is the
* worse failure of the two: a clipped figure looks broken, a split one looks
* wrong. Measured at 30px, `$658,194.37` is 185px wide and the tile it has to
* live in is 149px at 1440 with the Piggy dock open and 104px on a 320px
* phone, so no single size fits every column count the grids use.
*
* A container query is the honest instrument: the tile asks how much room it
* was given and picks a step, so a four-up row at 1440 reads at 30px, the
* same row with the dock open reads at 22px, and a phone reads at 20px or 18.
* Every tile in a grid row is the same width, so a row steps together and
* never mixes two sizes. The steps below are the measured fits for the
* longest figure in the product with a few pixels of headroom each; the
* `break-words` fallback stays as the last resort so nothing can ever clip.
*
* The selectors are `.stat-tile .stat-figure-lg` rather than the figure's
* class alone, and that is load-bearing rather than decorative: `@layer
* components` is emitted BEFORE `@layer utilities`, so a single-class rule
* here loses to `text-2xl` on source order and the ladder silently did
* nothing. Two classes is 0-2-0 against a utility's 0-1-0, which wins
* wherever the layers sit. The figure is always inside the tile that declares
* the container, so the descendant selector costs nothing.
*/
.stat-tile {
container-type: inline-size;
}
/* 24px. Measured need for the longest figure at this step: 148px. */
@container (max-width: 192px) {
.stat-tile .stat-figure-lg {
font-size: 1.5rem;
}
}
/* 22px — needs 136px. This is the 1440-with-the-dock-open case, at 149px. */
@container (max-width: 156px) {
.stat-tile .stat-figure-lg {
font-size: 1.375rem;
}
}
/* 20px — needs 124px. A 393px phone gives the figure 140px. */
@container (max-width: 144px) {
.stat-tile .stat-figure-lg {
font-size: 1.25rem;
}
}
/* 18px — needs 111px. A 360px phone gives 124px. */
@container (max-width: 130px) {
.stat-tile .stat-figure-lg {
font-size: 1.125rem;
}
}
/* 16px needs 99px, and a 320px phone gives 104px. The floor: below this a
figure is no longer a headline, and `break-words` takes over. */
@container (max-width: 118px) {
.stat-tile .stat-figure-lg {
font-size: 1rem;
}
}
/*
* The file picker's own button.
*
* `Input[type=file]`'s shell was styled and `::file-selector-button` was not,
* so the control a person actually presses on the data-onboarding page was
* the platform's own bevelled chrome wrong font, wrong radius, and about
* 103x25px inside a 44px field. It is the one control in the product that had
* never been dressed.
*/
input[type='file']::file-selector-button {
margin-right: 0.75rem;
height: 2rem;
padding: 0 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--surface-2));
color: hsl(var(--fg));
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background-color var(--dur-1) var(--ease-out);
}
input[type='file']::file-selector-button:hover {
background: hsl(var(--border) / 0.6);
}
}
+33 -8
View File
@@ -12,6 +12,7 @@
* the exception: a destination someone cannot use is not a disabled control,
* it is a page that answers 403, and offering it is worse than omitting it.
*/
import { createElement, forwardRef } from 'react';
import {
BookUser,
Boxes,
@@ -24,15 +25,36 @@ import {
Server,
Settings,
ShieldCheck,
Sparkles,
Target,
TrendingUp,
Users,
type LucideIcon,
type LucideProps,
} from 'lucide-react';
import type { Capability, Team } from '@pig/core';
import { PiggyMark } from '@/components/PiggyMark';
import { canAny, type PermissionIdentity } from './permissions';
/**
* Piggy's own mark, wearing a Lucide icon's shape.
*
* Four consumers read this table the sidebar, the phone tab bar, the command
* palette and the header and every one of them renders `<Icon className
* aria-hidden />` against a `LucideIcon`. Widening that type would have made
* all four declare a new one for the sake of a single row, so the adapter lives
* here instead: it takes the two props those call sites actually pass and drops
* the rest, because `size`, `color` and `strokeWidth` have no meaning for a
* filled two-tone mark. The ref goes nowhere for the same reason nothing in
* this product has ever taken a ref to a nav glyph.
*
* Written with `createElement` rather than JSX only because this module is a
* plain `.ts` table that four `.tsx` files import.
*/
const PiggyNavIcon: LucideIcon = forwardRef<SVGSVGElement, Omit<LucideProps, 'ref'>>(
({ className }, _ref) => createElement(PiggyMark, { className }),
);
PiggyNavIcon.displayName = 'PiggyNavIcon';
export const NAV_GROUPS = ['Workspace', 'Intelligence', 'Marketplace', 'Records', 'Control'] as const;
export type NavGroup = (typeof NAV_GROUPS)[number];
@@ -80,14 +102,17 @@ export const NAV: NavItem[] = [
* the agent below three reports a filing that made sense when Piggy could
* only read and answer, and stopped making sense the moment it could act.
*
* Sparkles rather than a chat bubble because the header's Piggy control
* already uses Sparkles: the rail row and the header button open the same
* agent on two surfaces, and giving them one glyph is what says so. Nothing
* else in this table uses it, which is the constraint that matters the
* sidebar collapses to icons alone, and two rows sharing a glyph are two
* rows you have to expand the sidebar to tell apart.
* The agent's own mark, not a stock glyph. Piggy was drawn five different
* ways Sparkles here, Sparkles again for the model picker, a robot on
* every answer, a speech bubble on the page buttons so the one thing a
* reader could not learn from the interface was what Piggy looks like.
* `PiggyMark` is now the agent everywhere and nothing else, which is what
* lets this row, the header control and the signature on every answer read
* as one thing. Nothing else in this table uses it, which is the constraint
* that matters the sidebar collapses to icons alone, and two rows sharing
* a glyph are two rows you have to expand the sidebar to tell apart.
*/
{ to: '/piggy', label: 'Piggy', icon: Sparkles, group: 'Workspace', primary: true },
{ to: '/piggy', label: 'Piggy', icon: PiggyNavIcon, group: 'Workspace', primary: true },
// Still first in Intelligence and still in the phone tab bar. Losing `/` cost
// it a URL, not its prominence: it is one click from anywhere, and it remains
// the page an exec opens to see whether the business is working.
+100 -13
View File
@@ -335,7 +335,7 @@ interface Refusal {
function describeFailure(error: unknown): Refusal {
if (error instanceof PiggyRateLimitError) {
const clearsAt = rateLimitClearsAt(error.retryAfterSeconds);
return { message: rateLimitMessage(clearsAt), retryableAt: clearsAt.getTime() };
return { message: rateLimitMessage(clearsAt, error.message), retryableAt: clearsAt.getTime() };
}
return { message: error instanceof Error ? error.message : 'Piggy chat failed.' };
}
@@ -361,10 +361,24 @@ function rateLimitClearsAt(retryAfterSeconds: number | null): Date {
* "0.0s": a number that stopped being a measurement. A clock time does not
* drift, and the user's question is left on screen above it, so the sentence
* says what will happen to it rather than only what went wrong.
*
* WHY the wait happened is the server's to say, not this function's. A 429
* reaching this client has two quite different causes PIG's own per-person
* hourly quota, and Prime Inference throttling the deployment upstream and
* this hardcoded the first one for both. In a deployment where the upstream is
* the common case, that is the product telling a GTM lead they have exhausted
* an allowance they have barely touched, and discarding a far better sentence
* the relay had already written ("Prime Inference is rate limiting us, so this
* question was never answered none of this was charged to you"). So the
* server's reason is quoted and only the deadline is composed here.
*/
function rateLimitMessage(clearsAt: Date): string {
function rateLimitMessage(clearsAt: Date, serverReason: string): string {
const time = clearsAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
return `You have used this hour's Piggy questions. The limit clears at ${time}, when Retry will work again.`;
const reason = serverReason.trim() || 'Piggy is rate limited right now.';
// A relayed sentence may or may not be punctuated; two full stops read as a
// typo and none reads as a run-on.
const stopped = /[.!?]$/.test(reason) ? reason : `${reason}.`;
return `${stopped} Retry will work again at ${time}.`;
}
/** Both the body field and the header are integers of seconds, and both may be absent. */
@@ -445,12 +459,13 @@ export function usePiggyConversation({
* A transcript this conversation is resuming, read back from
* `GET /api/piggy/conversations/:id`.
*
* A seed, like everything else here: it is applied at mount and never again,
* so a caller reopening a different thread must remount the hook (the
* workspace keys it on the conversation id). Without it the workspace could
* list history it had no way of putting back on screen, and the relay builds
* a turn's prompt from the `history` the client sends so an unseeded hook
* would also continue a reopened thread having forgotten every word of it.
* Adopted until the first send of this session, not only at mount. It is
* almost never available at mount: the workspace puts the thread on screen
* as soon as the id is in the URL, and `GET /api/piggy/conversations/:id` is
* a round trip behind it so a state initialiser captured `[]` every time
* and threw the stored transcript away as it arrived. The relay also builds
* a turn's prompt from the `history` the client sends, so an unseeded hook
* carried on a reopened thread having forgotten every word of it.
*/
initialMessages?: TranscriptMessage[];
/**
@@ -491,6 +506,14 @@ export function usePiggyConversation({
* written synchronously, so the second press is refused by the first.
*/
const runningRef = useRef(false);
/**
* Raised by the first send of this session, and never lowered.
*
* It is what makes the late seed below safe. Once this conversation has said
* anything, what is on screen is ahead of anything the store can hand back,
* and adopting a fetch would delete the turn being read.
*/
const touched = useRef(false);
const abortRef = useRef<AbortController | null>(null);
// Bumped only to re-read the clock. `isRetryable` withholds the Retry button
// while a rate limit holds, and nothing else in a transcript nobody is typing
@@ -499,6 +522,24 @@ export function usePiggyConversation({
useEffect(() => () => abortRef.current?.abort(), []);
/**
* Put the stored transcript on screen when it lands.
*
* The store keeps every question, tool call, proposed change and answer, and
* the client used to discard all of it: ~50 rows in the history rail opened
* on a blank pane that said, in writing, that nothing was stored. This is the
* half of that loop that was missing.
*
* Compared by identity rather than adopted outright because callers rebuild
* the array on every render `toTranscript(detail.data.messages)` is a fresh
* object each time and replacing state with an equal value would re-render
* the whole transcript for nothing.
*/
useEffect(() => {
if (touched.current || !initialMessages) return;
setMessages((current) => (sameTurns(current, initialMessages) ? current : initialMessages));
}, [initialMessages]);
useEffect(() => {
const now = Date.now();
const waits = messages
@@ -519,6 +560,7 @@ export function usePiggyConversation({
if (!message || runningRef.current) return;
// Claimed before the first await, so nothing else can enter this turn.
runningRef.current = true;
touched.current = true;
const userId = crypto.randomUUID();
const assistantId = crypto.randomUUID();
const history = toChatHistory(from ?? messages);
@@ -539,6 +581,16 @@ export function usePiggyConversation({
// answer — did anything terminate this turn? — is about the events, not
// about what React has committed.
let settled = false;
/**
* Whether the relay ever started answering.
*
* The question below is marked "Not sent" only when this is false. A
* connection that dies half-way through has still spent the turn the
* tokens are gone, the tools have run, and part of the answer is on screen
* so telling the user their question never left is both wrong and the
* thing that makes them ask it again.
*/
let accepted = false;
try {
const request = {
message,
@@ -551,6 +603,7 @@ export function usePiggyConversation({
conversationId: conversationRef.current,
};
for await (const event of streamPiggyChat(request, abort.signal)) {
accepted = true;
if (event.type === 'meta' && event.conversationId !== conversationRef.current) {
conversationRef.current = event.conversationId;
setConversationId(event.conversationId);
@@ -588,8 +641,9 @@ export function usePiggyConversation({
}
// The question is marked, not deleted: the user's words stay on
// screen to be re-sent, and `toChatHistory` knows to keep a turn
// the relay refused out of the model's history.
if (entry.id === userId) return { ...entry, failed: true };
// the relay refused out of the model's history. Only a turn the
// relay never began answering is marked — see `accepted`.
if (entry.id === userId && !accepted) return { ...entry, failed: true };
return entry;
}),
);
@@ -810,10 +864,12 @@ export async function* readNdjson<Value>(
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) yield JSON.parse(line) as Value;
const event = parseNdjsonLine<Value>(line);
if (event !== undefined) yield event;
}
if (done) {
if (buffer.trim()) yield JSON.parse(buffer) as Value;
const event = parseNdjsonLine<Value>(buffer);
if (event !== undefined) yield event;
return;
}
}
@@ -821,3 +877,34 @@ export async function* readNdjson<Value>(
reader.releaseLock();
}
}
/**
* One NDJSON record, or nothing at all.
*
* A body cut mid-line leaves a fragment behind, and parsing it threw out of the
* generator: the transcript then showed `Unexpected end of JSON input` where
* Piggy's answer had been the partial text already streamed was replaced by
* that sentence, and the question above it was labelled "Not sent" after the
* tokens had been spent. A fragment is not an event. Dropping it lets `send`
* see the stream end without a `done`, which is exactly what happened, and the
* turn is marked truncated with everything that did arrive still on screen.
*/
function parseNdjsonLine<Value>(line: string): Value | undefined {
if (!line.trim()) return undefined;
try {
return JSON.parse(line) as Value;
} catch {
return undefined;
}
}
/**
* Whether two transcripts are the same turns in the same order.
*
* Ids are enough: a stored row's id is its primary key and a live turn's is a
* UUID minted once, so two arrays agreeing on every id are two readings of one
* conversation.
*/
function sameTurns(a: TranscriptMessage[], b: TranscriptMessage[]): boolean {
return a.length === b.length && a.every((entry, index) => entry.id === b[index]?.id);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* What PIG says about Piggy, written once.
*
* The same four sentences existed three times in the dock's starters, in the
* workspace's starters and in the workspace title bar and had already drifted
* apart in the way duplicated copy always does. The dock's version never
* mentioned that Piggy can write, which is the product's headline capability,
* missing from the surface most people keep open all day. The workspace's
* version had picked up release-note voice: "and now, act on the answer",
* "Running on Prime Agent", "in this build". None of those sentences is about
* the reader's business.
*
* So: one module, PIG's voice plain, specific, and about consequence. It says
* what Piggy reads, what it can change, and what stands between a proposal and
* the book. It does not name the harness, the model or the release.
*
* Every line comes in two lengths because the same sentence has to work in a
* 22rem dock and on a full page. `short` is not a truncation of `long`: it is
* the same claim with the qualifications the narrow surface has no room to
* carry, and it must survive alone. Where a fact cannot be dropped that a
* change waits for a person it is in both.
*/
/** One sentence at two widths. Both are complete; neither is a summary. */
export interface PiggyLine {
/** A full page, a sheet, anything wider than about 40rem. */
long: string;
/** The 22rem dock, the mobile drawer, a two-line header. */
short: string;
}
/**
* Pick the length for the surface.
*
* A boolean rather than a width: the caller already knows whether it is narrow
* the dock passes `compact`, the workspace passes `narrow` and a component
* measuring itself to choose a sentence is a component that reflows text on
* resize.
*/
export function piggyLine(line: PiggyLine, narrow = false): string {
return narrow ? line.short : line.long;
}
export const piggyCopy = {
/**
* The first sentence anyone reads. It names the subject (the book), the
* capability (change), and the condition (your approval) in that order,
* because a reader who stops after six words should still have learned the
* thing the old dock never told them.
*/
headline: {
long: 'Ask anything about the book. Piggy can change it, with your approval.',
short: 'Ask anything. Piggy can change it, with your approval.',
},
/**
* What the answers rest on. The boundary is stated as a list of absences
* because "scoped tools" alone is a claim, and the three things Piggy has no
* access to are what make it a checkable one.
*/
capability: {
long:
'Piggy reads your PIG records through scoped tools — no shell, no filesystem, ' +
'no browser — and every answer carries the rows it read.',
short: 'Piggy reads your PIG records through scoped tools, and shows the rows it read.',
},
/**
* What stands between a proposal and the book. This is the sentence the
* safety argument rests on, so the short form keeps the whole mechanism and
* drops only the detail of what the card contains.
*/
safety: {
long:
'A change arrives as a card naming the record and every field it would alter. ' +
'Nothing reaches the book until you press Apply.',
short: 'A change waits on a card. Nothing reaches the book until you press Apply.',
},
/** The thread title bar's second line, for a conversation already filed. */
threadSaved: {
long: 'Answered from your PIG records, and nothing else.',
short: 'From your PIG records only.',
},
/** The same line before the first question has named the thread. */
threadNew: {
long: 'New conversation. It is filed under your history as soon as you ask.',
short: 'Filed under your history as soon as you ask.',
},
/** Heading over the openers that only read. */
readGroupTitle: {
long: 'Look something up',
short: 'Look something up',
},
/** The note under those openers. */
readGroupNote: {
long: 'Answered from your records, with the rows it read attached.',
short: 'Answered from your records.',
},
/** Heading over the openers that end in a change. */
writeGroupTitle: {
long: 'Get something done',
short: 'Get something done',
},
/**
* The note under the write openers, in the three situations that change what
* is true rather than only how it is worded.
*
* `readOnly` describes an escalation the user is about to perform, so it says
* so before the press rather than after: pressing a write opener in Read only
* moves the thread to Ask first, and a permission that changes silently is
* the one thing this product must never do.
*/
writeGroupNote: {
askFirst: {
long:
'Piggy shows you exactly what it would write, and asks which record if your ' +
'line does not say.',
short: 'Piggy shows what it would write before anything lands.',
},
readOnly: {
long:
'These switch Piggy to Ask first: it proposes the change, you press Apply. ' +
'It asks which record if your line does not say.',
short: 'These switch Piggy to Ask first. It proposes, you press Apply.',
},
noAccess: {
long: 'Your access does not allow changing records, so Piggy can only read here.',
short: 'Your access is read-only, so Piggy can only read.',
},
},
/** What the composer invites. One placeholder for every Piggy surface. */
composerPlaceholder: {
long: 'Ask about capacity, margin, paper or next actions…',
short: 'Ask about capacity, margin or paper…',
},
/**
* The empty run ledger. Says what would fill it, not that it is empty a
* panel that only reports its own emptiness has told the reader nothing they
* could not see.
*/
activityEmpty: {
long:
'Nothing has run yet. Ask Piggy a question and the turn appears here with its ' +
'model, its cost and every tool it called.',
short: 'Nothing has run yet. Ask Piggy something and the turn appears here.',
},
} satisfies Record<string, PiggyLine | Record<string, PiggyLine>>;
/** The one name for the control that opens the agent, wherever it appears. */
export const PIGGY_ASK_LABEL = 'Ask Piggy';
+103
View File
@@ -0,0 +1,103 @@
/**
* What a tool is called when a person reads it.
*
* There were three of these tables. `piggy/tool.tsx` named the eleven read
* tools; `piggy/approval-card.tsx` named the five write tools; the evidence
* rail title-cased whatever it was handed. So the transcript could call a step
* "Idle capacity" while the approval card two rows below called its own step
* "Log Activity", and a tool absent from every table arrived as "Get Record By
* Id" the identifier with its underscores taken out, presented as English.
*
* One table, therefore, covering every tool Piggy can call, read and write
* alike, and one fallback. The fallback capitalises the first letter only:
* "Get record by id" is still a bad name, but it reads as a name rather than as
* a title, so a missing entry looks like an omission instead of a decision.
*
* The source of truth is `apps/piggy/src`: `chat-tools.ts` (the focused read and
* the four lookups), `page-tools.ts` (one per route), `lifecycle-tools.ts`,
* `write-tools.ts` (the five that need approval) and `tools.ts` (the two given
* only to a queued background task). When a tool is added there, it is added
* here in the same change a chat transcript that cannot name a step it just
* took is a transcript nobody trusts.
*/
/**
* Reads. Named for what they return, not for how they are fetched, because the
* reader is scanning for the subject: "Renewal deadlines" answers "what did it
* look at" in a way that "List renewals" does not.
*/
const READ_TOOL_LABELS: Record<string, string> = {
// chat-tools.ts — the record the panel was opened from, and the lookups.
pig_get_record: 'Record in focus',
pig_get_record_by_id: 'Record lookup',
pig_search_records: 'Record search',
pig_list_renewals: 'Renewal deadlines',
pig_list_inventory: 'Provider inventory',
// lifecycle-tools.ts — only ever given alongside an account context.
pig_get_account_lifecycle: 'Account lifecycle',
// page-tools.ts — one per route, chosen by where the dock is standing.
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',
// tools.ts — the queued research task's own pair. These never appear in an
// interactive transcript, but they do appear in the run ledger and in the
// evidence counts, which is where the title-cased fallback was showing.
pig_get_subject: 'Task subject',
pig_record_fact: 'Propose a fact',
};
/**
* Writes. These carry a `label` in `write-tools.ts` and the strings here are
* that label verbatim: the server names the change in the approval payload and
* the client names the step in the transcript, and the two must not disagree
* about what the user just approved.
*/
const WRITE_TOOL_LABELS: Record<string, string> = {
pig_log_activity: 'Log activity',
pig_create_contact: 'Create contact',
pig_update_deal_stage: 'Update deal stage',
pig_update_record_fields: 'Update record fields',
pig_create_task: 'Create task',
};
/** Every tool Piggy can call, read and write, in one lookup. */
export const PIGGY_TOOL_LABELS: Record<string, string> = {
...READ_TOOL_LABELS,
...WRITE_TOOL_LABELS,
};
/**
* The tools that change the book. Exported as a set rather than left implicit
* so a list can group reads above writes the ordering the run ledger and the
* evidence rail both want without a second copy of the membership test.
*/
export const PIGGY_WRITE_TOOL_NAMES: ReadonlySet<string> = new Set(
Object.keys(WRITE_TOOL_LABELS),
);
/** True for a tool whose result needs a person's approval before it lands. */
export function isPiggyWriteTool(name: string): boolean {
return PIGGY_WRITE_TOOL_NAMES.has(name);
}
/**
* The reader's name for a tool.
*
* Unknown identifiers keep their words and lose only the `pig_` prefix and the
* underscores. Title-casing them was the old behaviour and it was the worse
* one: "Get Record By Id" claims to be a designed label, while "Get record by
* id" reads as the identifier it is.
*/
export function piggyToolLabel(name: string): string {
const known = PIGGY_TOOL_LABELS[name];
if (known) return known;
const words = name.replace(/^pig_/, '').replaceAll('_', ' ').trim();
if (!words) return name;
return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
}
+295 -186
View File
@@ -18,15 +18,12 @@ import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import {
DEMAND_OPEN_STAGES,
DEMAND_STAGE_LABELS,
SUPPLY_OPEN_STAGES,
SUPPLY_STAGE_LABELS,
type ActivityType,
type ContractStatus,
type ContractType,
type CustomerLifecycleProjection,
type CustomerRelationshipState,
type GrowthFacet,
type PermissionGrant,
} from '@pig/core';
import {
@@ -36,6 +33,7 @@ import {
Building2,
CalendarClock,
CheckSquare,
CircleAlert,
CircleDollarSign,
Clock3,
ExternalLink,
@@ -55,6 +53,7 @@ import {
Users,
} from 'lucide-react';
import { PiggyAskButton } from '@/components/PiggyChat';
import { PiggyMark } from '@/components/PiggyMark';
import {
AccountSheet,
ContactSheet,
@@ -65,6 +64,14 @@ import {
type DemandDealRecord,
type SupplyDealRecord,
} from '@/components/RecordSheets';
import {
ContractStatusBadge,
DealStageBadge,
FacetBadge,
RelationshipBadge,
RenewalBadge,
SideBadge,
} from '@/components/status';
import {
Badge,
Button,
@@ -72,7 +79,10 @@ import {
CardContent,
CardHeader,
CardTitle,
ConfidenceBadge,
EmptyState,
Label,
Section,
Skeleton,
Stat,
} from '@/components/ui';
@@ -121,6 +131,19 @@ interface ActivityRecord {
demandDealId: string | null;
supplyDealId: string | null;
actorAgent: string | null;
/**
* How a write Piggy made stays distinguishable from one a person typed.
*
* `executeMutation` stamps `actorAgent` only for an api-key principal, and
* Piggy's approved writes run as the signed-in person so an approved
* proposal landed on this timeline reading as hand-typed, directly above a
* seeded row that said "by piggy". The agent leaves two other marks and both
* are read here: `pig_log_activity` writes a `piggy:` external id (unique,
* so a retried tool call deduplicates), and every other write tool stamps
* `meta.actorAgent` through `attributedToPiggy`.
*/
externalId: string | null;
meta: { actorAgent?: string | null } | null;
occurredAt: string;
}
@@ -229,7 +252,7 @@ export function Account() {
if (detail.error || !detail.data || !account) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<BackLink />
<Card>
<EmptyState
@@ -266,21 +289,25 @@ export function Account() {
const lifecycle = growth.data?.lifecycle;
return (
<div className="flex flex-col gap-4 md:gap-5">
<BackLink />
<Header
account={account}
ownerLabel={ownerLabel(account.ownerUserId, owner, team.isLoading)}
relationshipState={lifecycle?.relationshipState ?? null}
writable={writable}
onEdit={() => setAccountSheet(true)}
/>
<div className="flex flex-col gap-6">
{/* The back link belongs to the header, not to the page rhythm: 24px of
air between "All accounts" and the record it returns from reads as a
block of its own rather than as the way out of this one. */}
<div className="flex flex-col gap-3">
<BackLink />
<Header
account={account}
ownerLabel={ownerLabel(account.ownerUserId, owner, team.isLoading)}
relationshipState={lifecycle?.relationshipState ?? null}
writable={writable}
onEdit={() => setAccountSheet(true)}
/>
</div>
{missed.length ? <MissedNoticeBanner alarms={missed} /> : null}
<OwnershipPanel account={account} />
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<section className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<Stat
label="Attention"
value={lifecycle ? lifecycle.score : '—'}
@@ -304,13 +331,13 @@ export function Account() {
/>
</section>
<div className="grid gap-4 xl:grid-cols-3 xl:items-start">
<div className="grid gap-3 xl:grid-cols-3 xl:items-start">
{/*
Sidebar first in the DOM, reordered right on wide screens: on a phone
the useful reading order is "what state is this relationship in and
who is in it" before three long tables of history.
*/}
<aside className="flex min-w-0 flex-col gap-4 xl:order-2">
<aside className="flex min-w-0 flex-col gap-3 xl:order-2">
<LifecyclePanel query={growth} />
<ContactsPanel
contacts={data.contacts}
@@ -323,7 +350,7 @@ export function Account() {
/>
</aside>
<div className="flex min-w-0 flex-col gap-4 xl:order-1 xl:col-span-2">
<div className="flex min-w-0 flex-col gap-3 xl:order-1 xl:col-span-2">
{/*
A one-sided account gets one pipeline panel. Showing a supplier an
empty "nothing has been sold to this account" card is a prompt to
@@ -405,10 +432,9 @@ function Header({
}) {
const type = account.supplierType ?? account.customerSegment;
return (
<Card className="overflow-hidden">
<div className="h-1 bg-gradient-to-r from-accent-subtle via-info to-positive" />
<CardHeader className="gap-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<Card>
<CardHeader className="gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-base font-semibold text-accent-fg">
{initials(account.name)}
@@ -420,7 +446,11 @@ function Header({
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
{account.website ? (
<a
className="inline-flex items-center gap-1 underline-offset-4 hover:text-fg hover:underline"
// `min-h-11` and a negative margin: a 20px-tall link in a
// record header is a target a thumb misses, and it was the
// only sub-44px control on this page. The margin keeps the
// metadata row visually where it was.
className="-my-3 inline-flex min-h-11 items-center gap-1 rounded-lg underline-offset-4 hover:text-fg hover:underline"
href={account.website}
target="_blank"
rel="noreferrer"
@@ -452,40 +482,42 @@ function Header({
<SideBadge side={account.side} />
{relationshipState ? <RelationshipBadge state={relationshipState} /> : null}
{type ? <Badge tone="neutral">{humanise(type)}</Badge> : null}
{account.confidence !== 'confirmed' ? (
<Badge tone={account.confidence === 'disputed' ? 'danger' : 'warning'}>
{humanise(account.confidence)} record
</Badge>
) : null}
{/* The product's one provenance marker, rather than this page's copy
of it: the accounts table and Piggy's evidence both draw an
unverified record this way, and a record that looks differently
sourced on two screens is a record nobody trusts on either. */}
<ConfidenceBadge confidence={account.confidence} />
</div>
{account.description ? (
<p className="max-w-3xl text-sm leading-6 text-muted">{account.description}</p>
) : null}
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3">
<Field label="Owner" value={ownerLabel} />
<Field label="Jurisdiction" value={account.jurisdiction ?? 'Not recorded'} />
<Field label="Headquarters" value={account.country ?? 'Not recorded'} />
<Field
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<DetailTile label="Owner" value={ownerLabel} />
<DetailTile label="Jurisdiction" value={account.jurisdiction} />
<DetailTile label="Headquarters" value={account.country} />
<DetailTile
label="Last activity"
value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : 'None recorded'}
value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : null}
/>
</dl>
</div>
</CardHeader>
</Card>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-lg bg-surface-2 p-2.5">
<dt className="text-[11px] uppercase tracking-wide text-muted">{label}</dt>
<dd className="mt-0.5 truncate text-sm font-medium" title={value}>
{value}
</dd>
</div>
);
/**
* One recorded field, as a tile.
*
* This was `Field`, one of eight label-and-value tiles the audit counted at
* three numeral scales. It is now `Stat` at the inline size on an inset
* surface, so a field on a record reads the same as a figure in a panel and
* an unrecorded one renders `Stat`'s muted em dash instead of a bold
* "Not recorded", which set a missing fact in the same weight as a known one.
*/
function DetailTile({ label, value }: { label: string; value: string | null }) {
return <Stat label={label} value={value} size="sm" surface="inset" />;
}
// ---------------------------------------------------------------- ownership
@@ -535,10 +567,15 @@ function OwnershipPanel({ account }: { account: AccountDetailRecord }) {
</div>
</CardHeader>
<CardContent className="pt-0">
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
<Field label="Ultimate parent" value={account.ultimateParentName ?? 'Not recorded'} />
<Field label="Parent country" value={parentCountry ?? 'Not recorded'} />
<Field
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<DetailTile label="Ultimate parent" value={account.ultimateParentName} />
<DetailTile label="Parent country" value={parentCountry} />
{/*
"Never verified" rather than an em dash: unverified ownership is a
statement about this record, not an absent field, and it is the one
an export-control question turns on.
*/}
<DetailTile
label="Ownership verified"
value={
account.ownershipVerifiedAt
@@ -546,7 +583,7 @@ function OwnershipPanel({ account }: { account: AccountDetailRecord }) {
: 'Never verified'
}
/>
</dl>
</div>
</CardContent>
</Card>
);
@@ -618,15 +655,27 @@ function MissedNoticeBanner({ alarms }: { alarms: RenewalAlarm[] }) {
);
}
/**
* Renewal urgency in the shared vocabulary, with the two states it cannot say.
*
* `RenewalBadge` covers three of the five: no alarm, a notice already lapsed,
* and a notice still scheduled. The middle two stay here because this page
* separates a deadline that has *passed* from one that is merely close they
* are not the same news and because a countdown is the whole point of the
* cell. They carry the shared marks and tones so the distinction is the
* wording, not a second colour language.
*
* The scheduled date is written by hand rather than delegated for the same
* reason `dateWithYear` exists: `RenewalBadge` uses `shortDate`, and a notice
* deadline two years out reading "Oct 13" is a question, not an answer.
*/
function RenewalCell({ alarm }: { alarm: RenewalAlarm }) {
if (alarm.state === 'not_applicable') {
return <span className="text-xs text-muted">No alarm</span>;
}
if (alarm.state === 'expired') return <Badge tone="neutral">Expired</Badge>;
if (alarm.state === 'not_applicable') return <RenewalBadge state="not_applicable" />;
if (alarm.state === 'expired') return <RenewalBadge state="expired" />;
if (alarm.state === 'missed') {
return (
<Badge tone="danger">
<AlertTriangle aria-hidden />
<AlertTriangle className="size-3.5" aria-hidden />
Notice missed {agoOrIn(alarm.days)}
</Badge>
);
@@ -634,7 +683,7 @@ function RenewalCell({ alarm }: { alarm: RenewalAlarm }) {
if (alarm.state === 'due_soon') {
return (
<Badge tone="warning">
<Clock3 aria-hidden />
<CircleAlert className="size-3.5" aria-hidden />
Notice {agoOrIn(alarm.days)}
</Badge>
);
@@ -664,7 +713,10 @@ function DemandDeals({
meta={deals.length ? `${money(sum(deals.map((deal) => deal.acvCents)))} recorded ACV` : undefined}
>
{ordered.length === 0 ? (
<PanelEmpty description="Nothing has been sold to this account yet. A deal created on the demand pipeline appears here." />
<PanelEmpty
title="Nothing sold to this account yet"
description="A deal opened on the demand pipeline appears here."
/>
) : (
<ul className="divide-y divide-border">
{ordered.map((deal) => {
@@ -677,10 +729,13 @@ function DemandDeals({
<span className="nums shrink-0 font-semibold">{money(deal.acvCents)}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone={stageTone(deal.stage)}>{DEMAND_STAGE_LABELS[deal.stage]}</Badge>
<DealStageBadge side="demand" stage={deal.stage} />
<Badge tone="neutral">{humanise(deal.productLine)}</Badge>
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
{/* Executed paper is a process outcome, not good news: green
on every deal that has an MSA leaves nothing green for a
figure that is actually good. */}
{deal.msaExecuted ? <Badge tone="neutral">MSA</Badge> : null}
{deal.dpaExecuted ? <Badge tone="neutral">DPA</Badge> : null}
</div>
<p className="text-xs text-muted">
{closed ? 'Closed' : 'Expected close'} {dateWithYear(deal.expectedCloseDate)}
@@ -707,14 +762,17 @@ function SupplyDeals({ deals }: { deals: SupplyDealRecord[] }) {
count={deals.length}
>
{deals.length === 0 ? (
<PanelEmpty description="No capacity has been sourced from this account. Supply-side deals appear here once one is opened." />
<PanelEmpty
title="No capacity sourced here"
description="Supply-side deals appear here once one is opened against this account."
/>
) : (
<ul className="divide-y divide-border">
{deals.map((deal) => (
<li key={deal.id} className="flex flex-col gap-2 px-4 py-3.5 sm:px-5">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<p className="min-w-0 font-medium">{deal.name}</p>
<Badge tone={supplyStageTone(deal.stage)}>{SUPPLY_STAGE_LABELS[deal.stage]}</Badge>
<DealStageBadge side="supply" stage={deal.stage} />
</div>
<p className="text-xs text-muted">
{deal.gpuCount && deal.gpuType ? `${deal.gpuCount}× ${deal.gpuType}` : 'Shape not recorded'}
@@ -771,29 +829,46 @@ function Contracts({
count={contracts.length}
action={
contracts.length ? (
<Link
to="/contracts"
className="text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
>
All paper
</Link>
// A Button rather than a bare link: this is a panel action, and a
// 20px text link is a target a thumb misses.
<Button size="sm" variant="ghost" asChild>
<Link to="/contracts">All paper</Link>
</Button>
) : undefined
}
>
{ordered.length === 0 ? (
<PanelEmpty description="No paper is on file for this account. Contracts created on the Contracts page appear here." />
<PanelEmpty
title="No paper on file"
description="Contracts created on the Contracts page appear here."
/>
) : (
<ul className="divide-y divide-border">
{ordered.map((alarm) => (
<li key={alarm.contract.id} className="flex flex-col gap-2 px-4 py-3.5 sm:px-5">
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1">
<p className="min-w-0 font-medium">{alarm.contract.title}</p>
{/*
A link, not a styled paragraph. Measured, this page's <main>
held six interactive elements and not one of them was a
contract these rows carried a title, a type, a status, a
side and a notice date, read as clickable, and were plain
divs. "Open the contract this account is on" was a journey
with no route through the product at all. `?contract=` is the
URL contract the Contracts page now honours; it opens the same
detail sheet a row there opens.
*/}
<Link
to={`/contracts?contract=${alarm.contract.id}`}
className="tap -my-2 flex min-w-0 items-center rounded-lg font-medium underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
<span className="min-w-0 break-words">{alarm.contract.title}</span>
</Link>
<RenewalCell alarm={alarm} />
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone="neutral">{CONTRACT_TYPE_LABELS[alarm.contract.type]}</Badge>
<Badge tone={statusTone(alarm.contract.status)}>{humanise(alarm.contract.status)}</Badge>
<Badge tone="neutral">{alarm.contract.side === 'supply' ? 'Buy-side' : 'Sell-side'}</Badge>
<ContractStatusBadge status={alarm.contract.status} />
<SideBadge side={alarm.contract.side === 'supply' ? 'supply' : 'demand'} />
</div>
<p className="text-xs text-muted">
{alarm.contract.effectiveAt ? `Effective ${dateWithYear(alarm.contract.effectiveAt)} · ` : ''}
@@ -819,7 +894,7 @@ function LifecyclePanel({
if (query.isLoading) {
return (
<Card className="p-4">
<Skeleton className="h-40 rounded-xl" />
<Skeleton className="h-40 rounded-md" />
</Card>
);
}
@@ -829,9 +904,10 @@ function LifecyclePanel({
return (
<Panel icon={<Clock3 className="size-4" aria-hidden />} title="Lifecycle">
<PanelEmpty
title={notFound ? 'Demand accounts only' : 'Lifecycle unavailable'}
description={
notFound
? 'Growth scores the demand book only, so a supplier account carries no lifecycle projection.'
? 'Growth scores the demand book, so a supplier account carries no projection.'
: 'The lifecycle projection could not be loaded for this account.'
}
/>
@@ -846,13 +922,17 @@ function LifecyclePanel({
title="Lifecycle"
meta={`Rules ${lifecycle.rulesetVersion}`}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-5 sm:pb-5">
<div className="flex items-center gap-3">
<div className="nums text-3xl font-semibold leading-none">{lifecycle.score}</div>
<p className="text-xs leading-5 text-muted">
Attention score. Weighted evidence, not a win or churn probability.
</p>
</div>
<div className="flex flex-col gap-3 px-4 pb-4 sm:px-5 sm:pb-5">
{/* The panel figure, at the panel figure's size. This was a bespoke
30px numeral a third numeral scale on a page that already shows
the same score at 30px in the KPI row above. */}
<Stat
surface="bare"
size="md"
label="Attention score"
value={lifecycle.score}
hint="Weighted evidence, not a win or churn probability."
/>
<div className="flex flex-wrap gap-1.5">
<RelationshipBadge state={lifecycle.relationshipState} />
{lifecycle.facets.map((facet) => (
@@ -864,16 +944,16 @@ function LifecyclePanel({
{lifecycle.signals.map((signal) => (
<li
key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`}
className="flex gap-3 rounded-lg border border-border/70 p-3"
className="flex gap-3 rounded-md bg-surface-2 p-3"
>
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-md bg-surface text-xs font-semibold">
+{signal.weight}
</span>
<div className="min-w-0">
<p className="text-sm leading-5">{signal.explanation}</p>
<p className="mt-1 text-[11px] uppercase tracking-wide text-muted">
<Label as="p" className="mt-1">
{signal.category} · {signal.sourceRefs.map((ref) => humanise(ref.type)).join(', ')}
</p>
</Label>
</div>
</li>
))}
@@ -884,7 +964,7 @@ function LifecyclePanel({
</p>
)}
{lifecycle.blockers.map((blocker) => (
<div key={blocker} className="flex gap-2 rounded-lg bg-warning/10 p-3 text-sm text-warning">
<div key={blocker} className="flex gap-2 rounded-md bg-warning/10 p-3 text-sm text-warning">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="leading-5">{blocker}</span>
</div>
@@ -947,7 +1027,8 @@ function ContactsPanel({
>
{contacts.length === 0 ? (
<PanelEmpty
description="Nobody is recorded against this account. PIG never guesses an address from a name and a domain, so a contact is added once the relationship is known."
title="Nobody recorded here"
description="PIG never guesses an address from a name and a domain, so a contact is added once the relationship is known."
action={
<Button variant="outline" disabled={!writable} onClick={onAdd}>
<UserPlus aria-hidden />
@@ -978,15 +1059,27 @@ function ContactsPanel({
<span className="sr-only">Edit {contact.fullName}</span>
</Button>
</div>
{/*
A part somebody plays is identity, not status: it is neither
good nor bad and nothing follows from it today, so it carries
no colour and the word does the work. Departure is the one
exception on this panel, because emailing a person who has left
is a mistake somebody makes from this list.
*/}
<div className="flex flex-wrap gap-1.5">
{contact.isDecisionMaker ? <Badge tone="accent">Decision maker</Badge> : null}
{contact.isDecisionMaker ? <Badge tone="neutral">Decision maker</Badge> : null}
{(roles.get(contact.id) ?? []).map((role) => (
<Badge key={role.label} tone="info" title={role.deals.join(' · ')}>
<Badge key={role.label} tone="neutral" title={role.deals.join(' · ')}>
{role.label}
{role.deals.length > 1 ? <span className="nums opacity-70">×{role.deals.length}</span> : null}
</Badge>
))}
{contact.departedAt ? <Badge tone="warning">Departed</Badge> : null}
{contact.departedAt ? (
<Badge tone="warning">
<CircleAlert className="size-3.5" aria-hidden />
Departed
</Badge>
) : null}
</div>
{contact.email ? (
<a
@@ -1129,7 +1222,8 @@ function Timeline({
>
{activities.length === 0 ? (
<PanelEmpty
description="No calls, emails or notes have been recorded against this account. Anything logged from the pipeline, Slack or Piggy lands here — and a call you have just had can be written down now."
title="Nothing logged yet"
description="Anything recorded from the pipeline, Slack or Piggy lands here — and a call you have just had can be written down now."
action={
<Button variant="outline" disabled={!canLog} onClick={onLog} title={logTitle(canLog)}>
<StickyNote aria-hidden />
@@ -1141,9 +1235,12 @@ function Timeline({
<div className="flex flex-col gap-6 px-4 pb-5 sm:px-5">
{months.map(([month, entries]) => (
<section key={month} aria-label={month}>
<h3 className="sticky top-[var(--app-header-h)] z-10 -mx-4 bg-surface/95 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur sm:-mx-5 sm:px-5">
<Label
as="h3"
className="sticky top-[var(--app-header-h)] z-10 -mx-4 bg-surface/95 px-4 py-2 backdrop-blur sm:-mx-5 sm:px-5"
>
{month}
</h3>
</Label>
<ol className="mt-2 space-y-4">
{entries.map((activity, index) => {
const Icon = ACTIVITY_ICONS[activity.type];
@@ -1172,7 +1269,7 @@ function Timeline({
</span>
{person ? <span>· {person}</span> : null}
{deal ? <span className="truncate">· {deal}</span> : null}
{activity.actorAgent ? <span>· by {activity.actorAgent}</span> : null}
<ActorNote activity={activity} />
</p>
</div>
</li>
@@ -1204,6 +1301,43 @@ function logTitle(canLog: boolean): string | undefined {
return canLog ? undefined : 'Logging activity against this account needs write access to its side of the book';
}
/**
* Whether Piggy wrote this entry.
*
* Two marks, because the agent leaves two: `pig_log_activity` writes a
* `piggy:` external id, and the four write tools that change a record stamp
* `meta.actorAgent` as the mutation's own activity row is inserted. Neither
* sets the `actor_agent` column, which is reserved for an api-key principal
* so a timeline that read only that column showed an approved proposal as if a
* person had typed it.
*/
function writtenByPiggy(activity: ActivityRecord): boolean {
return (
activity.actorAgent === 'piggy' ||
activity.meta?.actorAgent === 'piggy' ||
Boolean(activity.externalId?.startsWith('piggy:'))
);
}
/**
* Who wrote the entry, where it is worth saying.
*
* Only a machine gets named. An entry with no attribution was typed by a
* person, and "by Priya" on ninety per cent of a timeline is ninety per cent
* noise the exception is the interesting fact, and on this product the
* interesting fact is that the agent did it and a human approved it.
*/
function ActorNote({ activity }: { activity: ActivityRecord }) {
if (writtenByPiggy(activity)) {
return (
<span className="inline-flex items-center gap-1">
·<PiggyMark className="size-3.5 shrink-0" /> by Piggy
</span>
);
}
return activity.actorAgent ? <span>· by {activity.actorAgent}</span> : null;
}
function groupByMonth(activities: ActivityRecord[]): [string, ActivityRecord[]][] {
const months = new Map<string, ActivityRecord[]>();
for (const activity of activities) {
@@ -1217,6 +1351,14 @@ function groupByMonth(activities: ActivityRecord[]): [string, ActivityRecord[]][
// -------------------------------------------------------------- furniture
/**
* A card that a `Section` titles.
*
* The heading, the count and the fold are the primitive's now; the card, the
* icon and the meta line are this page's. `level={2}` puts the panels one step
* under the record's `<h1>` and leaves `h3` free for the timeline's months,
* which were competing with the panel titles at the same level.
*/
function Panel({
icon,
title,
@@ -1234,47 +1376,65 @@ function Panel({
}) {
return (
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 pb-3">
<div className="flex min-w-0 items-center gap-2">
<span className="text-muted">{icon}</span>
<CardTitle className="text-base">{title}</CardTitle>
{count == null ? null : (
<span className="nums rounded-full bg-surface-2 px-2 py-0.5 text-xs text-muted">
{count}
<div className="p-4 pb-3 sm:p-5 sm:pb-3">
<Section
level={2}
title={
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-muted">{icon}</span>
{title}
</span>
)}
</div>
{/*
Both, where both are given. The header used to let an action replace
the meta line, which was harmless while no panel supplied the two
and would have silently dropped "Most recent first" from the timeline
the moment it grew a Log button. Where they compete for the same row
on a phone the meta gives way, because the action is the half that
cannot be recovered by reading further down the panel.
*/}
{action || meta ? (
<div className="flex shrink-0 items-center gap-2">
{meta ? (
<span className={`text-xs text-muted${action ? ' hidden sm:inline' : ''}`}>
{meta}
</span>
) : null}
{action}
</div>
) : null}
</CardHeader>
}
count={count}
/*
Both, where both are given. The header used to let an action replace
the meta line, which was harmless while no panel supplied the two
and would have silently dropped "Most recent first" from the timeline
the moment it grew a Log button. Where they compete for the same row
on a phone the meta gives way, because the action is the half that
cannot be recovered by reading further down the panel.
*/
action={
action || meta ? (
// `-my-2` so a 44px control does not inflate a 24px heading row
// into a 44px one and open a hole above the first list entry.
<div className="-my-2 flex shrink-0 items-center gap-2">
{meta ? (
<span className={`text-xs text-muted${action ? ' hidden sm:inline' : ''}`}>
{meta}
</span>
) : null}
{action}
</div>
) : undefined
}
/>
</div>
{children}
</Card>
);
}
function PanelEmpty({ description, action }: { description: string; action?: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-3 px-6 pb-8 pt-2 text-center">
<p className="max-w-sm text-sm leading-6 text-muted">{description}</p>
{action}
</div>
);
/**
* A panel with nothing in it, said the way the rest of the product says it.
*
* Seven of these existed; this page held the least finished of them a
* centred grey paragraph with no title, no shape and a different height in
* every panel. `panel` is the size `EmptyState` names for a card body, and it
* is used for every one of them including the two in the sidebar: the columns
* collapse into a single stack below `xl`, so a size chosen by which column a
* panel sits in is a page that looks designed by two people on a phone.
*/
function PanelEmpty({
title,
description,
action,
}: {
title: string;
description: string;
action?: React.ReactNode;
}) {
return <EmptyState size="panel" title={title} description={description} action={action} />;
}
function Restricted({
@@ -1290,7 +1450,7 @@ function Restricted({
// child wins the mount order and loses the unmount race, leaving the tab
// named after whichever effect happened to run last.
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<BackLink />
<Card>
<CardContent className="pt-5">
@@ -1312,16 +1472,16 @@ function Restricted({
function LoadingAccount() {
return (
<div className="flex flex-col gap-4" role="status" aria-live="polite">
<div className="flex flex-col gap-6" role="status" aria-live="polite">
<span className="sr-only">Loading account</span>
<Skeleton className="h-4 w-28" />
<Skeleton className="h-48 rounded-2xl" />
<div className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} className="h-24 rounded-2xl" />
))}
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="grid gap-3 xl:grid-cols-3">
<Skeleton className="h-72 rounded-2xl xl:order-2" />
<Skeleton className="h-72 rounded-2xl xl:order-1 xl:col-span-2" />
</div>
@@ -1329,40 +1489,6 @@ function LoadingAccount() {
);
}
// -------------------------------------------------------------------- atoms
function SideBadge({ side }: { side: AccountRecord['side'] }) {
return (
<Badge tone={side === 'supply' ? 'info' : side === 'both' ? 'accent' : 'neutral'}>
{side === 'supply' ? 'Buy-side' : side === 'demand' ? 'Sell-side' : 'Both sides'}
</Badge>
);
}
function RelationshipBadge({ state }: { state: CustomerRelationshipState }) {
const tone =
state === 'deployed'
? 'positive'
: state === 'contracted'
? 'info'
: state === 'former_customer'
? 'warning'
: 'neutral';
return <Badge tone={tone}>{humanise(state)}</Badge>;
}
function FacetBadge({ facet }: { facet: GrowthFacet }) {
const tone =
facet === 'at_risk'
? 'danger'
: facet === 'renewal_due' || facet === 'data_stale'
? 'warning'
: facet === 'expansion_candidate'
? 'positive'
: 'accent';
return <Badge tone={tone}>{humanise(facet)}</Badge>;
}
// ------------------------------------------------------------------ helpers
const DAY_MS = 86_400_000;
@@ -1426,7 +1552,9 @@ function ownerLabel(
}
function humanise(value: string): string {
const spaced = value.replaceAll('_', ' ');
// `ai` is cased because the segment enum is `applied_ai_startup`, and
// "Applied ai startup" on a record about GPU capacity reads as a typo.
const spaced = value.replaceAll('_', ' ').replace(/\bai\b/g, 'AI');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
@@ -1462,22 +1590,3 @@ function alarmRank(alarm: RenewalAlarm): number {
return alarm.state === 'missed' ? 0 : alarm.state === 'due_soon' ? 1 : alarm.state === 'scheduled' ? 2 : 3;
}
function stageTone(stage: DemandDealRecord['stage']) {
if (stage === 'closed_won') return 'positive' as const;
if (stage === 'closed_lost') return 'neutral' as const;
if (stage === 'deployment' || stage === 'expansion') return 'info' as const;
return 'accent' as const;
}
function supplyStageTone(stage: SupplyDealRecord['stage']) {
if (stage === 'live') return 'positive' as const;
if (stage === 'churned' || stage === 'rejected') return 'neutral' as const;
return 'accent' as const;
}
function statusTone(status: ContractStatus) {
if (status === 'executed') return 'positive' as const;
if (status === 'expired' || status === 'terminated') return 'neutral' as const;
if (status === 'out_for_signature') return 'info' as const;
return 'warning' as const;
}
+50 -12
View File
@@ -6,7 +6,9 @@ import { Building2, ChevronRight, Mail, Pencil, Plus, RefreshCw, Search, UserPlu
import { Link } from 'react-router-dom';
import { AccountSheet, ContactSheet, type AccountRecord, type ContactRecord, type ContactRow } from '@/components/RecordSheets';
import { DataTable, DataTableColumnHeader } from '@/components/DataTable';
import { Badge, Button, Card, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
import { SIDE_LABELS, SideBadge } from '@/components/status';
import { Badge, Button, Card, ConfidenceBadge, EmptyState, Input, Skeleton, Stat } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { get, relativeTime } from '@/lib/api';
import { can } from '@/lib/permissions';
@@ -37,7 +39,7 @@ export function Accounts() {
const accountColumns: ColumnDef<AccountRecord>[] = [
{ id: 'account', accessorFn: (account) => `${account.name} ${account.domain ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><Link to={`/accounts/${row.original.id}`} className="truncate block font-medium underline-offset-4 hover:text-accent-fg hover:underline">{row.original.name}</Link>{row.original.domain ? <p className="truncate text-xs text-muted">{row.original.domain}</p> : null}</div> },
{ accessorKey: 'side', header: ({ column }) => <DataTableColumnHeader column={column} title="Side" />, cell: ({ row }) => <SideBadge side={row.original.side} /> },
{ id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = accountType(row.original); return type ? <span className="capitalize">{type.replace(/_/g, ' ')}</span> : '—'; } },
{ id: 'type', accessorFn: (account) => account.supplierType ?? account.customerSegment ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, cell: ({ row }) => { const type = accountType(row.original); return type ? humanise(type) : '—'; } },
{ accessorKey: 'country', header: ({ column }) => <DataTableColumnHeader column={column} title="Country" />, cell: ({ row }) => row.original.country ?? '—' },
{ accessorKey: 'confidence', header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.confidence} /> },
{ accessorKey: 'lastActivityAt', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.lastActivityAt ? relativeTime(row.original.lastActivityAt) : '—' },
@@ -47,7 +49,7 @@ export function Accounts() {
{ id: 'contact', accessorFn: (row) => `${row.contact.fullName} ${row.contact.email ?? ''}`, header: ({ column }) => <DataTableColumnHeader column={column} title="Contact" />, cell: ({ row }) => <div className="min-w-0 max-w-xs"><p className="truncate font-medium">{row.original.contact.fullName}</p>{row.original.contact.email ? <p className="truncate text-xs text-muted">{row.original.contact.email}</p> : <p className="text-xs text-muted">No email recorded</p>}</div> },
{ accessorKey: 'accountName', header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ row }) => row.original.contact.accountId && row.original.accountName ? <Link to={`/accounts/${row.original.contact.accountId}`} className="underline-offset-4 hover:text-accent-fg hover:underline">{row.original.accountName}</Link> : 'Unassigned' },
{ id: 'title', accessorFn: (row) => row.contact.title ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />, cell: ({ row }) => row.original.contact.title ?? '—' },
{ id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => <span className="capitalize">{row.original.contact.affiliation.replace(/_/g, ' ')}</span> },
{ id: 'affiliation', accessorFn: (row) => row.contact.affiliation, header: ({ column }) => <DataTableColumnHeader column={column} title="Affiliation" />, cell: ({ row }) => humanise(row.original.contact.affiliation) },
{ id: 'confidence', accessorFn: (row) => row.contact.confidence, header: ({ column }) => <DataTableColumnHeader column={column} title="Confidence" />, cell: ({ row }) => <Confidence confidence={row.original.contact.confidence} /> },
{ id: 'lastActivityAt', accessorFn: (row) => row.contact.lastActivityAt ?? '', header: ({ column }) => <DataTableColumnHeader column={column} title="Last activity" />, cell: ({ row }) => row.original.contact.lastActivityAt ? relativeTime(row.original.contact.lastActivityAt) : '—' },
{ id: 'actions', enableHiding: false, enableSorting: false, header: 'Actions', cell: ({ row }) => <div className="flex justify-end"><Button size="icon" variant="ghost" title="Edit contact" disabled={!canContact(row.original)} onClick={() => setContactSheet({ open: true, record: row.original.contact })}><Pencil aria-hidden /><span className="sr-only">Edit {row.original.contact.fullName}</span></Button></div> },
@@ -55,21 +57,57 @@ export function Accounts() {
const activeQuery = view === 'accounts' ? accountsQuery : contactsQuery;
const count = view === 'accounts' ? accountsQuery.data?.length ?? 0 : contactsQuery.data?.length ?? 0;
return <div className="flex flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1><p className="mt-1 max-w-2xl text-sm text-muted">Providers we buy from, customers we sell to, and the people who make each relationship real.</p></div><div className="grid grid-cols-2 gap-2 sm:flex"><Button className="min-h-11" variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button><Button className="min-h-11" variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button></div></header>
<section className="rounded-xl border border-border bg-surface-2/60 p-3"><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><Tabs value={view} onValueChange={(value) => { setView(value as View); setMobileSearch(''); }}><TabsList className="grid min-h-[52px] w-full grid-cols-2 border border-border bg-surface p-1 sm:w-64"><TabsTrigger className="min-h-11 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg" value="accounts">Accounts</TabsTrigger><TabsTrigger className="min-h-11 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg" value="contacts">Contacts</TabsTrigger></TabsList></Tabs><p className="text-sm text-muted"><strong className="nums text-fg">{count}</strong> {view}{view === 'accounts' && side !== 'all' ? ` · ${side}` : ''}</p></div>{view === 'accounts' ? <div className="mt-3 grid grid-cols-3 gap-1 rounded-lg bg-surface p-1 sm:ml-auto sm:w-fit">{(['all', 'supply', 'demand'] as const).map((value) => <button key={value} onClick={() => setSide(value)} aria-label={`Show ${value} accounts`} aria-pressed={side === value} className={['min-h-11 rounded-md px-4 text-sm font-medium capitalize transition-colors', side === value ? 'bg-surface-2 text-fg shadow-sm' : 'text-muted hover:text-fg'].join(' ')}>{value}</button>)}</div> : null}</section>
return <div className="flex flex-col gap-6">
<PageHeader
title="Accounts"
description="Providers we buy from, customers we sell to, and the people who make each relationship real."
actions={<><Button variant="outline" disabled={!canAny} onClick={() => setContactSheet({ open: true })}><UserPlus aria-hidden />New contact</Button><Button variant="primary" disabled={!canAny} onClick={() => setAccountSheet({ open: true })}><Plus aria-hidden />New account</Button></>}
/>
{/*
No tinted panel around the toolbar: the tabs already carry a border and a
surface from the primitive, so boxing them added a fourth plane to a
system that has three, and the box was the only one of its kind in the
product.
Two tab bars at opposite ends of the row, because they answer two
different questions which records you are looking at, and which half of
the book they come from. Stacked, they read as one control that has been
split in two. The filter's labels are the book's own: the Side column
beside it says "Buy-side", and a filter saying "supply" for the same fact
is a second vocabulary to learn.
*/}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs value={view} onValueChange={(value) => { setView(value as View); setMobileSearch(''); }}>
<TabsList className="grid w-full grid-cols-2 sm:w-64"><TabsTrigger value="accounts">Accounts</TabsTrigger><TabsTrigger value="contacts">Contacts</TabsTrigger></TabsList>
</Tabs>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3">
{view === 'accounts' ? <Tabs value={side} onValueChange={(value) => setSide(value as typeof side)}>
<TabsList aria-label="Filter by side of the book"><TabsTrigger value="all">All</TabsTrigger><TabsTrigger value="supply">{SIDE_LABELS.supply}</TabsTrigger><TabsTrigger value="demand">{SIDE_LABELS.demand}</TabsTrigger></TabsList>
</Tabs> : null}
<p className="text-sm text-muted"><strong className="nums text-fg">{count}</strong> {view}{view === 'accounts' && side !== 'all' ? ` · ${SIDE_LABELS[side].toLocaleLowerCase()}` : ''}</p>
</div>
</div>
<label className="relative md:hidden"><span className="sr-only">Search {view}</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={mobileSearch} onChange={(event) => setMobileSearch(event.target.value)} placeholder={view === 'accounts' ? 'Search account, country or type' : 'Search contact, account or title'} /></label>
{activeQuery.isLoading ? <Skeleton className="h-64" /> : null}
{activeQuery.isError ? <Card><EmptyState title={`${view === 'accounts' ? 'Accounts' : 'Contacts'} unavailable`} description={activeQuery.error.message} action={<Button variant="outline" onClick={() => void activeQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card> : null}
{!activeQuery.isLoading && !activeQuery.isError && view === 'accounts' ? <><div className="hidden md:block"><DataTable columns={accountColumns} data={accountsQuery.data ?? []} filterColumn="account" filterPlaceholder="Search account names or domains" emptyMessage="No accounts found." /></div><div className="grid gap-3 md:hidden">{visibleAccounts.map((account) => <AccountCard key={account.id} account={account} writable={canAccount(account)} onAddContact={() => setContactSheet({ open: true, accountId: account.id })} onEdit={() => setAccountSheet({ open: true, record: account })} />)}{visibleAccounts.length === 0 ? <Card><EmptyState icon={<Building2 />} title={accountsQuery.data?.length ? 'No accounts match' : `No ${side === 'all' ? '' : `${side} `}accounts`} description={accountsQuery.data?.length ? 'Try a broader search.' : 'No relationship records are available in this view.'} /></Card> : null}</div></> : null}
{!activeQuery.isLoading && !activeQuery.isError && view === 'contacts' ? <><div className="hidden md:block"><DataTable columns={contactColumns} data={contactsQuery.data ?? []} filterColumn="contact" filterPlaceholder="Search contact names or email" emptyMessage="No contacts found." /></div><div className="grid gap-3 md:hidden">{visibleContacts.map((row) => <ContactCard key={row.contact.id} row={row} writable={canContact(row)} onEdit={() => setContactSheet({ open: true, record: row.contact })} />)}{visibleContacts.length === 0 ? <Card><EmptyState icon={<Mail />} title={contactsQuery.data?.length ? 'No contacts match' : 'No contacts recorded'} description={contactsQuery.data?.length ? 'Try a broader search.' : 'Add a sourced contact to an account when the relationship is known.'} /></Card> : null}</div></> : null}
{!activeQuery.isLoading && !activeQuery.isError && view === 'accounts' ? <><div className="hidden md:block"><DataTable columns={accountColumns} data={accountsQuery.data ?? []} filterColumn="account" filterPlaceholder="Search account names or domains" empty={<EmptyState icon={<Building2 />} title="No accounts yet" description="Relationship records appear here once a buy- or sell-side account exists." />} /></div><div className="grid gap-3 md:hidden">{visibleAccounts.map((account) => <AccountCard key={account.id} account={account} writable={canAccount(account)} onAddContact={() => setContactSheet({ open: true, accountId: account.id })} onEdit={() => setAccountSheet({ open: true, record: account })} />)}{visibleAccounts.length === 0 ? <Card><EmptyState icon={<Building2 />} title={accountsQuery.data?.length ? 'No accounts match' : `No ${side === 'all' ? '' : `${side} `}accounts`} description={accountsQuery.data?.length ? 'Try a broader search.' : 'No relationship records are available in this view.'} /></Card> : null}</div></> : null}
{!activeQuery.isLoading && !activeQuery.isError && view === 'contacts' ? <><div className="hidden md:block"><DataTable columns={contactColumns} data={contactsQuery.data ?? []} filterColumn="contact" filterPlaceholder="Search contact names or email" empty={<EmptyState icon={<Mail />} title="No contacts recorded" description="Add a sourced contact to an account when the relationship is known." />} /></div><div className="grid gap-3 md:hidden">{visibleContacts.map((row) => <ContactCard key={row.contact.id} row={row} writable={canContact(row)} onEdit={() => setContactSheet({ open: true, record: row.contact })} />)}{visibleContacts.length === 0 ? <Card><EmptyState icon={<Mail />} title={contactsQuery.data?.length ? 'No contacts match' : 'No contacts recorded'} description={contactsQuery.data?.length ? 'Try a broader search.' : 'Add a sourced contact to an account when the relationship is known.'} /></Card> : null}</div></> : null}
<AccountSheet open={accountSheet.open} onOpenChange={(open) => setAccountSheet((state) => ({ ...state, open }))} record={accountSheet.record} identity={me} /><ContactSheet open={contactSheet.open} onOpenChange={(open) => setContactSheet((state) => ({ ...state, open }))} record={contactSheet.record} defaultAccountId={contactSheet.accountId} identity={me} />
</div>;
}
function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0">{/* The whole title is the tap target: on a phone a link the width of the text is the difference between opening the record and selecting it. */}<Link to={`/accounts/${account.id}`} className="tap flex items-center gap-1 truncate font-semibold underline-offset-4 hover:underline">{account.name}<ChevronRight className="size-4 shrink-0 text-muted" aria-hidden /></Link><p className="mt-0.5 truncate text-sm text-muted">{account.domain ?? 'No domain recorded'}</p></div><SideBadge side={account.side} /></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Relationship" value={type ? type.replace(/_/g, ' ') : 'Not classified'} /><RecordValue label="Geography" value={account.country ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={account.confidence} />} /><RecordValue label="Last activity" value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : 'No activity'} /></div><div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3"><Button className="min-h-11" variant="outline" disabled={!writable} onClick={onAddContact}><UserPlus aria-hidden />Add contact</Button><Button className="min-h-11" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit account</Button></div></article>; }
function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{row.contact.fullName}</p><p className="mt-0.5 truncate text-sm text-muted">{row.contact.title ?? 'No title recorded'}</p></div><Badge tone="neutral">{row.contact.affiliation.replace(/_/g, ' ')}</Badge></div><div className="mt-3 grid grid-cols-2 gap-2 text-sm"><RecordValue label="Account" value={row.contact.accountId && row.accountName ? <Link className="underline-offset-4 hover:underline" to={`/accounts/${row.contact.accountId}`}>{row.accountName}</Link> : 'Unassigned'} /><RecordValue label="Email" value={row.contact.email ?? 'Not recorded'} /><RecordValue label="Confidence" value={<Confidence confidence={row.contact.confidence} />} /><RecordValue label="Last activity" value={row.contact.lastActivityAt ? relativeTime(row.contact.lastActivityAt) : 'No activity'} /></div><Button className="mt-3 min-h-11 w-full border-t border-border" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit contact</Button></article>; }
function RecordValue({ label, value }: { label: string; value: React.ReactNode }) { return <div className="min-w-0 rounded-lg bg-surface-2 p-2.5"><p className="text-[11px] uppercase tracking-wide text-muted">{label}</p><div className="mt-1 truncate capitalize text-xs font-medium">{value}</div></div>; }
function SideBadge({ side }: { side: AccountRecord['side'] }) { return <Badge tone={side === 'supply' ? 'info' : side === 'both' ? 'accent' : 'neutral'}>{side === 'supply' ? 'Buy-side' : side === 'demand' ? 'Sell-side' : 'Both sides'}</Badge>; }
function AccountCard({ account, writable, onAddContact, onEdit }: { account: AccountRecord; writable: boolean; onAddContact(): void; onEdit(): void }) { const type = accountType(account); return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0">{/* The whole title is the tap target: on a phone a link the width of the text is the difference between opening the record and selecting it. */}<Link to={`/accounts/${account.id}`} className="tap flex items-center gap-1 truncate font-semibold underline-offset-4 hover:underline">{account.name}<ChevronRight className="size-4 shrink-0 text-muted" aria-hidden /></Link><p className="mt-0.5 truncate text-sm text-muted">{account.domain ?? 'No domain recorded'}</p></div><SideBadge side={account.side} /></div><div className="mt-3 grid grid-cols-2 gap-2"><RecordValue label="Relationship" value={type ? humanise(type) : null} /><RecordValue label="Geography" value={account.country} /><RecordValue label="Confidence" value={<Confidence confidence={account.confidence} />} /><RecordValue label="Last activity" value={account.lastActivityAt ? relativeTime(account.lastActivityAt) : null} /></div><div className="mt-3 grid grid-cols-2 gap-2 border-t border-border pt-3"><Button variant="outline" disabled={!writable} onClick={onAddContact}><UserPlus aria-hidden />Add contact</Button><Button variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit account</Button></div></article>; }
function ContactCard({ row, writable, onEdit }: { row: ContactRow; writable: boolean; onEdit(): void }) { return <article className="card min-w-0 p-4"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><p className="truncate font-semibold">{row.contact.fullName}</p><p className="mt-0.5 truncate text-sm text-muted">{row.contact.title ?? 'No title recorded'}</p></div><Badge tone="neutral">{humanise(row.contact.affiliation)}</Badge></div><div className="mt-3 grid grid-cols-2 gap-2"><RecordValue label="Account" value={row.contact.accountId && row.accountName ? <Link className="underline-offset-4 hover:underline" to={`/accounts/${row.contact.accountId}`}>{row.accountName}</Link> : 'Unassigned'} /><RecordValue label="Email" value={row.contact.email} /><RecordValue label="Confidence" value={<Confidence confidence={row.contact.confidence} />} /><RecordValue label="Last activity" value={row.contact.lastActivityAt ? relativeTime(row.contact.lastActivityAt) : null} /></div><Button className="mt-3 w-full border-t border-border" variant="ghost" disabled={!writable} onClick={onEdit}><Pencil aria-hidden />Edit contact</Button></article>; }
/**
* The phone card's label-and-value tile, which is `Stat` at the inline size
* one of the six the audit counted at three numeral scales. The value used to
* be 12px and truncated, so a long account name or an email address on this
* card ended mid-word; `Stat` wraps instead, which is the only honest thing to
* do with a value nobody can widen the column for.
*/
function RecordValue({ label, value }: { label: string; value: React.ReactNode }) { return <Stat label={label} value={value} size="sm" surface="inset" />; }
function Confidence({ confidence }: { confidence: string }) { return confidence === 'confirmed' ? <span className="text-muted">Confirmed</span> : <ConfidenceBadge confidence={confidence} />; }
function accountType(account: AccountRecord) { return account.supplierType ?? account.customerSegment; }
/** `ai` is cased because the segment is `applied_ai_startup`, and "Applied ai
* startup" beside a page about GPU capacity reads as a typo. */
function humanise(value: string) { const spaced = value.replaceAll('_', ' ').replace(/\bai\b/g, 'AI'); return spaced.charAt(0).toUpperCase() + spaced.slice(1); }
+80 -99
View File
@@ -20,14 +20,7 @@
* and the agenda beneath it not the timeline is the tappable, screen-reader
* complete record of the same events.
*/
import {
cloneElement,
isValidElement,
useId,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@@ -71,11 +64,13 @@ import {
CardTitle,
EmptyState,
Input,
Label,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
Dialog,
DialogContent,
@@ -351,7 +346,7 @@ export function Calendar() {
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<div className="space-y-6">
<Skeleton className="h-16" />
<div className="grid grid-cols-2 gap-2 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
@@ -367,7 +362,7 @@ export function Calendar() {
// The navigator stays: a quarter that fails to load is a quarter you must
// still be able to step away from.
return (
<div className="space-y-4">
<div className="space-y-6">
{header}
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
@@ -400,7 +395,7 @@ export function Calendar() {
};
return (
<div className="space-y-4 pb-[calc(5.5rem+var(--safe-bottom))] md:space-y-5 md:pb-0">
<div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
{header}
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-5">
@@ -415,18 +410,14 @@ export function Calendar() {
hint="ACV × probability, closing this quarter"
/>
<Stat label="Deals closing" value={data.totals.closingCount} hint="Excludes closed lost" />
<Stat
label="Renewals"
value={data.totals.renewalCount}
hint="Notice date falls in quarter"
tone={data.totals.renewalCount ? 'warning' : 'default'}
/>
<Stat
label="Obligations due"
value={data.totals.obligationCount}
hint="Outstanding only"
tone={data.totals.obligationCount ? 'warning' : 'default'}
/>
{/*
One coloured figure in the row, and it is the one that is not merely
work: a lapsed export authorisation is unlawful business. Renewals and
obligations are counts of things to do, and a row where four numbers
out of five are amber is a row nobody reads.
*/}
<Stat label="Renewals" value={data.totals.renewalCount} hint="Notice date falls in quarter" />
<Stat label="Obligations due" value={data.totals.obligationCount} hint="Outstanding only" />
{/*
Under a "mine" filter the API reports zero expiring authorisations
the table has no owner column, so an owner filter can only ever
@@ -480,6 +471,7 @@ export function Calendar() {
reader in an empty quarter with no control to leave it by.
*/
<EmptyState
size="page"
icon={<CalendarDays />}
title="Nothing dated in this quarter"
description={
@@ -547,17 +539,10 @@ function QuarterHeader({
onCreate(): void;
}) {
return (
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
Calendar <span className="nums text-muted">{quarterLabel(cursor)}</span>
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
What closes, what renews, what expires, and when capacity lands projected from the
records that already carry the dates.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<PageHeader
title={<>Calendar <span className="nums text-muted">{quarterLabel(cursor)}</span></>}
description="What closes, what renews, what expires, and when capacity lands — projected from the records that already carry the dates."
actions={<>
<div className="flex items-center rounded-lg border border-border">
<Button
variant="ghost"
@@ -586,8 +571,8 @@ function QuarterHeader({
<Plus className="size-4" aria-hidden /> New entry
</Button>
) : null}
</div>
</header>
</>}
/>
);
}
@@ -648,7 +633,7 @@ function Filters({
aria-pressed={active}
onClick={() => toggle(kind)}
className={cn(
'tap inline-flex min-h-11 items-center gap-2 rounded-lg border px-3 text-xs font-medium transition-colors',
'tap inline-flex min-h-11 items-center gap-2 rounded-lg border px-3 text-xs font-medium transition-colors duration-1 ease-enter',
active
? TONE_BAR[KIND_TONE[kind]]
: 'border-border text-muted hover:bg-surface-2',
@@ -738,7 +723,7 @@ function ComplianceCard({
type="button"
onClick={() => onEvent(event)}
className={cn(
'tap flex w-full min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors',
'tap flex w-full min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors duration-1 ease-enter',
event.state === 'overdue'
? 'border-danger/60 bg-danger/10'
: 'border-border hover:bg-surface-2',
@@ -900,9 +885,19 @@ function Timeline({
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<div className="min-w-0">
<CardTitle className="text-base">Quarter timeline</CardTitle>
{/*
"Click", not "tap", and the agenda is named as the touch path.
The marks in this lane grid are 24px by construction the row
pitch is 28px, so a 44px target would overlap the row above and
below and open the wrong record so the timeline is a pointer and
keyboard surface, and the agenda underneath is the same events at
full size. Promising a tap the geometry cannot honour was the part
that was wrong; the geometry itself is what a Gantt lane is.
*/}
<p className="mt-1 text-xs text-muted">
One lane per kind. Bars are spans, diamonds are points. Scroll the pane sideways; tap
anything to open the record.
One lane per kind. Bars are spans, diamonds are points. Scroll the pane sideways and
click a mark to open its record or use the agenda below, which lists the same events
in full.
</p>
</div>
</CardHeader>
@@ -916,10 +911,10 @@ function Timeline({
{months.map((month) => (
<div
key={month.key}
className="absolute top-0 border-l border-border/70 pl-2 text-xs font-medium uppercase tracking-wide text-muted"
className="absolute top-0 border-l border-border/70 pl-2"
style={{ left: `${month.leftPct}%`, width: `${month.widthPct}%` }}
>
{month.label}
<Label>{month.label}</Label>
</div>
))}
<div className="h-5" />
@@ -1026,7 +1021,7 @@ function TimelineMark({
aria-label={title}
onClick={() => onEvent(event)}
className={cn(
'absolute flex h-6 min-w-0 items-center overflow-hidden rounded-md border px-1.5 text-left text-[11px] font-medium',
'absolute flex h-6 min-w-0 items-center overflow-hidden rounded-md border px-1.5 text-left text-xs font-medium',
TONE_BAR[tone],
item.clippedStart && 'rounded-l-none border-l-2 border-l-dashed',
item.clippedEnd && 'rounded-r-none border-r-2 border-r-dashed',
@@ -1051,6 +1046,24 @@ const STATE_TONE: Record<CalendarEventState, StatusColor> = {
done: 'neutral',
};
/**
* The tone for a badge, which is not always the tone for a state.
*
* `due` covers two different things a point event inside the seven-day
* horizon, which needs a person, and a SPAN whose start has passed, which is
* simply running. `stateLabel` already told them apart in words; the colour did
* not, so an agenda carried roughly eighteen amber "Running" pills for windows
* nobody has to act on, sitting beside the two rows that genuinely do. Warning
* is the product's one "act now" tone and it is spent by that.
*
* `info` is what `status.tsx` documents for exactly this case: "a neutral time
* or system fact running, queued, out for signature".
*/
function badgeTone(event: CalendarEvent): StatusColor {
if (event.state === 'due' && event.isSpan) return 'info';
return STATE_TONE[event.state];
}
const STATE_LABELS: Record<CalendarEventState, string> = {
overdue: 'Overdue',
due: 'Due',
@@ -1153,21 +1166,19 @@ function AgendaGroup({
}) {
return (
<section className="min-w-0">
<h3
className={cn(
'px-4 pb-2 text-xs font-semibold uppercase tracking-wide sm:px-5',
tone === 'danger' ? 'text-danger' : 'text-muted',
)}
<Label
as="h3"
className={cn('px-4 pb-2 sm:px-5', tone === 'danger' && 'text-danger')}
>
{label} <span className="nums font-normal">· {events.length}</span>
</h3>
{label} <span className="nums">· {events.length}</span>
</Label>
<ul className="min-w-0">
{events.map((event) => (
<li key={event.id} className="min-w-0 border-t border-border/60">
<button
type="button"
onClick={() => onEvent(event)}
className="tap flex w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-surface-2 sm:px-5"
className="tap flex w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left transition-colors duration-1 ease-enter hover:bg-surface-2 sm:px-5"
>
<span
className={cn('size-2.5 shrink-0 rounded-full', TONE_DOT[toneFor(event)])}
@@ -1193,7 +1204,7 @@ function AgendaGroup({
{money(event.amountCents, event.currency ?? 'USD')}
</span>
) : null}
<Badge tone={STATE_TONE[event.state]} className="hidden shrink-0 sm:inline-flex">
<Badge tone={badgeTone(event)} className="hidden shrink-0 sm:inline-flex">
{stateLabel(event)}
</Badge>
</button>
@@ -1258,7 +1269,7 @@ function EntryDialog({ defaultStart, onClose }: { defaultStart: Date; onClose():
save.mutate();
}}
>
<Field label="Title">
<FormField label="Title">
<Input
required
maxLength={240}
@@ -1266,20 +1277,20 @@ function EntryDialog({ defaultStart, onClose }: { defaultStart: Date; onClose():
onChange={(change) => setTitle(change.target.value)}
placeholder="Q3 business review — Halcyon Research"
/>
</Field>
</FormField>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Kind">
<FormField label="Kind">
<EntryKindSelect value={kind} onValueChange={setKind} />
</Field>
<Field label="Starts">
</FormField>
<FormField label="Starts">
<Input
required
type="datetime-local"
value={startsAt}
onChange={(change) => setStartsAt(change.target.value)}
/>
</Field>
<Field
</FormField>
<FormField
label="Ends"
hint="Leave blank for a point in time. A window that closes with nothing done reads as overdue, not done."
className="sm:col-span-2"
@@ -1290,16 +1301,16 @@ function EntryDialog({ defaultStart, onClose }: { defaultStart: Date; onClose():
min={startsAt}
onChange={(change) => setEndsAt(change.target.value)}
/>
</Field>
</FormField>
</div>
<Field label="Notes">
<FormField label="Notes">
<Textarea
rows={3}
maxLength={8000}
value={description}
onChange={(change) => setDescription(change.target.value)}
/>
</Field>
</FormField>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
@@ -1371,7 +1382,7 @@ function EntryDetail({ event, onClose }: { event: CalendarEvent; onClose(): void
<p className="whitespace-pre-wrap text-sm text-muted">{event.meta.description}</p>
) : null}
<div className="flex items-center gap-2">
<Badge tone={STATE_TONE[event.state]}>{stateLabel(event)}</Badge>
<Badge tone={badgeTone(event)}>{stateLabel(event)}</Badge>
{event.state === 'overdue' ? (
<span className="text-xs text-muted">
Its window closed with nothing in the completion column.
@@ -1397,51 +1408,21 @@ function EntryDetail({ event, onClose }: { event: CalendarEvent; onClose(): void
);
}
/**
* The same field wrapper Contracts uses: the id is cloned onto the control
* rather than onto a wrapper, because a `<label for>` pointing at a `<div>`
* associates with nothing and the screen reader reads an unlabelled input.
*/
function Field({
label,
hint,
className,
children,
}: {
label: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
const id = useId();
const control = isValidElement<{ id?: string; 'aria-label'?: string }>(children)
? cloneElement(children, {
id: children.props.id ?? id,
'aria-label': children.props['aria-label'] ?? label,
})
: children;
return (
<div className={cn('flex min-w-0 flex-col gap-1.5', className)}>
<Label htmlFor={id}>{label}</Label>
{control}
{hint ? <p className="text-xs text-muted">{hint}</p> : null}
</div>
);
}
/** Forwards the id Field clones in onto the trigger, which is the focusable element. */
/** Forwards what FormField clones in onto the trigger, which is the focusable element. */
function EntryKindSelect({
id,
value,
onValueChange,
...trigger
}: {
id?: string;
'aria-label'?: string;
'aria-describedby'?: string;
value: CalendarEntryKind;
onValueChange(next: CalendarEntryKind): void;
}) {
return (
<Select value={value} onValueChange={(next) => onValueChange(next as CalendarEntryKind)}>
<SelectTrigger id={id} className="h-11">
<SelectTrigger {...trigger}>
<SelectValue />
</SelectTrigger>
<SelectContent>
+63 -77
View File
@@ -50,9 +50,12 @@ import {
CardTitle,
EmptyState,
Input,
Label,
Skeleton,
Stat,
} from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
Select,
SelectContent,
@@ -61,6 +64,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { UtilisationBar } from '@/components/ui/utilisation-bar';
type CapacityTab = 'available' | 'match' | 'inventory';
@@ -130,10 +134,8 @@ export function Capacity() {
if (!readable) {
return (
<div className="space-y-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
</header>
<div className="space-y-6">
<PageHeader title="Capacity" />
<Card>
<CardContent className="pt-5">
<EmptyState
@@ -148,21 +150,17 @@ export function Capacity() {
}
return (
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
<p className="mt-1 text-sm text-muted">
What we hold, what is sold, what is still sellable and what providers
are charging for the same GPUs.
</p>
</div>
<RecordCommitmentButton
canCommit={canCommit}
onRecord={() => setRecordingCommitment(true)}
className="shrink-0"
/>
</header>
<div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<PageHeader
title="Capacity"
description="What we hold, what is sold, what is still sellable — and what providers are charging for the same GPUs."
actions={
<RecordCommitmentButton
canCommit={canCommit}
onRecord={() => setRecordingCommitment(true)}
/>
}
/>
{/* A segmented control rather than tabs it reads correctly at phone
width, where a tab row would either wrap or scroll. */}
@@ -346,28 +344,25 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri
</p>
</CardHeader>
<CardContent className="space-y-3">
{/* Sold and held are shown as separate segments, because a full-looking
bar made mostly of unconverted holds is a lie a seller would act on. */}
<div>
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2" role="img" aria-label={`${percent(soldPct)} sold, ${percent(heldPct)} held, ${compactNumber(row.availableGpuHours)} GPU-hours sellable`}>
<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-1.5 flex justify-between text-xs text-muted">
<div className="space-y-2">
<UtilisationBar
sold={row.soldGpuHours}
held={row.heldGpuHours}
total={row.totalGpuHours}
label={row.name}
/>
<div className="flex justify-between text-xs text-muted">
<span>{percent(soldPct)} sold</span>
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
<span className="nums">{compactNumber(row.availableGpuHours)} hrs sellable</span>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<dt className="text-muted">Cost</dt>
<dd className="nums text-right">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<dt className="text-muted">Break even</dt>
<dd className="nums text-right">
<dl className="grid grid-cols-2 items-baseline gap-x-3 gap-y-2">
<Label as="dt">Cost</Label>
<dd className="nums text-right text-sm font-medium">{unitPrice(row.costPerGpuHourCents)}/GPU-hr</dd>
<Label as="dt">Break even</Label>
<dd className="nums text-right text-sm font-medium">
{row.breakEvenPriceCents == null
? 'Fully sold'
: row.breakEvenPriceCents === 0
@@ -424,22 +419,22 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
});
return (
<div className="space-y-4">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">What does the customer need?</CardTitle>
<p className="text-xs leading-relaxed text-muted">Match against capacity already under commitment. The allocation ledger remains the final authority when you save.</p>
<p className="text-sm text-muted">Match against capacity already under commitment. The allocation ledger remains the final authority when you save.</p>
</CardHeader>
<CardContent>
<form
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Capacity requirement"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<Field label="GPU type" htmlFor="match-gpu-type">
<FormField label="GPU type">
{/*
* A Select, not free text. Hardware identifiers are matched
* verbatim upstream, so "H100" which is what anyone types
@@ -460,8 +455,8 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
hint: `${compactNumber(option.availableGpuHours)} hrs free`,
}))}
/>
</Field>
<Field label="GPUs" htmlFor="match-gpu-count">
</FormField>
<FormField label="GPUs">
<Input
id="match-gpu-count"
value={form.gpuCount}
@@ -471,8 +466,8 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
inputMode="numeric"
pattern="[0-9]*"
/>
</Field>
<Field label="GPU-hours" htmlFor="match-gpu-hours">
</FormField>
<FormField label="GPU-hours">
<Input
id="match-gpu-hours"
value={form.totalGpuHours}
@@ -480,8 +475,8 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
inputMode="numeric"
placeholder="Optional"
/>
</Field>
<Field label="Max $/GPU-hr" htmlFor="match-max-price">
</FormField>
<FormField label="Max $/GPU-hr">
<Input
id="match-max-price"
value={form.maxPrice}
@@ -489,16 +484,16 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
inputMode="decimal"
placeholder="Optional"
/>
</Field>
<Field label="Needed from" htmlFor="match-starts-at">
</FormField>
<FormField label="Needed from">
<Input
id="match-starts-at"
type="date"
value={form.startsAt}
onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
/>
</Field>
<Field label="Needed until" htmlFor="match-ends-at">
</FormField>
<FormField label="Needed until">
<Input
id="match-ends-at"
type="date"
@@ -506,7 +501,7 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
min={form.startsAt || undefined}
onChange={(e) => setForm({ ...form, endsAt: e.target.value })}
/>
</Field>
</FormField>
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
<input
@@ -566,8 +561,10 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
<CardContent className="pt-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{match.name}</p>
<p className="text-xs text-muted">
<CardTitle className="min-w-0 break-words text-base leading-snug">
{match.name}
</CardTitle>
<p className="mt-1 text-xs text-muted">
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
{compactNumber(match.availableGpuHours)} GPU-hrs sellable
</p>
@@ -623,15 +620,6 @@ function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: s
);
}
function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: React.ReactNode }) {
return (
<label className="block" htmlFor={htmlFor}>
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
{children}
</label>
);
}
// ------------------------------------------------------------ GPU type control
/**
@@ -659,7 +647,7 @@ function GpuTypeSelect({
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} aria-label={label} className="h-11">
<SelectTrigger id={id} aria-label={label}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -717,8 +705,8 @@ function MatchGap({
if (reasons.length === 0) return null;
return (
<div className="mx-auto max-w-lg rounded-lg bg-surface-2 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted">What excluded the book</p>
<div className="mx-auto max-w-lg rounded-md bg-surface-2 p-4 text-left">
<Label>What excluded the book</Label>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-muted">
{reasons.map((reason) => (
<li key={reason}>{reason}</li>
@@ -955,7 +943,7 @@ function ProviderInventory() {
const medianSpread = median(spreads.map((spread) => spread.spreadCents));
return (
<div className="space-y-4">
<div className="space-y-6">
<p className="text-sm text-muted">
What providers are asking, for capacity we do not hold. The number that pays
the business is the spread: an hour on our book against an hour on theirs.
@@ -998,18 +986,16 @@ function ProviderInventory() {
/>
</div>
<div className="sm:max-w-xs">
<Field label="GPU type" htmlFor="inventory-gpu-type">
<GpuTypeSelect
id="inventory-gpu-type"
label="Filter inventory by GPU type"
value={gpuType}
onChange={setGpuType}
anyLabel={`Every GPU type (${typeOptions.length})`}
options={typeOptions}
/>
</Field>
</div>
<FormField label="GPU type" className="sm:max-w-xs">
<GpuTypeSelect
id="inventory-gpu-type"
label="Filter inventory by GPU type"
value={gpuType}
onChange={setGpuType}
anyLabel={`Every GPU type (${typeOptions.length})`}
options={typeOptions}
/>
</FormField>
{groups.length === 0 ? (
<Card>
+232 -169
View File
@@ -1,4 +1,5 @@
import { cloneElement, isValidElement, useDeferredValue, useId, useMemo, useState } from 'react';
import { useDeferredValue, useId, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
@@ -36,10 +37,14 @@ import {
CardContent,
EmptyState,
Input,
Label,
Section,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
Select,
SelectContent,
@@ -60,9 +65,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { PiggyAskButton } from '@/components/PiggyChat';
import {
CONTRACT_STATUS_LABELS,
ContractStatusBadge,
RenewalBadge,
type RenewalState,
} from '@/components/status';
type ContractSide = (typeof ACCOUNT_SIDES)[number];
type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired';
interface ContractRecord {
id: string;
@@ -245,16 +255,6 @@ const TYPE_LABELS: Record<ContractType, string> = {
amendment: 'Amendment',
};
const 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',
};
const EMPTY_FORM: ContractFormState = {
accountId: '',
type: 'msa',
@@ -337,7 +337,23 @@ export function Contracts() {
usePageTitle('Contracts');
const me = useIdentity();
const signing = useContractSigning();
const [selectedId, setSelectedId] = useState<string | null>(null);
/*
* `?contract=<id>` opens straight into that contract's sheet.
*
* PIG has no `/contracts/:id` route, and the consequence was that a contract
* named on another page could not be reached from it at all: the account
* record page lists a customer's paper by title, status and notice date, and
* every one of those rows was inert text. Journey-tested, "open the contract
* this account is on" was impossible the only way through was the list, a
* search and a guess.
*
* Read once, in a lazy initialiser rather than an effect, so arriving with
* the parameter opens the sheet on the first paint and closing it does not
* fight a re-read. The parameter is dropped on close (below) so the sheet
* does not reopen on a refresh or a Back.
*/
const [searchParams, setSearchParams] = useSearchParams();
const [selectedId, setSelectedId] = useState<string | null>(() => searchParams.get('contract'));
const [editor, setEditor] = useState<'create' | 'edit' | null>(null);
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase());
@@ -387,10 +403,8 @@ export function Contracts() {
// three separate network errors.
if (!canAny(me, 'book:read')) {
return (
<div className="flex flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Contracts</h1>
</header>
<div className="space-y-6">
<PageHeader title="Contracts" />
<Card>
<CardContent className="pt-5">
<EmptyState
@@ -405,38 +419,49 @@ export function Contracts() {
}
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Contracts</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Governing paper, commercial quality, service commitments and the dates that cannot slip.
</p>
</div>
<Button
type="button"
variant="primary"
disabled={!signing.any}
title={signing.any ? undefined : SIGN_DENIED}
onClick={() => {
setSelectedId(null);
setEditor('create');
}}
>
<FilePlus2 aria-hidden />
New contract
</Button>
</header>
<div className="space-y-6">
<PageHeader
title="Contracts"
description="Governing paper, commercial quality, service commitments and the dates that cannot slip."
actions={
<Button
type="button"
variant="primary"
disabled={!signing.any}
title={signing.any ? undefined : SIGN_DENIED}
onClick={() => {
setSelectedId(null);
setEditor('create');
}}
>
<FilePlus2 aria-hidden />
New contract
</Button>
}
/>
{contractsQuery.data?.length ? (
<section className="grid grid-cols-3 gap-2 rounded-xl border border-border bg-surface-2/60 p-3" aria-label="Contract portfolio summary">
<PortfolioMetric label="Governing" value={portfolio.governing} />
<PortfolioMetric label="Executed" value={portfolio.executed} />
<PortfolioMetric label="Notice due" value={portfolio.due} urgent={portfolio.due > 0} />
</section>
<Card
role="group"
aria-label="Contract portfolio summary"
className="grid grid-cols-3 gap-2 p-4 sm:p-5"
>
<Stat size="md" surface="inset" label="Governing" value={portfolio.governing} />
<Stat size="md" surface="inset" label="Executed" value={portfolio.executed} />
<Stat
size="md"
surface="inset"
label="Notice due"
value={portfolio.due}
tone={portfolio.due > 0 ? 'warning' : undefined}
/>
</Card>
) : null}
<div className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_160px_140px]">
{/* No panel around the filters. On a 98% canvas the controls' own
`bg-surface` already separates them, and a bordered band here read as a
second card stacked under the portfolio summary. */}
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px_140px]">
<Input
aria-label="Search contracts"
placeholder="Search paper, account or reference"
@@ -492,11 +517,11 @@ export function Contracts() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Paper</TableHead>
<TableHead>Counterparty</TableHead>
<TableHead>Quality</TableHead>
<TableHead>Term</TableHead>
<TableHead>Renewal action</TableHead>
<TableHead><Label as="span">Paper</Label></TableHead>
<TableHead><Label as="span">Counterparty</Label></TableHead>
<TableHead><Label as="span">Quality</Label></TableHead>
<TableHead><Label as="span">Term</Label></TableHead>
<TableHead><Label as="span">Renewal action</Label></TableHead>
<TableHead className="w-12"><span className="sr-only">Open</span></TableHead>
</TableRow>
</TableHeader>
@@ -528,7 +553,9 @@ export function Contracts() {
<TableCell className="nums text-sm">
<Term contract={row.contract} />
</TableCell>
<TableCell><RenewalBadge row={row} /></TableCell>
<TableCell>
<RenewalBadge state={row.renewalState} noticeAt={row.renewalNoticeAt} />
</TableCell>
<TableCell><ChevronRight aria-hidden /></TableCell>
</TableRow>
))}
@@ -548,7 +575,7 @@ export function Contracts() {
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge>{TYPE_LABELS[row.contract.type]}</Badge>
<StatusBadge status={row.contract.status} />
<ContractStatusBadge status={row.contract.status} />
{row.contract.parentContractId ? <Badge tone="neutral">Child paper</Badge> : null}
</div>
<p className="mt-2 truncate font-medium">{row.contract.title}</p>
@@ -557,8 +584,12 @@ export function Contracts() {
<ChevronRight className="shrink-0 text-muted" aria-hidden />
</div>
<div className="mt-3 grid grid-cols-[1fr_auto] items-end gap-3 border-t border-border pt-3 text-xs text-muted">
<div><p className="capitalize">{row.contract.side} · {terminationLabel(row.contract.terminationTier)}</p><p className="nums mt-1"><Term contract={row.contract} /></p></div>
<RenewalBadge row={row} />
{/* `capitalize` on the side alone. Applied to the whole line
it also title-cased the termination label "Tier Not
Recorded" which is a different sentence from the one the
desktop table shows for the same contract. */}
<div><p><span className="capitalize">{row.contract.side}</span> · {terminationLabel(row.contract.terminationTier)}</p><p className="nums mt-1"><Term contract={row.contract} /></p></div>
<RenewalBadge state={row.renewalState} noticeAt={row.renewalNoticeAt} />
</div>
</button>
))}
@@ -576,6 +607,13 @@ export function Contracts() {
if (!open) {
setSelectedId(null);
setEditor(null);
// `replace`, so dismissing a sheet that a link opened does not put
// a second entry in history for the same page.
if (searchParams.has('contract')) {
const next = new URLSearchParams(searchParams);
next.delete('contract');
setSearchParams(next, { replace: true });
}
}
}}
onEdit={() => setEditor('edit')}
@@ -678,16 +716,16 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
<SheetHeader>
<div className="flex flex-wrap items-center gap-2 pr-10">
<Badge>{TYPE_LABELS[detail.contract.type]}</Badge>
<StatusBadge status={detail.contract.status} />
<ContractStatusBadge status={detail.contract.status} />
<span className="text-xs capitalize text-muted">{detail.contract.side}</span>
</div>
<SheetTitle className="pr-10 text-xl">{detail.contract.title}</SheetTitle>
<SheetTitle className="pr-10">{detail.contract.title}</SheetTitle>
<SheetDescription>{detail.accountName ?? 'Unknown counterparty'}</SheetDescription>
</SheetHeader>
{chain.length > 1 ? (
<div className="mt-4 rounded-lg bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Governing hierarchy</p>
<div className="mt-4 rounded-md bg-surface-2 p-3">
<Label className="mb-2">Governing hierarchy</Label>
<div className="flex flex-wrap items-center gap-1.5 text-sm">
{chain.map((contract, index) => (
<span key={contract.id} className="flex items-center gap-1.5">
@@ -715,16 +753,19 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
) : null}
</div>
<Tabs defaultValue="summary" className="mt-5">
<TabsList className="grid h-auto min-h-11 w-full grid-cols-3 border border-border bg-surface-2 p-1">
<TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="summary">Summary</TabsTrigger>
<TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="sla">Service levels</TabsTrigger>
<TabsTrigger className="min-h-11 px-2 text-xs text-muted data-[state=active]:bg-surface data-[state=active]:text-fg sm:text-sm" value="obligations">Obligations {detail.obligations.length}</TabsTrigger>
<Tabs defaultValue="summary" className="mt-6">
{/* `px-2` only: three labels in a 3-column grid overflow their track at
393px on the primitive's own `px-3`, and a tab row is not worth a
horizontal scrollbar. */}
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger className="px-2" value="summary">Summary</TabsTrigger>
<TabsTrigger className="px-2" value="sla">Service levels</TabsTrigger>
<TabsTrigger className="px-2" value="obligations">Obligations {detail.obligations.length}</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="mt-4 flex flex-col gap-4">
<TabsContent value="summary" className="flex flex-col gap-6">
{detail.renewalState === 'due' ? (
<div className="flex gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
<div className="flex gap-3 rounded-md border border-warning/30 bg-warning/10 p-3 text-sm">
<AlertTriangle className="shrink-0 text-warning" aria-hidden />
<div><p className="font-medium">Renewal notice window is open</p><p className="text-muted">Notice was due {shortDate(detail.renewalNoticeAt)}; expiry is {shortDate(detail.contract.expiresAt)}.</p></div>
</div>
@@ -741,21 +782,32 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
</Section>
<Section title="Term and paper">
<TermGrid>
<Value label="Effective" value={shortDate(detail.contract.effectiveAt)} />
<Value label="Expires" value={shortDate(detail.contract.expiresAt)} />
<Value label="Renewal notice" value={detail.contract.noticeDays == null ? '—' : `${detail.contract.noticeDays} days`} />
<Value label="Governing law" value={detail.contract.governingLaw ?? '—'} />
<Value label="Contracting party" value={detail.contract.contractingPartyName ?? detail.accountName ?? '—'} />
<Value label="Counterparty reference" value={detail.contract.externalReference ?? '—'} />
{/*
One tile, via `Term`, rather than an Effective tile beside an
Expires tile. `shortDate` drops the year inside the current one,
so a twelve-month MSA printed "Effective Oct 4, 2025" above
"Expires Oct 4" paper that appears to end before it starts.
The table column already fixed this; the sheet had not.
*/}
<Stat
size="sm"
surface="inset"
label="Term"
value={<Term contract={detail.contract} />}
/>
<Stat size="sm" surface="inset" label="Renewal notice" value={detail.contract.noticeDays == null ? '—' : `${detail.contract.noticeDays} days`} />
<Stat size="sm" surface="inset" label="Governing law" value={detail.contract.governingLaw ?? '—'} />
<Stat size="sm" surface="inset" label="Contracting party" value={detail.contract.contractingPartyName ?? detail.accountName ?? '—'} />
<Stat size="sm" surface="inset" label="Counterparty reference" value={detail.contract.externalReference ?? '—'} />
</TermGrid>
</Section>
{detail.hierarchy.children.length ? (
<Section title="Paper governed by this contract">
<div className="flex flex-col gap-2">
{detail.hierarchy.children.map((child) => (
<div key={child.id} className="flex min-h-11 items-center justify-between rounded-lg bg-surface-2 px-3 py-2 text-sm">
<div key={child.id} className="flex min-h-11 items-center justify-between rounded-md bg-surface-2 px-3 py-2 text-sm">
<span><span className="font-medium">{child.title}</span><span className="ml-2 text-muted">{TYPE_LABELS[child.type]}</span></span>
<StatusBadge status={child.status} />
<ContractStatusBadge status={child.status} />
</div>
))}
</div>
@@ -764,11 +816,11 @@ function ContractDetailView({ detail, onEdit }: { detail: ContractDetail; onEdit
{detail.contract.notes ? <Section title="Notes"><p className="whitespace-pre-wrap text-sm text-muted">{detail.contract.notes}</p></Section> : null}
</TabsContent>
<TabsContent value="sla" className="mt-4 flex flex-col gap-4">
<TabsContent value="sla" className="flex flex-col gap-6">
<SlaDetail detail={detail} />
</TabsContent>
<TabsContent value="obligations" className="mt-4">
<TabsContent value="obligations">
<Obligations detail={detail} />
</TabsContent>
</Tabs>
@@ -784,7 +836,7 @@ function SlaDetail({ detail }: { detail: ContractDetail }) {
}
return (
<>
<div className="flex items-start justify-between gap-3 rounded-lg bg-surface-2 p-4">
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-2 p-4">
<div><p className="font-medium">{kind === 'negotiated' ? 'Negotiated SLA' : 'Credits policy'}</p><p className="mt-1 text-sm text-muted">{kind === 'negotiated' ? 'A signed, measurable service commitment.' : 'Reliability and credits terms, not an uptime guarantee.'}</p></div>
<Badge tone={kind === 'negotiated' ? 'positive' : 'warning'}>{kind === 'negotiated' ? 'Committed' : 'Policy only'}</Badge>
</div>
@@ -801,7 +853,7 @@ function SlaDetail({ detail }: { detail: ContractDetail }) {
</TermGrid>
</Section>
{terms.remedyType?.value === 'fee_abatement' ? (
<div className="rounded-lg border border-info/30 bg-info/10 p-3 text-sm">
<div className="rounded-md border border-info/30 bg-info/10 p-3 text-sm">
<p className="font-medium">Fee abatement trigger</p>
<p className="mt-1 text-muted">Fees abate after {String(terms.abatementTriggerValue?.value ?? '—')} consecutive {humanise(terms.abatementTriggerUnit?.value)}.</p>
</div>
@@ -819,12 +871,12 @@ function SlaDetail({ detail }: { detail: ContractDetail }) {
</Section>
{Array.isArray(terms.creditSchedule?.value) && terms.creditSchedule.value.length ? (
<Section title="Credit tiers">
<div className="flex flex-col gap-2">{terms.creditSchedule.value.map((tier: CreditTier) => <div key={`${tier.belowPct}-${tier.creditPct}`} className="flex justify-between rounded-lg bg-surface-2 px-3 py-2 text-sm"><span>Below {tier.belowPct}%</span><strong>{tier.creditPct}% credit</strong></div>)}</div>
<div className="flex flex-col gap-2">{terms.creditSchedule.value.map((tier: CreditTier) => <div key={`${tier.belowPct}-${tier.creditPct}`} className="flex justify-between rounded-md bg-surface-2 px-3 py-2 text-sm"><span>Below {tier.belowPct}%</span><strong>{tier.creditPct}% credit</strong></div>)}</div>
</Section>
) : null}
{Array.isArray(terms.maintenanceClasses?.value) && terms.maintenanceClasses.value.length ? (
<Section title="Maintenance classes">
<div className="flex flex-col gap-2">{terms.maintenanceClasses.value.map((item: MaintenanceClass) => <div key={item.class} className="rounded-lg bg-surface-2 px-3 py-2 text-sm"><div className="flex justify-between gap-2"><strong>{humanise(item.class)}</strong><span className="text-muted">{item.noticeValue} {humanise(item.noticeUnit)} notice</span></div><p className="mt-1 text-xs text-muted">{item.excludedFromUptime ? 'Excluded from uptime calculation' : 'Counts toward uptime'}{item.allowancePerPeriodHours == null ? '' : ` · ${item.allowancePerPeriodHours} hours allowed per period`}</p></div>)}</div>
<div className="flex flex-col gap-2">{terms.maintenanceClasses.value.map((item: MaintenanceClass) => <div key={item.class} className="rounded-md bg-surface-2 px-3 py-2 text-sm"><div className="flex justify-between gap-2"><strong>{humanise(item.class)}</strong><span className="text-muted">{item.noticeValue} {humanise(item.noticeUnit)} notice</span></div><p className="mt-1 text-xs text-muted">{item.excludedFromUptime ? 'Excluded from uptime calculation' : 'Counts toward uptime'}{item.allowancePerPeriodHours == null ? '' : ` · ${item.allowancePerPeriodHours} hours allowed per period`}</p></div>)}</div>
</Section>
) : null}
{terms.exclusions?.value ? <Section title="Exclusions"><p className="whitespace-pre-wrap text-sm text-muted">{String(terms.exclusions.value)}</p></Section> : null}
@@ -861,17 +913,20 @@ function Obligations({ detail }: { detail: ContractDetail }) {
});
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div><h3 className="font-semibold">Dated obligations</h3><p className="text-xs text-muted">Renewal, payment, review and delivery alarms.</p></div>
<Section
title="Dated obligations"
description="Renewal, payment, review and delivery alarms."
action={
<Button type="button" size="sm" variant="outline" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} onClick={() => setAdding((value) => !value)}><Plus aria-hidden /> Add</Button>
</div>
}
>
<div className="flex flex-col gap-2">
{adding ? (
<form className="rounded-lg border border-border p-3" onSubmit={(event) => { event.preventDefault(); save.mutate(); }}>
<form className="rounded-md border border-border p-3" onSubmit={(event) => { event.preventDefault(); save.mutate(); }}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Obligation" className="sm:col-span-2"><Input required value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Send non-renewal notice" /></Field>
<Field label="Kind"><EnumSelect value={kind} onValueChange={(value) => setKind(value as Obligation['kind'])}>{['renewal_notice', 'milestone', 'payment', 'review', 'true_up'].map((value) => <SelectItem key={value} value={value}>{humanise(value)}</SelectItem>)}</EnumSelect></Field>
<Field label="Due"><Input required type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} /></Field>
<FormField label="Obligation" className="sm:col-span-2"><Input required value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Send non-renewal notice" /></FormField>
<FormField label="Kind"><EnumSelect value={kind} onValueChange={(value) => setKind(value as Obligation['kind'])}>{['renewal_notice', 'milestone', 'payment', 'review', 'true_up'].map((value) => <SelectItem key={value} value={value}>{humanise(value)}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Due"><Input required type="datetime-local" value={dueAt} onChange={(event) => setDueAt(event.target.value)} /></FormField>
</div>
{save.isError ? <p className="mt-2 text-sm text-danger">{save.error.message}</p> : null}
<div className="mt-3 flex justify-end gap-2"><Button type="button" variant="ghost" onClick={() => setAdding(false)}>Cancel</Button><Button type="submit" variant="primary" disabled={save.isPending || !maySign} title={maySign ? undefined : SIGN_DENIED}>Save obligation</Button></div>
@@ -879,9 +934,10 @@ function Obligations({ detail }: { detail: ContractDetail }) {
) : null}
{detail.obligations.length === 0 ? <EmptyState icon={<CalendarClock />} title="No obligations recorded" description="An expiry date alone cannot be acted on. Add the notice, review or true-up deadline." /> : detail.obligations.map((obligation) => {
const overdue = !obligation.completedAt && new Date(obligation.dueAt) < new Date();
return <div key={obligation.id} className={cn('flex items-center gap-3 rounded-lg border p-3', overdue ? 'border-warning/40' : 'border-border')}><button type="button" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} className={cn('flex size-11 shrink-0 items-center justify-center rounded-full border disabled:opacity-50', obligation.completedAt ? 'border-positive bg-positive/10 text-positive' : 'border-border')} aria-label={obligation.completedAt ? 'Reopen obligation' : 'Complete obligation'} onClick={() => complete.mutate(obligation)}>{obligation.completedAt ? <Check aria-hidden /> : null}</button><div className="min-w-0 flex-1"><p className={cn('truncate font-medium', obligation.completedAt && 'text-muted line-through')}>{obligation.title}</p><p className={cn('text-xs', overdue ? 'text-warning' : 'text-muted')}>{humanise(obligation.kind)} · {shortDate(obligation.dueAt)}{overdue ? ' · overdue' : ''}</p></div></div>;
return <div key={obligation.id} className={cn('flex items-center gap-3 rounded-md border p-3', overdue ? 'border-warning/40' : 'border-border')}><button type="button" disabled={!maySign} title={maySign ? undefined : SIGN_DENIED} className={cn('flex size-11 shrink-0 items-center justify-center rounded-full border disabled:opacity-50', obligation.completedAt ? 'border-positive bg-positive/10 text-positive' : 'border-border')} aria-label={obligation.completedAt ? 'Reopen obligation' : 'Complete obligation'} onClick={() => complete.mutate(obligation)}>{obligation.completedAt ? <Check aria-hidden /> : null}</button><div className="min-w-0 flex-1"><p className={cn('truncate font-medium', obligation.completedAt && 'text-muted line-through')}>{obligation.title}</p><p className={cn('text-xs', overdue ? 'text-warning' : 'text-muted')}>{humanise(obligation.kind)} · {shortDate(obligation.dueAt)}{overdue ? ' · overdue' : ''}</p></div></div>;
})}
</div>
</div>
</Section>
);
}
@@ -918,71 +974,71 @@ function ContractEditor({ initial, contractId, accounts, contracts, onCancel, on
<form className="mt-5 flex flex-col gap-5 pb-6" onSubmit={(event) => { event.preventDefault(); save.mutate(); }}>
<Section title="Paper and hierarchy">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Counterparty"><EnumSelect required value={form.accountId || undefined} onValueChange={(value) => set('accountId', value)}><SelectItem value="__choose" disabled>Choose account</SelectItem>{accounts.map((account) => <SelectItem key={account.id} value={account.id}>{account.name}</SelectItem>)}</EnumSelect></Field>
<Field label="Market side"><EnumSelect value={form.side} onValueChange={(value) => set('side', value as ContractSide)}>{ACCOUNT_SIDES.filter((value) => value !== 'both').map((value) => <SelectItem key={value} value={value}>{humanise(value)}</SelectItem>)}</EnumSelect></Field>
<Field label="Paper type"><EnumSelect value={form.type} onValueChange={(value) => set('type', value as ContractType)}>{CONTRACT_TYPES.map((value) => <SelectItem key={value} value={value}>{TYPE_LABELS[value]}</SelectItem>)}</EnumSelect></Field>
<Field label="Status"><EnumSelect value={form.status} onValueChange={(value) => set('status', value as ContractStatus)}>{CONTRACT_STATUSES.map((value) => <SelectItem key={value} value={value}>{STATUS_LABELS[value]}</SelectItem>)}</EnumSelect></Field>
<Field label="Title" className="sm:col-span-2"><Input required value={form.title} onChange={(event) => set('title', event.target.value)} placeholder="Enterprise capacity order form" /></Field>
<Field label="Parent paper" hint="Explicit terms here override its terms."><EnumSelect value={form.parentContractId || '__none'} onValueChange={(value) => set('parentContractId', value === '__none' ? '' : value)}><SelectItem value="__none">No parent</SelectItem>{parentOptions.map(({ contract }) => <SelectItem key={contract.id} value={contract.id}>{TYPE_LABELS[contract.type]} · {contract.title}</SelectItem>)}</EnumSelect></Field>
<Field label="Counterparty reference"><Input value={form.externalReference} onChange={(event) => set('externalReference', event.target.value)} /></Field>
<Field label="Contracting entity"><Input value={form.contractingPartyName} onChange={(event) => set('contractingPartyName', event.target.value)} placeholder="Only where different from account" /></Field>
<Field label="Executed document URL"><Input type="url" value={form.documentUrl} onChange={(event) => set('documentUrl', event.target.value)} placeholder="https://…" /></Field>
<FormField label="Counterparty"><EnumSelect required placeholder="Choose account" value={form.accountId || undefined} onValueChange={(value) => set('accountId', value)}>{accounts.map((account) => <SelectItem key={account.id} value={account.id}>{account.name}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Market side"><EnumSelect value={form.side} onValueChange={(value) => set('side', value as ContractSide)}>{ACCOUNT_SIDES.filter((value) => value !== 'both').map((value) => <SelectItem key={value} value={value}>{humanise(value)}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Paper type"><EnumSelect value={form.type} onValueChange={(value) => set('type', value as ContractType)}>{CONTRACT_TYPES.map((value) => <SelectItem key={value} value={value}>{TYPE_LABELS[value]}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Status"><EnumSelect value={form.status} onValueChange={(value) => set('status', value as ContractStatus)}>{CONTRACT_STATUSES.map((value) => <SelectItem key={value} value={value}>{CONTRACT_STATUS_LABELS[value]}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Title" className="sm:col-span-2"><Input required value={form.title} onChange={(event) => set('title', event.target.value)} placeholder="Enterprise capacity order form" /></FormField>
<FormField label="Parent paper" hint="Explicit terms here override its terms."><EnumSelect value={form.parentContractId || '__none'} onValueChange={(value) => set('parentContractId', value === '__none' ? '' : value)}><SelectItem value="__none">No parent</SelectItem>{parentOptions.map(({ contract }) => <SelectItem key={contract.id} value={contract.id}>{TYPE_LABELS[contract.type]} · {contract.title}</SelectItem>)}</EnumSelect></FormField>
<FormField label="Counterparty reference"><Input value={form.externalReference} onChange={(event) => set('externalReference', event.target.value)} /></FormField>
<FormField label="Contracting entity"><Input value={form.contractingPartyName} onChange={(event) => set('contractingPartyName', event.target.value)} placeholder="Only where different from account" /></FormField>
<FormField label="Executed document URL"><Input type="url" value={form.documentUrl} onChange={(event) => set('documentUrl', event.target.value)} placeholder="https://…" /></FormField>
</div>
</Section>
<Section title="Commercial quality" description="These terms make backlog comparable instead of merely large.">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Contract value"><Input inputMode="decimal" value={form.value} onChange={(event) => set('value', event.target.value)} placeholder="0.00" /></Field>
<Field label="Currency"><Input maxLength={3} value={form.currency} onChange={(event) => set('currency', event.target.value.toUpperCase())} /></Field>
<Field label="Termination tier"><EnumSelect value={form.terminationTier || '__none'} onValueChange={(value) => set('terminationTier', value === '__none' ? '' : value)}><SelectItem value="__none">Not recorded</SelectItem><SelectItem value="1_prepaid">1 · Prepaid take-or-pay</SelectItem><SelectItem value="2_take_or_pay">2 · Take-or-pay</SelectItem><SelectItem value="3_cancellable">3 · Cancellable</SelectItem></EnumSelect></Field>
<Field label="Take-or-pay floor %"><Input inputMode="decimal" value={form.takeOrPayFloorPct} onChange={(event) => set('takeOrPayFloorPct', event.target.value)} /></Field>
<Field label="Prepaid %"><Input inputMode="decimal" value={form.prepaidPct} onChange={(event) => set('prepaidPct', event.target.value)} /></Field>
<Field label="Assignment deadline"><Input inputMode="numeric" value={form.assignmentDeadlineBusinessDays} onChange={(event) => set('assignmentDeadlineBusinessDays', event.target.value)} placeholder="Business days" /></Field>
<FormField label="Contract value"><Input inputMode="decimal" value={form.value} onChange={(event) => set('value', event.target.value)} placeholder="0.00" /></FormField>
<FormField label="Currency"><Input maxLength={3} value={form.currency} onChange={(event) => set('currency', event.target.value.toUpperCase())} /></FormField>
<FormField label="Termination tier"><EnumSelect value={form.terminationTier || '__none'} onValueChange={(value) => set('terminationTier', value === '__none' ? '' : value)}><SelectItem value="__none">Not recorded</SelectItem><SelectItem value="1_prepaid">1 · Prepaid take-or-pay</SelectItem><SelectItem value="2_take_or_pay">2 · Take-or-pay</SelectItem><SelectItem value="3_cancellable">3 · Cancellable</SelectItem></EnumSelect></FormField>
<FormField label="Take-or-pay floor %"><Input inputMode="decimal" value={form.takeOrPayFloorPct} onChange={(event) => set('takeOrPayFloorPct', event.target.value)} /></FormField>
<FormField label="Prepaid %"><Input inputMode="decimal" value={form.prepaidPct} onChange={(event) => set('prepaidPct', event.target.value)} /></FormField>
<FormField label="Assignment deadline"><Input inputMode="numeric" value={form.assignmentDeadlineBusinessDays} onChange={(event) => set('assignmentDeadlineBusinessDays', event.target.value)} placeholder="Business days" /></FormField>
<ToggleField label="Assignable on default" checked={form.assignableOnDefault} onCheckedChange={(value) => set('assignableOnDefault', value)} />
</div>
</Section>
<Section title="Term and renewal">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Effective"><Input type="date" value={form.effectiveAt} onChange={(event) => set('effectiveAt', event.target.value)} /></Field>
<Field label="Expires"><Input type="date" value={form.expiresAt} onChange={(event) => set('expiresAt', event.target.value)} /></Field>
<Field label="Executed"><Input type="date" value={form.executedAt} onChange={(event) => set('executedAt', event.target.value)} /></Field>
<Field label="Notice period"><Input inputMode="numeric" value={form.noticeDays} onChange={(event) => set('noticeDays', event.target.value)} placeholder="Days" /></Field>
<Field label="Governing law"><Input value={form.governingLaw} onChange={(event) => set('governingLaw', event.target.value)} /></Field>
<FormField label="Effective"><Input type="date" value={form.effectiveAt} onChange={(event) => set('effectiveAt', event.target.value)} /></FormField>
<FormField label="Expires"><Input type="date" value={form.expiresAt} onChange={(event) => set('expiresAt', event.target.value)} /></FormField>
<FormField label="Executed"><Input type="date" value={form.executedAt} onChange={(event) => set('executedAt', event.target.value)} /></FormField>
<FormField label="Notice period"><Input inputMode="numeric" value={form.noticeDays} onChange={(event) => set('noticeDays', event.target.value)} placeholder="Days" /></FormField>
<FormField label="Governing law"><Input value={form.governingLaw} onChange={(event) => set('governingLaw', event.target.value)} /></FormField>
<ToggleField label="Auto-renews" checked={form.isAutoRenew} onCheckedChange={(value) => set('isAutoRenew', value)} />
</div>
</Section>
<Section title="Service levels" description="Credits policies are not displayed as negotiated guarantees.">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Commitment shape"><EnumSelect value={form.slaKind} onValueChange={(value) => set('slaKind', value as SlaKind)}>{SLA_KINDS.map((value) => <SelectItem key={value} value={value}>{value === 'none' ? 'No commitment' : value === 'credits_policy' ? 'Credits policy' : 'Negotiated SLA'}</SelectItem>)}</EnumSelect></Field>
<FormField label="Commitment shape"><EnumSelect value={form.slaKind} onValueChange={(value) => set('slaKind', value as SlaKind)}>{SLA_KINDS.map((value) => <SelectItem key={value} value={value}>{value === 'none' ? 'No commitment' : value === 'credits_policy' ? 'Credits policy' : 'Negotiated SLA'}</SelectItem>)}</EnumSelect></FormField>
{form.slaKind !== 'none' ? <>
<Field label="Uptime target %"><Input inputMode="decimal" value={form.uptimeTargetPct} onChange={(event) => set('uptimeTargetPct', event.target.value)} /></Field>
<Field label="Measurement unit"><Input value={form.measurementUnit} onChange={(event) => set('measurementUnit', event.target.value)} placeholder="cluster, rack, node…" /></Field>
<Field label="Measurement window"><Input value={form.measurementWindow} onChange={(event) => set('measurementWindow', event.target.value)} placeholder="monthly" /></Field>
<Field label="Remedy"><EnumSelect value={form.remedyType} onValueChange={(value) => set('remedyType', value as SlaRecord['remedyType'])}><SelectItem value="service_credit">Service credit</SelectItem><SelectItem value="fee_abatement">Fee abatement</SelectItem><SelectItem value="termination_right">Termination right</SelectItem></EnumSelect></Field>
<Field label="Credit cap %"><Input inputMode="decimal" value={form.creditCapPct} onChange={(event) => set('creditCapPct', event.target.value)} /></Field>
{form.remedyType === 'fee_abatement' ? <><Field label="Abatement trigger"><Input inputMode="numeric" value={form.abatementTriggerValue} onChange={(event) => set('abatementTriggerValue', event.target.value)} /></Field><Field label="Trigger unit"><Input value={form.abatementTriggerUnit} onChange={(event) => set('abatementTriggerUnit', event.target.value)} /></Field></> : null}
<Field label="Claim deadline"><Input inputMode="numeric" value={form.claimDeadlineValue} onChange={(event) => set('claimDeadlineValue', event.target.value)} /></Field>
<Field label="Claim deadline unit"><Input value={form.claimDeadlineUnit} onChange={(event) => set('claimDeadlineUnit', event.target.value)} /></Field>
<Field label="Credit expiry months"><Input inputMode="numeric" value={form.creditExpiryMonths} onChange={(event) => set('creditExpiryMonths', event.target.value)} /></Field>
<Field label="Node replacement hours"><Input inputMode="numeric" value={form.nodeReplacementHours} onChange={(event) => set('nodeReplacementHours', event.target.value)} /></Field>
<Field label="MTTR hours"><Input inputMode="numeric" value={form.mttrHours} onChange={(event) => set('mttrHours', event.target.value)} /></Field>
<Field label="Support response hours"><Input inputMode="numeric" value={form.supportResponseHours} onChange={(event) => set('supportResponseHours', event.target.value)} /></Field>
<Field label="RCA delivery hours"><Input inputMode="numeric" value={form.rcaDeliveryHours} onChange={(event) => set('rcaDeliveryHours', event.target.value)} /></Field>
<Field label="Reasonable-endeavours days/year"><Input inputMode="numeric" value={form.reasonableEndeavoursDaysPerYear} onChange={(event) => set('reasonableEndeavoursDaysPerYear', event.target.value)} /></Field>
<FormField label="Uptime target %"><Input inputMode="decimal" value={form.uptimeTargetPct} onChange={(event) => set('uptimeTargetPct', event.target.value)} /></FormField>
<FormField label="Measurement unit"><Input value={form.measurementUnit} onChange={(event) => set('measurementUnit', event.target.value)} placeholder="cluster, rack, node…" /></FormField>
<FormField label="Measurement window"><Input value={form.measurementWindow} onChange={(event) => set('measurementWindow', event.target.value)} placeholder="monthly" /></FormField>
<FormField label="Remedy"><EnumSelect value={form.remedyType} onValueChange={(value) => set('remedyType', value as SlaRecord['remedyType'])}><SelectItem value="service_credit">Service credit</SelectItem><SelectItem value="fee_abatement">Fee abatement</SelectItem><SelectItem value="termination_right">Termination right</SelectItem></EnumSelect></FormField>
<FormField label="Credit cap %"><Input inputMode="decimal" value={form.creditCapPct} onChange={(event) => set('creditCapPct', event.target.value)} /></FormField>
{form.remedyType === 'fee_abatement' ? <><FormField label="Abatement trigger"><Input inputMode="numeric" value={form.abatementTriggerValue} onChange={(event) => set('abatementTriggerValue', event.target.value)} /></FormField><FormField label="Trigger unit"><Input value={form.abatementTriggerUnit} onChange={(event) => set('abatementTriggerUnit', event.target.value)} /></FormField></> : null}
<FormField label="Claim deadline"><Input inputMode="numeric" value={form.claimDeadlineValue} onChange={(event) => set('claimDeadlineValue', event.target.value)} /></FormField>
<FormField label="Claim deadline unit"><Input value={form.claimDeadlineUnit} onChange={(event) => set('claimDeadlineUnit', event.target.value)} /></FormField>
<FormField label="Credit expiry months"><Input inputMode="numeric" value={form.creditExpiryMonths} onChange={(event) => set('creditExpiryMonths', event.target.value)} /></FormField>
<FormField label="Node replacement hours"><Input inputMode="numeric" value={form.nodeReplacementHours} onChange={(event) => set('nodeReplacementHours', event.target.value)} /></FormField>
<FormField label="MTTR hours"><Input inputMode="numeric" value={form.mttrHours} onChange={(event) => set('mttrHours', event.target.value)} /></FormField>
<FormField label="Support response hours"><Input inputMode="numeric" value={form.supportResponseHours} onChange={(event) => set('supportResponseHours', event.target.value)} /></FormField>
<FormField label="RCA delivery hours"><Input inputMode="numeric" value={form.rcaDeliveryHours} onChange={(event) => set('rcaDeliveryHours', event.target.value)} /></FormField>
<FormField label="Reasonable-endeavours days/year"><Input inputMode="numeric" value={form.reasonableEndeavoursDaysPerYear} onChange={(event) => set('reasonableEndeavoursDaysPerYear', event.target.value)} /></FormField>
<ToggleField label="Sole remedy" checked={form.isSoleRemedy} onCheckedChange={(value) => set('isSoleRemedy', value)} />
<Field label="Spare-pool scope" hint="Comma-separated; include switches when the paper does."><Input value={form.sparePoolScope} onChange={(event) => set('sparePoolScope', event.target.value)} placeholder="compute nodes, network switches" /></Field>
<Field label="Spare-pool obligation" className="sm:col-span-2"><Textarea value={form.sparePoolObligation} onChange={(event) => set('sparePoolObligation', event.target.value)} /></Field>
<Field label="Credit tiers" hint="One per line: below %, credit %." className="sm:col-span-2"><Textarea value={form.creditSchedule} onChange={(event) => set('creditSchedule', event.target.value)} placeholder={'99.9, 10\n99.0, 25'} /></Field>
<Field label="Maintenance classes" hint="One per line: class, notice, unit, allowance hours, excluded yes/no." className="sm:col-span-2"><Textarea value={form.maintenanceClasses} onChange={(event) => set('maintenanceClasses', event.target.value)} placeholder="planned, 7, days, 4, yes" /></Field>
<Field label="Additional metric targets" hint="One per line: metric, target, unit." className="sm:col-span-2"><Textarea value={form.metricTargets} onChange={(event) => set('metricTargets', event.target.value)} placeholder="node_availability_pct, 99, percent" /></Field>
<Field label="Exclusions" className="sm:col-span-2"><Textarea value={form.exclusions} onChange={(event) => set('exclusions', event.target.value)} placeholder="Maintenance, force majeure, customer-caused faults…" /></Field>
<FormField label="Spare-pool scope" hint="Comma-separated; include switches when the paper does."><Input value={form.sparePoolScope} onChange={(event) => set('sparePoolScope', event.target.value)} placeholder="compute nodes, network switches" /></FormField>
<FormField label="Spare-pool obligation" className="sm:col-span-2"><Textarea value={form.sparePoolObligation} onChange={(event) => set('sparePoolObligation', event.target.value)} /></FormField>
<FormField label="Credit tiers" hint="One per line: below %, credit %." className="sm:col-span-2"><Textarea value={form.creditSchedule} onChange={(event) => set('creditSchedule', event.target.value)} placeholder={'99.9, 10\n99.0, 25'} /></FormField>
<FormField label="Maintenance classes" hint="One per line: class, notice, unit, allowance hours, excluded yes/no." className="sm:col-span-2"><Textarea value={form.maintenanceClasses} onChange={(event) => set('maintenanceClasses', event.target.value)} placeholder="planned, 7, days, 4, yes" /></FormField>
<FormField label="Additional metric targets" hint="One per line: metric, target, unit." className="sm:col-span-2"><Textarea value={form.metricTargets} onChange={(event) => set('metricTargets', event.target.value)} placeholder="node_availability_pct, 99, percent" /></FormField>
<FormField label="Exclusions" className="sm:col-span-2"><Textarea value={form.exclusions} onChange={(event) => set('exclusions', event.target.value)} placeholder="Maintenance, force majeure, customer-caused faults…" /></FormField>
</> : null}
</div>
</Section>
<Field label="Internal notes"><Textarea value={form.notes} onChange={(event) => set('notes', event.target.value)} placeholder="Do not use this in place of negotiated terms." /></Field>
<FormField label="Internal notes"><Textarea value={form.notes} onChange={(event) => set('notes', event.target.value)} placeholder="Do not use this in place of negotiated terms." /></FormField>
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
<div className="sticky bottom-0 flex flex-col gap-2 border-t border-border bg-surface/95 py-3 backdrop-blur sm:flex-row sm:justify-end">
{maySign ? null : (
@@ -1089,50 +1145,47 @@ function formFromDetail(detail: ContractDetail): ContractFormState {
type EnumSelectProps = React.ComponentProps<typeof Select> & {
id?: string;
'aria-label'?: string;
/** Shown while nothing is chosen. A select whose value is unset otherwise
* renders an empty box that reads as a broken control. */
placeholder?: string;
};
function EnumSelect({ children, id, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
return <Select {...props}><SelectTrigger id={id} aria-label={ariaLabel} className="h-11"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{children}</SelectGroup></SelectContent></Select>;
}
function Field({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
const id = useId();
const control = isValidElement<{ id?: string; 'aria-label'?: string }>(children)
? cloneElement(children, {
id: children.props.id ?? id,
'aria-label': children.props['aria-label'] ?? label,
})
: children;
return <div className={cn('flex flex-col gap-1.5', className)}><Label htmlFor={id}>{label}</Label>{control}{hint ? <p className="text-xs text-muted">{hint}</p> : null}</div>;
function EnumSelect({ children, id, placeholder, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
return <Select {...props}><SelectTrigger id={id} aria-label={ariaLabel}><SelectValue placeholder={placeholder} /></SelectTrigger><SelectContent><SelectGroup>{children}</SelectGroup></SelectContent></Select>;
}
function ToggleField({ label, checked, onCheckedChange }: { label: string; checked: boolean; onCheckedChange(value: boolean): void }) {
const id = useId();
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-lg border border-border px-3"><Label htmlFor={id}>{label}</Label><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
return <div className="flex min-h-11 items-center justify-between gap-3 rounded-lg border border-border px-3"><Label as="label" htmlFor={id}>{label}</Label><Switch id={id} checked={checked} onCheckedChange={onCheckedChange} /></div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return <section><div className="mb-3"><h3 className="font-semibold">{title}</h3>{description ? <p className="mt-0.5 text-xs text-muted">{description}</p> : null}</div>{children}</section>;
}
/**
* A pair of term tiles per row.
*
* No longer a `<dl>`: `Stat` is the product's one label-and-value tile and it
* renders divs, so a definition list here would have been a `dl` whose children
* were not `dt`/`dd` worse for a screen reader than the plain grid it looks
* like.
*/
function TermGrid({ children }: { children: React.ReactNode }) { return <div className="grid gap-2 sm:grid-cols-2">{children}</div>; }
function PortfolioMetric({ label, value, urgent = false }: { label: string; value: number; urgent?: boolean }) {
return <div className="min-w-0 rounded-lg bg-surface px-3 py-2"><p className="truncate text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className={cn('nums mt-0.5 text-lg font-semibold', urgent && 'text-warning')}>{value}</p></div>;
}
function TermGrid({ children }: { children: React.ReactNode }) { return <dl className="grid gap-3 sm:grid-cols-2">{children}</dl>; }
function Value({ label, value }: { label: string; value: React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="text-xs text-muted">{label}</dt><dd className="mt-1 text-sm font-medium">{value}</dd></div>; }
function EffectiveValue({ label, term, suffix = '', format = String }: { label: string; term?: EffectiveTerm; suffix?: string; format?(value: unknown): React.ReactNode }) { return <div className="rounded-lg bg-surface-2 p-3"><dt className="flex items-center justify-between gap-2 text-xs text-muted"><span>{label}</span>{term?.inherited ? <span title="Inherited from governing paper">Inherited</span> : null}</dt><dd className="mt-1 text-sm font-medium">{term ? <>{format(term.value)}{suffix}</> : '—'}</dd></div>; }
function StatusBadge({ status }: { status: ContractStatus }) {
const tone = status === 'executed' ? 'positive' : status === 'expired' || status === 'terminated' ? 'danger' : status === 'out_for_signature' ? 'warning' : 'neutral';
return <Badge tone={tone}>{STATUS_LABELS[status]}</Badge>;
}
function RenewalBadge({ row }: { row: Pick<ContractListRow, 'renewalState' | 'renewalNoticeAt'> }) {
if (row.renewalState === 'not_applicable') return <span className="text-xs text-muted">No alarm</span>;
if (row.renewalState === 'due') return <Badge tone="warning"><AlertTriangle aria-hidden /> Notice due</Badge>;
if (row.renewalState === 'expired') return <Badge tone="danger">Expired</Badge>;
return <span className="text-xs text-muted">Notice {shortDate(row.renewalNoticeAt)}</span>;
/**
* A term as it applies to this paper, and where it came from.
*
* The inheritance marker is the tile's hint rather than a corner chip: "Inherited
* from governing paper" is the sentence a reader needs when a number they are
* about to quote was never typed on the contract in front of them.
*/
function EffectiveValue({ label, term, suffix = '', format = String }: { label: string; term?: EffectiveTerm; suffix?: string; format?(value: unknown): React.ReactNode }) {
return (
<Stat
size="sm"
surface="inset"
label={label}
value={term ? <>{format(term.value)}{suffix}</> : '—'}
hint={term?.inherited ? 'Inherited from governing paper' : undefined}
/>
);
}
/**
@@ -1160,13 +1213,23 @@ function Term({ contract }: { contract: Pick<ContractRecord, 'effectiveAt' | 'ex
}
function QualityCell({ contract }: { contract: ContractRecord }) {
return <div><StatusBadge status={contract.status} /><p className="mt-1 text-xs text-muted">{terminationLabel(contract.terminationTier)}</p></div>;
return <div><ContractStatusBadge status={contract.status} /><p className="mt-1 text-xs text-muted">{terminationLabel(contract.terminationTier)}</p></div>;
}
function terminationLabel(value: unknown): string {
return value === '1_prepaid' ? 'Tier 1 · prepaid' : value === '2_take_or_pay' ? 'Tier 2 · take-or-pay' : value === '3_cancellable' ? 'Tier 3 · cancellable' : 'Tier not recorded';
}
/**
* An underscored enum value as a reader would write it.
*
* Sentence case, not title case. Title-casing every word rendered "Renewal
* Notice" and "Service Credit" beside PIG's own sentence-case copy, and a
* product that capitalises its nouns in one panel and not the next has two
* voices.
*/
function humanise(value: unknown): string {
return value == null ? '—' : String(value).replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
if (value == null) return '';
const words = String(value).replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : '—';
}
+48 -27
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { PermissionGrant } from '@pig/core';
import { Check, ExternalLink, FileCheck2, ShieldCheck, X } from 'lucide-react';
import { Check, ExternalLink, FileCheck2, RefreshCw, X } from 'lucide-react';
import { SourcedValue, type SourcedFact } from '@/components/SourcedValue';
import {
Badge,
@@ -9,8 +9,10 @@ import {
CardHeader,
CardTitle,
EmptyState,
Label,
Skeleton,
} from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import { get, patch, relativeTime } from '@/lib/api';
import { can } from '@/lib/permissions';
@@ -92,26 +94,22 @@ export function FactReview() {
const items = factsQuery.data?.facts ?? [];
return (
<div className="flex flex-col gap-5 sm:gap-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-accent-fg">
<ShieldCheck className="size-4" aria-hidden />
Evidence control
</div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Fact review</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Inspect provenance, decide whether the evidence is credible, and keep uncertain claims out of record truth.
</p>
</div>
<Badge tone={items.length > 0 ? 'warning' : 'positive'} aria-live="polite">
{items.length} awaiting review
</Badge>
</header>
<div className="space-y-6">
<PageHeader
title="Fact review"
description="Inspect provenance, decide whether the evidence is credible, and keep uncertain claims out of record truth."
/* Warning only while the queue holds work: a cleared queue is nothing
to do, and a green badge on every visit teaches people to skip it. */
actions={
<Badge tone={items.length > 0 ? 'warning' : 'neutral'} aria-live="polite">
{items.length} awaiting review
</Badge>
}
/>
<Card className="overflow-hidden border-primary/30 bg-accent-subtle/40">
<Card className="overflow-hidden">
<CardContent className="flex gap-3 p-4 sm:p-5">
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-muted" aria-hidden />
<div className="min-w-0">
<p className="font-medium">Approval validates evidence only. It does not write a CRM field.</p>
<p className="mt-1 text-sm text-muted">
@@ -134,7 +132,20 @@ export function FactReview() {
<Card>
<EmptyState
title="Could not load the review queue"
description={factsQuery.error instanceof Error ? factsQuery.error.message : 'Try again.'}
description={
factsQuery.error instanceof Error
? factsQuery.error.message
: 'The queue could not be read.'
}
// The description used to BE the instruction ("Try again.") with no
// control to carry it out. Twelve of fifteen routes offer the
// button; this was one of the three that did not.
action={
<Button type="button" variant="outline" onClick={() => void factsQuery.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</Card>
) : null}
@@ -186,7 +197,14 @@ function ReviewCard({
const hasEvidence = Boolean(sourceUrl || (fact.evidence && Object.keys(fact.evidence).length > 0));
return (
<Card className="min-w-0">
/*
A column that fills its grid cell, so the decision row sits on the floor
of the card rather than wherever the evidence above it happened to end.
In a two-up row of unequal evidence the two Approve buttons were 22px
apart with 39px of dead space under one and 61px under the other, which
reads as an unfinished layout on the page whose job is a decision.
*/
<Card className="flex min-w-0 flex-col">
<CardHeader className="gap-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
@@ -199,13 +217,16 @@ function ReviewCard({
{humanise(fact.band)}
</Badge>
</div>
<CardTitle className="min-w-0 text-lg">
{/* 16px, like every other card title in the product. This was the only
`text-lg` CardTitle left 18px is the section-heading size the
scale retired. */}
<CardTitle className="min-w-0">
<SourcedValue value={fact.value} fact={fact} />
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-lg bg-surface-2 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Evidence</p>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="rounded-md bg-surface-2 p-2.5">
<Label>Evidence</Label>
<p className="mt-1 break-words text-sm leading-relaxed">
{evidenceSummary(fact.evidence)}
</p>
@@ -223,7 +244,7 @@ function ReviewCard({
</div>
{mayReview ? (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div className="mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
@@ -246,7 +267,7 @@ function ReviewCard({
</Button>
</div>
) : (
<p className="text-xs text-muted">
<p className="mt-auto text-xs text-muted">
Research administrators can decide proposals. You have read-only access.
</p>
)}
+189 -99
View File
@@ -1,23 +1,24 @@
/**
* Growth which account to expand, renew or protect next.
*
* The page used to announce itself: an accent pill reading "GROWTH
* INTELLIGENCE" under a Sparkles glyph, a 30px headline, a blurred accent wash
* and a gradient hairline on every customer card. None of that is in the rest
* of the product, and two of the marks the accent colour and the sparkle
* are the agent's, on a page the agent does not appear on. What is left is the
* same header, tiles and badges as every other analysis surface, so the page
* reads as part of one product rather than as its own launch.
*/
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type {
CustomerLifecycleProjection,
CustomerRelationshipState,
GrowthFacet,
} from '@pig/core';
import {
AlertTriangle,
ArrowUpRight,
Bot,
CircleDollarSign,
Clock3,
Gauge,
Server,
Sparkles,
} from 'lucide-react';
import type { CustomerLifecycleProjection } from '@pig/core';
import { AlertTriangle, ArrowUpRight, Gauge, Server } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PiggyAskButton } from '@/components/PiggyChat';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Label, Skeleton, Stat } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FacetBadge, RelationshipBadge } from '@/components/status';
import { compactNumber, dateRange, get, money, unitPrice } from '@/lib/api';
import { initials } from '@/lib/utils';
import { usePageTitle } from '@/lib/title';
@@ -51,6 +52,21 @@ interface GrowthReport {
type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle';
const GROWTH_VIEWS = [
['priority', 'Priority'],
['expansion', 'Expansion'],
['renewal', 'Renewal'],
['risk', 'Risk'],
['idle', 'Idle supply'],
] as const satisfies ReadonlyArray<readonly [GrowthView, string]>;
function customersInView(customers: GrowthCustomer[], view: GrowthView): GrowthCustomer[] {
if (view === 'expansion') return customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate'));
if (view === 'renewal') return customers.filter((row) => row.lifecycle.facets.includes('renewal_due'));
if (view === 'risk') return customers.filter((row) => row.lifecycle.facets.includes('at_risk') || row.lifecycle.facets.includes('data_stale'));
return customers;
}
export function Growth() {
usePageTitle('Growth');
const [view, setView] = useState<GrowthView>('priority');
@@ -58,56 +74,76 @@ export function Growth() {
queryKey: ['growth'],
queryFn: () => get<GrowthReport>('/api/growth'),
});
const customers = useMemo(() => {
if (!data) return [];
if (view === 'expansion') return data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate'));
if (view === 'renewal') return data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due'));
if (view === 'risk') return data.customers.filter((row) => row.lifecycle.facets.includes('at_risk') || row.lifecycle.facets.includes('data_stale'));
return data.customers;
}, [data, view]);
const totals = useMemo(() => {
if (!data) return null;
return {
deployed: data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length,
expansion: data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length,
attention: data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due') || row.lifecycle.facets.includes('at_risk')).length,
idleCost: data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0),
};
}, [data]);
if (isLoading) return <div className="flex flex-col gap-4"><Skeleton className="h-40" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
if (error || !data) return <Card><CardContent className="flex flex-col items-center gap-4 pt-6"><EmptyState title="Growth intelligence is unavailable" description={error instanceof Error ? error.message : 'The lifecycle projection could not be loaded.'} /><Button variant="outline" onClick={() => void refetch()}>Try again</Button></CardContent></Card>;
const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length;
const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length;
const attention = data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due') || row.lifecycle.facets.includes('at_risk')).length;
const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0);
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-16" />
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}
</div>
<Skeleton className="h-80" />
</div>
);
}
if (error || !data || !totals) {
return (
<Card>
<CardContent className="pt-5">
<EmptyState
title="Growth is unavailable"
description={error instanceof Error ? error.message : 'The lifecycle projection could not be loaded.'}
action={<Button variant="outline" onClick={() => void refetch()}>Try again</Button>}
/>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4 pb-[calc(5.5rem+var(--safe-bottom))] md:space-y-5 md:pb-0">
<header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-4 py-4 sm:px-7 sm:py-6">
<div className="absolute -right-16 -top-20 size-56 rounded-full bg-accent/10 blur-3xl" />
<div className="relative max-w-3xl">
<div className="mb-2 inline-flex items-center gap-2 rounded-full bg-accent-subtle px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-accent-fg"><Sparkles className="size-3.5" aria-hidden />Growth intelligence</div>
<h1 className="text-xl font-semibold tracking-tight sm:text-3xl">Expand, renew, or protect the right account next.</h1>
<p className="mt-1.5 max-w-2xl text-sm leading-5 text-muted sm:mt-2 sm:leading-6">Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Attention scores are not win or churn probabilities.</p>
<p className="mt-2 text-[11px] text-muted sm:mt-3 sm:text-xs">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
</div>
</header>
<div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<PageHeader
title="Growth"
description={
<>
Expand, renew, or protect the right account next. Deterministic signals from customer
paper, deal activity and sold or reserved capacity an attention score is not a win or
churn probability.
{/* Provenance sits with the claim rather than on a settings page:
a ranking nobody can date is a ranking nobody can argue with. */}
<span className="mt-1 block text-xs">
Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}
</span>
</>
}
/>
{data.customersTruncated ? (
<div className="rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-sm text-warning" role="status">
<div className="rounded-xl border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning" role="status">
Showing the 200 most recently updated demand accounts. Narrow the workspace before treating these totals as complete.
</div>
) : null}
<div role="tablist" aria-label="Growth views" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{([
['priority', 'Priority'],
['expansion', 'Expansion'],
['renewal', 'Renewal'],
['risk', 'Risk'],
['idle', 'Idle supply'],
] as const).map(([value, label]) => (
<button key={value} role="tab" aria-selected={view === value} onClick={() => setView(value)} className={['tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm ring-1 ring-border' : 'text-muted hover:bg-surface-2'].join(' ')}>{label}</button>
))}
</div>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
{/*
One coloured figure. Renewal or risk is the only one of the four that
names something a person has to decide this week; a count of deployed
customers is a fact, and the idle cost below it is the same money the
Overview already flags at a lower threshold drawn in danger here it
made the page's own subject, the accounts, the third-loudest thing on it.
*/}
<section className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<Stat label="Deployed customers" value={totals.deployed} hint="Active sold capacity" />
<Stat label="Expansion candidates" value={totals.expansion} hint="Evidence-backed openings" />
<Stat label="Renewal or risk" value={totals.attention} hint="Needs a human decision" tone={totals.attention ? 'warning' : 'default'} />
{/*
Scoped in the hint, because this is the cost of the blocks listed
under "Idle supply" and not the book's whole idle spend. Read as a
@@ -115,49 +151,106 @@ export function Growth() {
lower threshold and therefore always shows a larger number for the
same book.
*/}
<Stat label="Idle supply cost" value={money(idleCost)} hint="Near-term blocks over the idle threshold" tone={idleCost ? 'danger' : 'default'} />
<Stat label="Idle supply cost" value={money(totals.idleCost)} hint="Near-term blocks over the idle threshold" />
</section>
{view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : (
customers.length ? (
<section className="grid gap-3 xl:grid-cols-2">
{customers.map((customer) => <CustomerCard key={customer.account.id} customer={customer} />)}
</section>
) : <Card><EmptyState icon={<Gauge />} title="No accounts in this view" description="Growth only surfaces a facet when its deterministic evidence threshold is met." /></Card>
)}
{/*
Radix rather than the five hand-rolled buttons that were here: they
announced themselves as a tablist without a tabpanel to point at, and
the selected one wore a shadow, which in this system belongs to cards
alone.
*/}
<Tabs value={view} onValueChange={(next) => setView(next as GrowthView)}>
{/* The list is wider than a phone at five tabs, so it scrolls in its
own gutter rather than making the page scroll sideways. */}
<div className="scroll-x -mx-1 px-1 pb-1">
<TabsList aria-label="Growth views">
{GROWTH_VIEWS.map(([value, label]) => (
<TabsTrigger key={value} value={value}>{label}</TabsTrigger>
))}
</TabsList>
</div>
{GROWTH_VIEWS.map(([value]) => (
<TabsContent key={value} value={value}>
{value === 'idle' ? (
<IdleSupply rows={data.idleSupply} />
) : (
<CustomerGrid customers={customersInView(data.customers, value)} />
)}
</TabsContent>
))}
</Tabs>
</div>
);
}
function CustomerGrid({ customers }: { customers: GrowthCustomer[] }) {
if (!customers.length) {
return (
<Card>
<EmptyState
icon={<Gauge className="size-8" />}
title="No accounts in this view"
description="Growth only surfaces a facet when its deterministic evidence threshold is met."
/>
</Card>
);
}
return (
// `items-start`, as the Overview grid does: an account with one signal
// stretched to the height of one with four, opening a hole under its own
// buttons.
<section className="grid items-start gap-3 xl:grid-cols-2">
{customers.map((customer) => <CustomerCard key={customer.account.id} customer={customer} />)}
</section>
);
}
function CustomerCard({ customer }: { customer: GrowthCustomer }) {
const { account, lifecycle } = customer;
return (
<Card className="overflow-hidden">
<div className="h-1 bg-gradient-to-r from-accent via-info to-positive" />
<Card>
<CardHeader className="gap-3">
<div className="flex items-start gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{initials(account.name)}</div>
<div className="min-w-0 flex-1"><CardTitle className="break-words text-lg leading-snug"><Link className="underline-offset-4 hover:underline" to={`/accounts/${account.id}`}>{account.name}</Link></CardTitle><p className="mt-1 truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
<div className="text-right" aria-label={`Attention score ${lifecycle.score}`}><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div>
<div className="min-w-0 flex-1">
<CardTitle className="break-words text-base leading-snug">
<Link className="underline-offset-4 hover:underline" to={`/accounts/${account.id}`}>{account.name}</Link>
</CardTitle>
<p className="mt-1 truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p>
</div>
{/* A panel figure, not a page one: the account's name outranks its
score, and at 30px the score was the largest thing on the card. */}
<Stat surface="bare" size="md" label="Attention" value={lifecycle.score} className="shrink-0 text-right" />
</div>
<div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 text-center">
<Metric label="Open deals" value={customer.openDealCount} />
<Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
<Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2">
<Stat surface="inset" size="sm" label="Open deals" value={customer.openDealCount} />
<Stat surface="inset" size="sm" label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
<Stat surface="inset" size="sm" label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
</div>
<div className="space-y-2">
{lifecycle.signals.slice(0, 2).map((signal) => (
<div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3">
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">+{signal.weight}</span>
<div className="min-w-0"><p className="text-sm leading-5">{signal.explanation}</p><p className="mt-1 text-[11px] uppercase tracking-wide text-muted">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</p></div>
<div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg bg-surface-2 p-3">
{/* `bg-surface` on the tile's own `bg-surface-2`: the weight chip
used to be surface-2 inside a bordered box and disappeared the
moment the box became a tile. */}
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-md bg-surface text-xs font-semibold">+{signal.weight}</span>
<div className="min-w-0">
<p className="text-sm leading-5">{signal.explanation}</p>
<Label className="mt-1">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</Label>
</div>
</div>
))}
{lifecycle.signals.length > 2 ? <p className="px-1 text-xs text-muted">+{lifecycle.signals.length - 2} more evidence signal{lifecycle.signals.length === 3 ? '' : 's'} in the account context</p> : null}
</div>
{lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
{/* 8px, the inset-tile radius. `Account.tsx` renders the same blocker
string at `rounded-md` and this rendered it at `rounded-lg` the
same sentence at two radii on two pages was the clearest single
proof left that the radius rule was not being followed. */}
{lifecycle.blockers.length ? <div className="rounded-md bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
<div className="flex flex-wrap gap-2">
<PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" />
<Link className="tap inline-flex min-h-11 flex-1 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2 sm:flex-none" to={`/accounts/${account.id}`}>Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
@@ -168,30 +261,27 @@ function CustomerCard({ customer }: { customer: GrowthCustomer }) {
}
function IdleSupply({ rows }: { rows: GrowthReport['idleSupply'] }) {
if (!rows.length) return <Card><EmptyState icon={<Server />} title="No material idle supply" description="No near-term commitment currently clears the idle-capacity threshold." /></Card>;
return <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{rows.map((row) => (
if (!rows.length) return <Card><EmptyState icon={<Server className="size-8" />} title="No material idle supply" description="No near-term commitment currently clears the idle-capacity threshold." /></Card>;
return <section className="grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3">{rows.map((row) => (
<Card key={row.commitmentId}>
<CardHeader><div className="flex items-start justify-between gap-3"><div><CardTitle>{row.name}</CardTitle><p className="mt-1 text-sm text-muted">{row.gpuCount}× {row.gpuType}</p></div><Badge tone="warning">{money(row.idleCostCents)} idle cost</Badge></div></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-2"><Metric label="Sold" value={compactNumber(row.soldGpuHours)} /><Metric label="Held" value={compactNumber(row.heldGpuHours)} /><Metric label="Sellable" value={compactNumber(row.availableGpuHours)} /></div>
<div className="space-y-1 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span>{dateRange(row.startsAt, row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span>{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="break-words text-base leading-snug">{row.name}</CardTitle>
<p className="mt-1 text-sm text-muted">{row.gpuCount}× {row.gpuType}</p>
</div>
<Badge tone="warning" className="nums shrink-0">{money(row.idleCostCents)} idle</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<Stat surface="inset" size="sm" label="Sold" value={compactNumber(row.soldGpuHours)} />
<Stat surface="inset" size="sm" label="Held" value={compactNumber(row.heldGpuHours)} />
<Stat surface="inset" size="sm" label="Sellable" value={compactNumber(row.availableGpuHours)} />
</div>
<div className="space-y-2 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span className="text-right">{dateRange(row.startsAt, row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span className="nums text-right">{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${unitPrice(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
<Link className="tap inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2" to="/capacity">Match this capacity <ArrowUpRight className="size-4" aria-hidden /></Link>
</CardContent>
</Card>
))}</section>;
}
function RelationshipBadge({ state }: { state: CustomerRelationshipState }) {
const tone = state === 'deployed' ? 'positive' : state === 'contracted' ? 'info' : state === 'former_customer' ? 'warning' : 'neutral';
return <Badge tone={tone}>{state.replaceAll('_', ' ')}</Badge>;
}
function FacetBadge({ facet }: { facet: GrowthFacet }) {
const icon = facet === 'renewal_due' ? <Clock3 aria-hidden /> : facet === 'at_risk' ? <AlertTriangle aria-hidden /> : facet === 'idle_supply_match' ? <Server aria-hidden /> : facet === 'expansion_candidate' ? <CircleDollarSign aria-hidden /> : facet === 'data_stale' ? <Bot aria-hidden /> : null;
const tone = facet === 'at_risk' ? 'danger' : facet === 'renewal_due' || facet === 'data_stale' ? 'warning' : facet === 'expansion_candidate' ? 'positive' : 'accent';
return <Badge tone={tone}>{icon}{facet.replaceAll('_', ' ')}</Badge>;
}
function Metric({ label, value }: { label: string; value: string | number }) {
return <div className="min-w-0"><div className="nums truncate font-semibold">{value}</div><div className="truncate text-[10px] uppercase tracking-wide text-muted">{label}</div></div>;
}
+32 -23
View File
@@ -9,6 +9,8 @@ import {
} from '@pig/core';
import { AlertTriangle, CheckCircle2, FileSpreadsheet, LoaderCircle, Lock, Upload } from 'lucide-react';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Input } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
Select,
SelectContent,
@@ -130,14 +132,13 @@ export function Imports() {
// the page with no h1 and no breadcrumb, so someone sent here by a link
// landed on a refusal with nothing naming the page it came from.
return (
<div className="flex flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Import data</h1>
</header>
<div className="space-y-6">
<PageHeader title="Import data" />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock className="h-8 w-8" />}
size="page"
icon={<Lock className="size-8" />}
title="Import access required"
description="A team administrator with data-import permission must run spreadsheet imports."
/>
@@ -148,11 +149,11 @@ export function Imports() {
}
return (
<div className="flex flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Import data</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review.</p>
</header>
<div className="space-y-6">
<PageHeader
title="Import data"
description="Stage source data, inspect every create or update, then commit the reviewed plan atomically. Nothing writes to PIG before review."
/>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{IMPORT_ENTITIES.map((candidate) => {
@@ -180,7 +181,11 @@ export function Imports() {
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-1 rounded-xl bg-subtle p-1">
{/* The selected segment is drawn on `surface`, not `surface-2`: the
strip is already `surface-2`, so a `secondary` button marked the
choice in exactly the colour it was sitting on and no segment
looked selected at all. */}
<div className="grid grid-cols-3 gap-1 rounded-xl bg-surface-2 p-1">
{([
['file', 'File'],
['notion', 'Notion'],
@@ -189,8 +194,9 @@ export function Imports() {
<Button
key={value}
type="button"
variant={source === value ? 'secondary' : 'ghost'}
className="min-h-11 px-2"
variant="ghost"
aria-pressed={source === value}
className={source === value ? 'bg-surface px-2 text-fg shadow-sm' : 'px-2 text-muted'}
onClick={() => setSource(value)}
>
{label}
@@ -230,19 +236,23 @@ export function Imports() {
<Card>
<CardHeader><CardTitle className="text-base">2. Map source columns</CardTitle><p className="text-xs text-muted">Only mapped fields are written. Blank optional cells clear nullable fields; required and non-null defaulted fields are left unchanged on updates.</p></CardHeader>
<CardContent className="flex flex-col gap-5">
<label className="flex flex-col gap-1.5 text-sm font-medium">
Stable source key
<FormField
label="Stable source key"
hint="Repeated imports update the same PIG record only when this source value and column name are unchanged."
>
<Select value={keySourceColumn} onValueChange={(value) => { setKeySourceColumn(value); setPreview(null); }}>
<SelectTrigger className="h-11"><SelectValue placeholder="Choose a unique source column" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder="Choose a unique source column" /></SelectTrigger>
<SelectContent><SelectGroup>{parsed.headers.map((header) => <SelectItem key={header} value={header}>{header}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
<span className="text-xs font-normal text-muted">Repeated imports update the same PIG record only when this source value and column name are unchanged.</span>
</label>
</FormField>
<Separator />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{definition.fields.map((field) => (
<label key={field.key} className="flex min-w-0 flex-col gap-1.5 text-sm font-medium">
<span>{field.label}{field.required ? <span className="text-danger"> *</span> : null}</span>
<FormField
key={field.key}
label={field.required ? `${field.label} *` : field.label}
hint={field.description}
>
<Select value={mapping[field.key] ?? 'none'} onValueChange={(value) => {
setMapping((current) => {
const next = { ...current };
@@ -252,11 +262,10 @@ export function Imports() {
});
setPreview(null);
}}>
<SelectTrigger className="h-11"><SelectValue /></SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup><SelectItem value="none">Do not import</SelectItem>{parsed.headers.map((header) => <SelectItem key={header} value={header}>{header}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
{field.description ? <span className="text-xs font-normal text-muted">{field.description}</span> : null}
</label>
</FormField>
))}
</div>
<Button variant="primary" disabled={dryRun.isPending || !keySourceColumn} onClick={() => dryRun.mutate()}>
+28 -40
View File
@@ -62,7 +62,8 @@ import { ApiError, get } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { toPiggyPageRoute } from '@pig/core';
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
import { Badge, Button, Card, EmptyState, Label, Skeleton } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { PublicHeader } from '@/components/PublicHeader';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AddResourceDialog } from '@/components/learn/AddResourceDialog';
@@ -161,6 +162,11 @@ export function Learn() {
description={
member.error instanceof Error ? member.error.message : 'Could not load the library.'
}
action={
<Button type="button" variant="outline" onClick={() => void member.refetch()}>
Try again
</Button>
}
/>
</Card>
);
@@ -184,22 +190,14 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
const platform = feed.tracks.platform ?? [];
return (
<div className="flex min-w-0 flex-col gap-10">
<header className="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
Curriculum
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Learn</h1>
{/* Precise about what the code opens: an earlier line promised that
"anything on the Platform track" could be sent to someone with no
account, which is true only of the rows marked by code. */}
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
How this market works, and how PIG works. The Platform track is what the share code
opens; Concepts stays with members.
</p>
</div>
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
<div className="flex min-w-0 flex-col gap-8">
{/* Precise about what the code opens: an earlier line promised that
"anything on the Platform track" could be sent to someone with no
account, which is true only of the rows marked by code. */}
<PageHeader
title="Learn"
description="How this market works, and how PIG works. The Platform track is what the share code opens; Concepts stays with members."
actions={<>
<Badge tone="neutral" className="nums">
{total} video{total === 1 ? '' : 's'}
</Badge>
@@ -229,8 +227,8 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
Add a video
</Button>
) : null}
</div>
</header>
</>}
/>
<TrackSection
id="learn-concepts"
@@ -240,23 +238,13 @@ function MemberView({ feed, onPlay }: { feed: MemberFeed; onPlay: (r: LearnResou
description="How this market actually works, taught side by side with the book you run. Members only."
>
<Tabs value={concept} onValueChange={setConcept} className="flex min-w-0 flex-col gap-4">
{/*
The primitive is stock shadcn, so it carries shadcn's tokens and a
fixed `h-9`. Both are wrong here and both are overridden rather
than fixed in the primitive, which other pages depend on: `bg-muted`
is a TEXT colour in PIG's palette and paints the strip as a pale
slab in dark mode, and a 36px row cannot hold a 44px touch target.
*/}
{/* `self-start` because the list is `inline-flex` inside a column
flex container, which stretches it to the full width regardless
of `w-auto`. */}
<TabsList className="h-auto w-full justify-start gap-1 overflow-x-auto rounded-xl border border-border bg-surface-2 p-1 text-muted sm:w-auto sm:self-start">
of `w-auto`. The palette, the 52px list and the 44px trigger all
live in the primitive now, so nothing is overridden here. */}
<TabsList className="w-full justify-start overflow-x-auto sm:w-auto sm:self-start">
{CONCEPT_TRACKS.map((track) => (
<TabsTrigger
key={track}
value={track}
className="min-h-[44px] shrink-0 rounded-lg px-4 data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm"
>
<TabsTrigger key={track} value={track} className="shrink-0 px-4">
{LEARN_TRACK_LABELS[track]}
</TabsTrigger>
))}
@@ -373,13 +361,13 @@ function CodeHolderView({
<AnonFrame>
<section aria-labelledby="learn-platform-public" className="flex min-w-0 flex-col gap-5">
<div className="flex min-w-0 flex-col gap-3">
<p className="flex min-w-0 items-center gap-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
<Label className="flex min-w-0 items-center gap-1.5">
<MonitorPlay className="size-3.5 shrink-0" aria-hidden />
Prime Intellect Growth
</p>
</Label>
<h1
id="learn-platform-public"
className="min-w-0 text-2xl font-semibold tracking-tight sm:text-3xl"
className="min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl"
>
Platform walkthroughs
</h1>
@@ -435,7 +423,7 @@ function AnonFrame({ children }: { children: ReactNode }) {
return (
<div className="app-canvas flex min-h-dvh min-w-0 flex-col">
<PublicHeader current="learn" />
<main className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-10 px-4 pb-14 pt-2 sm:px-6 sm:pb-20">
<main className="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-8 px-4 pb-14 pt-6 sm:px-6 sm:pb-20">
{children}
</main>
<footer className="mx-auto w-full min-w-0 max-w-5xl px-4 pb-8 text-xs text-muted sm:px-6">
@@ -465,11 +453,11 @@ function TrackSection({
return (
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
<div className="min-w-0">
<p className="flex min-w-0 items-center gap-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-accent-fg">
<Label className="flex min-w-0 items-center gap-1.5">
{icon}
{kicker}
</p>
<h2 id={id} className="mt-1 text-xl font-semibold tracking-tight">
</Label>
<h2 id={id} className="mt-1 text-base font-semibold leading-tight">
{title}
</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{description}</p>
+65 -44
View File
@@ -19,7 +19,8 @@ import { AlertTriangle, ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { compactNumber, get, money, percent, unitPrice } from '@/lib/api';
import { PiggyAskButton } from '@/components/PiggyChat';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat, cn } from '@/components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, Label, Skeleton, Stat, cn } from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
@@ -92,14 +93,25 @@ export function Margin() {
setFocusedId((current) => (current === commitmentId ? null : commitmentId));
if (isLoading) {
return <div className="flex flex-col gap-4"><Skeleton className="h-16" /><div className="grid grid-cols-2 gap-2 xl:grid-cols-4">{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}</div><Skeleton className="h-80" /></div>;
return (
<div className="space-y-6">
<Skeleton className="h-16" />
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-28" />)}
</div>
<Skeleton className="h-80" />
</div>
);
}
if (error || !data) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<EmptyState title="Could not load margin" description={error instanceof Error ? error.message : 'The margin ledger is unavailable.'} />
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
<CardContent className="pt-5">
<EmptyState
title="Could not load margin"
description={error instanceof Error ? error.message : 'The margin ledger is unavailable.'}
action={<Button variant="outline" onClick={() => void refetch()}>Try again</Button>}
/>
</CardContent>
</Card>
);
@@ -107,6 +119,7 @@ export function Margin() {
if (data.blocks.length === 0) {
return (
<EmptyState
size="page"
title="No capacity to report on"
description="Margin is computed from capacity commitments and the allocations against them. Both are recorded on the capacity book."
/*
@@ -135,33 +148,32 @@ export function Margin() {
const t = data.totals;
return (
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Revenue from what we sold, against the full cost of what we bought. Every
commitment is charged in full, so a block keeps paying for the hours nobody
has bought yet.
</p>
</div>
{/*
<div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<PageHeader
title="Margin"
description="Revenue from what we sold, against the full cost of what we bought. Every commitment is charged in full, so a block keeps paying for the hours nobody has bought yet."
/*
No `context` prop, deliberately. This button asks about whatever the
page has published the selected block, else /margin and pinning it
to the page here would make selecting a block change the dock and not
this button, which is the one the user just pressed.
*/}
<PiggyAskButton
label={focused ? 'Ask about this block' : 'Ask Piggy'}
prompt={
focused
? 'How much of this block is still unsold, what must the rest fetch to cover it, and how much term is left to sell into?'
: 'Which commitment is furthest from covering its cost, and what would the remaining hours have to fetch?'
}
/>
</header>
*/
ask={
<PiggyAskButton
label={focused ? 'Ask about this block' : 'Ask Piggy'}
prompt={
focused
? 'How much of this block is still unsold, what must the rest fetch to cover it, and how much term is left to sell into?'
: 'Which commitment is furthest from covering its cost, and what would the remaining hours have to fetch?'
}
/>
}
/>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
{/* One coloured figure: margin is the polarity of the book. Revenue and
cost are neither good nor bad on their own they are the two halves
this figure is the difference of. */}
<section className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<Stat label="Revenue" value={money(t.revenueCents)} hint={`${compactNumber(t.allocatedGpuHours)} GPU-hrs sold`} />
<Stat label="Cost" value={money(t.costCents)} hint={`${compactNumber(t.committedGpuHours)} GPU-hrs committed`} />
<Stat
@@ -231,15 +243,18 @@ export function Margin() {
) : null}
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 space-y-0">
<div>
{/* Stacked on a phone. Beside a four-line description the link was
centred against nothing, and the title it belongs to was two lines
above it. */}
<CardHeader className="gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="text-base">By commitment</CardTitle>
<p className="mt-1 text-xs text-muted">
Sold ratio describes contracted capacity sold, not workload utilization. Select a
<p className="mt-1 text-sm text-muted">
Sold ratio describes contracted capacity sold, not workload utilisation. Select a
commitment to point Piggy at it.
</p>
</div>
<Link to="/capacity" className="tap inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface-2">
<Link to="/capacity" className="tap -ml-3 inline-flex min-h-11 shrink-0 items-center gap-1 self-start rounded-lg px-3 text-sm font-medium text-accent-fg hover:bg-surface-2 sm:ml-0">
Capacity <ArrowRight className="size-4" aria-hidden />
</Link>
</CardHeader>
@@ -247,13 +262,16 @@ export function Margin() {
<div className="hidden scroll-x md:block">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
<th className="px-4 pb-2 font-medium sm:px-5">Commitment</th>
<th className="px-4 pb-2 text-right font-medium">Sold</th>
<th className="px-4 pb-2 text-right font-medium">Sellable</th>
<th className="px-4 pb-2 text-right font-medium">Sold ratio</th>
<th className="px-4 pb-2 text-right font-medium">Cost/hr</th>
<th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th>
{/* Column names are the product's one micro-label, so a table
head and a stat tile in the same viewport agree on what a
label looks like. */}
<tr className="border-b border-border text-left">
<Label as="th" className="px-4 pb-2 sm:px-5">Commitment</Label>
<Label as="th" className="px-4 pb-2 text-right">Sold</Label>
<Label as="th" className="px-4 pb-2 text-right">Sellable</Label>
<Label as="th" className="px-4 pb-2 text-right">Sold ratio</Label>
<Label as="th" className="px-4 pb-2 text-right">Cost/hr</Label>
<Label as="th" className="px-4 pb-2 text-right sm:px-5">Break even</Label>
</tr>
</thead>
<tbody>
@@ -307,10 +325,13 @@ export function Margin() {
<article
key={block.commitmentId}
className={cn(
'rounded-xl border border-border p-4',
// `border-brand`, not `border-accent`: `accent` is shadcn's
// subtle surface in this config, so a border in it disappears.
block.commitmentId === focusedId && 'border-brand bg-surface-2',
// The same inset row the uncovered blocks above use, rather
// than a bordered box: two lists of commitments on one page
// drawn two ways is the drift this pass exists to remove.
'rounded-lg bg-surface-2 p-3',
// `ring-brand`, not `ring-accent`: `accent` is shadcn's
// subtle surface in this config, so an outline in it vanishes.
block.commitmentId === focusedId && 'ring-1 ring-brand',
)}
>
<div className="flex items-start justify-between gap-3">
@@ -321,7 +342,7 @@ export function Margin() {
/>
<span className={['nums shrink-0 text-sm font-semibold', isUncovered(block) ? 'text-warning' : ''].join(' ')}>{percent(block.utilisation)} sold</span>
</div>
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dl className="mt-2 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted">Sold capacity</dt><dd className="nums text-right">{compactNumber(block.soldGpuHours)} hrs</dd>
<dt className="text-muted">Sellable capacity</dt><dd className="nums text-right">{compactNumber(block.availableGpuHours)} hrs</dd>
<dt className="text-muted">Our cost</dt><dd className="nums text-right">{unitPrice(block.costPerGpuHourCents)}/GPU-hr</dd>
+73 -27
View File
@@ -36,6 +36,7 @@ import {
Stat,
cn,
} from '@/components/ui';
import { PageHeader } from '@/components/ui/page-header';
import { withoutDemoPrefix } from '@/lib/utils';
import { CommitmentSheet } from '@/components/RecordSheets';
import { usePageTitle } from '@/lib/title';
@@ -128,11 +129,18 @@ export function Overview() {
});
if (isLoading) {
// Shaped like the page it stands in for — a one-column skeleton that
// resolves into a two-column figure row reads as a layout jump on the
// screen everyone opens first.
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
<div className="space-y-6">
<Skeleton className="h-16" />
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
<Skeleton className="h-48" />
</div>
);
}
@@ -140,12 +148,14 @@ export function Overview() {
if (error || !data) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 pt-6">
<CardContent className="pt-5">
<EmptyState
title="Could not load the overview"
description={error instanceof Error ? error.message : 'Unknown error.'}
action={
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
}
/>
<Button variant="outline" onClick={() => void refetch()}>Try again</Button>
</CardContent>
</Card>
);
@@ -166,19 +176,28 @@ export function Overview() {
const canRecordCommitment = can(me, 'commitment:write', 'supply');
return (
<div className="space-y-5 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
{greeting()}, {firstName}
</h1>
<p className="mt-1 text-sm text-muted">
{data.blocks === 0
<div className="space-y-6 pb-[calc(5.5rem+var(--safe-bottom))] md:pb-0">
<PageHeader
title={`${greeting()}, ${firstName}`}
description={
data.blocks === 0
? 'No capacity commitments yet — margin appears once you record what you have bought.'
: `${data.blocks} capacity commitment${data.blocks === 1 ? '' : 's'} on the book.`}
</p>
</header>
: `${data.blocks} capacity commitment${data.blocks === 1 ? '' : 's'} on the book.`
}
/>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
{/*
Every figure here is the headline of a page, so every figure is a way
in: the number this product exists to surface was previously dead text
beside a nav item that led to the same place.
One coloured figure, and it is margin. Sold ratio and idle capacity are
the same fact twice hours bought and not sold and the card directly
below states it once, in warning, with the blocks named and a way to act
on each. Three amber figures above a warning card is a row where nothing
is emphasised.
*/}
<section className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<Stat
label="Gross margin"
value={money(m.grossMarginCents)}
@@ -190,20 +209,22 @@ export function Overview() {
: `${percent(m.grossMarginPct, 1)} of ${money(m.revenueCents)} revenue`
}
tone={marginTone}
href="/margin"
className={KPI_LINK_RING}
/>
<Stat
label="Sold ratio"
value={percent(m.utilisation, 1)}
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
// Nothing bought cannot be under-sold; warning on 0% of 0 hours is an
// alarm about a book that does not exist yet.
tone={m.committedGpuHours > 0 && m.utilisation < 0.6 ? 'warning' : 'default'}
href="/capacity"
className={KPI_LINK_RING}
/>
<Stat
label="Idle capacity"
value={`${compactNumber(m.idleGpuHours)} hrs`}
hint="Bought and unsold"
tone={m.idleGpuHours > 0 ? 'warning' : 'default'}
href="/capacity"
className={KPI_LINK_RING}
/>
{/*
The value of what is open rather than a count of it a count is the
@@ -216,6 +237,8 @@ export function Overview() {
label="Open pipeline"
value={money(data.openDemandAcvCents)}
hint={`${data.openDemandDeals} demand · ${data.openSupplyDeals} supply open`}
href="/demand"
className={KPI_LINK_RING}
/>
</section>
@@ -280,7 +303,7 @@ export function Overview() {
{/* `items-start` so a short book does not stretch to the height of a busy
activity feed and open a hole under its last row. */}
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className="grid items-start gap-3 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">The book</CardTitle>
@@ -318,9 +341,13 @@ export function Overview() {
</CardHeader>
<CardContent>
{data.recentActivity.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">Nothing logged yet.</p>
<EmptyState
size="inline"
title="Nothing logged yet"
description="Calls, notes and emails appear here as they are recorded against an account."
/>
) : (
<ul className="space-y-2.5">
<ul className="space-y-2">
{data.recentActivity.slice(0, 6).map((activity) => (
<li key={activity.id} className="flex items-start gap-2 text-sm">
<Badge tone="neutral" className="mt-0.5 shrink-0">
@@ -504,6 +531,20 @@ function ComplianceRow({ item }: { item: ComplianceItem }) {
);
}
/**
* The focus ring, restated on a linked stat tile.
*
* `:focus-visible` paints the ring with a box-shadow from Tailwind's base
* layer, and `.card` sets its own `box-shadow` from the components layer,
* which is a later layer and therefore wins: a focusable card is a tab stop
* with no visible focus anywhere in the product. These are utilities, so they
* land in the last layer and paint. **Delete this the moment `Stat` declares
* the ring itself** the fix belongs in the primitive, and has been filed
* there.
*/
const KPI_LINK_RING =
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg';
const COMPLIANCE_ROWS = 3;
const DAY_MS = 86_400_000;
@@ -526,12 +567,17 @@ function Row({
return (
<div className="flex items-baseline justify-between gap-3">
<span className={emphasis ? 'font-medium' : 'text-muted'}>{label}</span>
{/*
Emphasis is weight, not size. The three numeric steps belong to `Stat`;
a 16px figure here would be a fourth, and it would sit two rows under
the 30px one that says the same thing.
*/}
<span
className={[
className={cn(
'nums tabular-nums',
emphasis ? 'text-base font-semibold' : '',
emphasis && 'font-semibold',
tone === 'positive' ? 'text-positive' : tone === 'danger' ? 'text-danger' : '',
].join(' ')}
)}
>
{value}
</span>
+35 -35
View File
@@ -15,7 +15,9 @@ import type { PermissionGrant } from '@pig/core';
import { Pencil, Plus, RefreshCw, Search } from 'lucide-react';
import { get, money, relativeTime, unitPrice } from '@/lib/api';
import { PiggyAskButton } from '@/components/PiggyChat';
import { Badge, Button, Card, EmptyState, Input, Skeleton, cn } from '@/components/ui';
import { Badge, Button, Card, EmptyState, Input, Label, Skeleton, Stat, cn } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { can } from '@/lib/permissions';
@@ -108,20 +110,20 @@ function PipelineBoard<T extends { id: string; name: string; stage: string; upda
: 'Are we lining up more capacity than the demand side can absorb?')}
/>;
if (boardQuery.isLoading) return <div className="space-y-5">{header}<Skeleton className="h-96" /></div>;
if (boardQuery.isError) return <div className="space-y-5">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-5">{header}<Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
if (boardQuery.isLoading) return <div className="space-y-6">{header}<Skeleton className="h-96" /></div>;
if (boardQuery.isError) return <div className="space-y-6">{header}<Card><EmptyState title={`${title} pipeline unavailable`} description={boardQuery.error.message} action={<Button variant="outline" onClick={() => void boardQuery.refetch()}><RefreshCw aria-hidden />Try again</Button>} /></Card>{sheetNode}</div>;
if (!boardQuery.data || boardQuery.data.deals.length === 0) return <div className="space-y-6">{header}<Card><EmptyState title={`No ${title.toLowerCase()} deals yet`} description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel." action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>} /></Card>{sheetNode}</div>;
const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id));
return <div className="space-y-5">
return <div className="space-y-6">
{header}
<section className="grid gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-center">
<label className="relative min-w-0"><span className="sr-only">Search {title.toLowerCase()} pipeline</span><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden /><Input className="h-11 pl-9" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search deal, account or product" /></label>
<PipelineStat label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><PipelineStat label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
<Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={query ? 'Matches' : 'Deals'} value={String(filteredDeals.length)} /><Stat size="sm" surface="bare" className="min-w-[7rem] rounded-md bg-surface px-3 py-2" label={metricLabel} value={metricValue(filteredDeals.map((row) => row.deal))} />
</section>
<div className="lg:hidden"><label className="block text-xs font-medium text-muted" htmlFor={`${team}-stage`}>Focus stage</label><select id={`${team}-stage`} className="mt-1 h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-[11px] text-muted">{index + 1}</span><h2 id={`${team}-${stage}`} className="truncate text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
<div className="lg:hidden"><FormField label="Focus stage"><select id={`${team}-stage`} className="h-11 w-full rounded-lg border border-border bg-surface px-3 text-sm font-medium text-fg" value={currentStage} onChange={(event) => setActiveStage(event.target.value)}>{stages.map((stage) => <option key={stage} value={stage}>{STAGE_LABELS[stage] ?? stage} · {byStage.get(stage)?.length ?? 0}</option>)}</select></FormField><div className="mt-3 space-y-2">{(byStage.get(currentStage) ?? []).map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{(byStage.get(currentStage) ?? []).length === 0 ? <StageEmpty stage={currentStage} filtered={Boolean(deferredQuery)} /> : null}</div></div>
<div className="hidden lg:block"><div className="mb-3 flex items-center justify-between gap-3"><p className="text-sm text-muted"><strong className="text-fg">{activeStageCount}</strong> of {stages.length} stages have {deferredQuery ? 'matching' : 'active'} work</p><p className="text-xs text-muted">Stage order runs left to right, then down.</p></div><div className="grid items-start gap-3 lg:grid-cols-3 2xl:grid-cols-4">{stages.map((stage, index) => { const rows = byStage.get(stage) ?? []; return <section key={stage} className="min-w-0 rounded-xl border border-border bg-surface-2/45 p-3" aria-labelledby={`${team}-${stage}`}><div className="mb-3 flex min-h-8 items-center justify-between gap-2"><div className="flex min-w-0 items-center gap-2"><span className="nums flex size-6 shrink-0 items-center justify-center rounded-full bg-surface text-xs text-muted">{index + 1}</span><Label as="h2" id={`${team}-${stage}`} className="truncate">{STAGE_LABELS[stage] ?? stage}</Label></div><Badge tone={rows.length ? 'accent' : 'neutral'}>{rows.length}</Badge></div><div className="space-y-2">{rows.map((row) => <DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} focused={row.deal.id === focusedId} onFocus={() => toggleFocus(row.deal.id)} onEdit={() => setSheet({ open: true, record: row.deal })} />)}{rows.length === 0 ? <StageEmpty stage={stage} filtered={Boolean(deferredQuery)} compact /> : null}</div></section>; })}</div></div>
{sheetNode}
</div>;
}
@@ -136,45 +138,43 @@ function PipelineBoard<T extends { id: string; name: string; stage: string; upda
* matters more, reading the deal or asking about it.
*/
function DealCard<T extends { id: string; name: string; updatedAt: string }>({ row, renderCard, writable, focused, onEdit, onFocus }: { row: { deal: T; accountName: string | null }; renderCard: (deal: T, accountName: string | null) => React.ReactNode; writable: boolean; focused: boolean; onEdit(): void; onFocus(): void }) {
// `ring-brand`, not `ring-accent`: in this Tailwind config `accent` is
// shadcn's subtle surface, so a ring drawn in it is invisible against the
// card. The brand is the monochrome that inverts with the theme.
return <article className={cn('card relative min-w-0 p-3 pr-12 shadow-sm', focused && 'ring-2 ring-brand')}>
// The focus ring is the brand across the product, so a selected card is
// marked the same way rather than in a colour that means something else.
return <article className={cn('card relative min-w-0 p-3 pr-12', focused && 'ring-2 ring-brand')}>
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title={writable ? 'Edit deal' : 'Deal write access required'}><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
<button
type="button"
aria-pressed={focused}
onClick={onFocus}
title={focused ? 'Stop pointing Piggy at this deal' : 'Point Piggy at this deal'}
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-2"
className="tap -mx-2 -mt-1 block w-[calc(100%+1rem)] rounded-lg px-2 py-1 text-left transition-colors duration-1 ease-enter hover:bg-surface-2"
>
<span className="block truncate font-medium">{row.deal.name}</span>
<span className="block truncate text-xs text-muted">{row.accountName ?? 'No account'}</span>
<span className="sr-only">{focused ? 'Piggy is looking at this deal' : 'Point Piggy at this deal'}</span>
</button>
{renderCard(row.deal, row.accountName)}
<p className="mt-2 text-[11px] text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
<p className="mt-2 text-xs text-muted">Updated {relativeTime(row.deal.updatedAt)}</p>
</article>;
}
function PipelineStat({ label, value }: { label: string; value: string }) { return <div className="min-w-[7rem] rounded-lg bg-surface px-3 py-2"><p className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</p><p className="nums mt-0.5 truncate text-sm font-semibold">{value}</p></div>; }
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) { return <p className={compact ? 'rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted' : 'py-10 text-center text-sm text-muted'}>{filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}</p>; }
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
return <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></div><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
{/*
Reversed above `sm` rather than reordered: stacked on a phone the primary
action has to come first, and on a wide header the same button belongs at
the right edge where it has always been.
*/}
<div className="flex flex-col gap-2 sm:shrink-0 sm:flex-row-reverse">
<Button className="min-h-11" variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>
{/*
No `context` prop on purpose: this asks about whatever the board has
published, which is the focused deal when there is one. Passing the page
here would pin it to the board and quietly ignore the card the user just
put in focus.
*/}
<PiggyAskButton label={askLabel} prompt={askPrompt} />
</div>
</header>;
function StageEmpty({ stage, filtered, compact = false }: { stage: string; filtered: boolean; compact?: boolean }) {
return <EmptyState
size={compact ? 'inline' : 'panel'}
className={compact ? 'rounded-lg border border-dashed border-border' : undefined}
title={filtered ? 'No matching deals' : `Nothing in ${STAGE_LABELS[stage] ?? stage}`}
/>;
}
function Header({ title, orientation, subtitle, writable, onCreate, askLabel, askPrompt }: { title: string; orientation: string; subtitle: string; writable: boolean; onCreate(): void; askLabel: string; askPrompt: string }) {
return <PageHeader
title={<span className="flex flex-wrap items-center gap-2">{title}<Badge tone={title === 'Supply' ? 'info' : 'neutral'}>{orientation}</Badge></span>}
description={subtitle}
actions={<Button variant="primary" disabled={!writable} onClick={onCreate} title={writable ? undefined : 'Deal write access required'}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
/*
No `context` prop on purpose: this asks about whatever the board has
published, which is the focused deal when there is one. Passing the page
here would pin it to the board and quietly ignore the card the user just
put in focus.
*/
ask={<PiggyAskButton label={askLabel} prompt={askPrompt} />}
/>;
}
+52 -55
View File
@@ -10,7 +10,20 @@ import { Check, Copy, KeyRound, LogOut, Monitor, Moon, Sun, Terminal } from 'luc
import { api, get, getSupabase, patch, post, relativeTime, shortDate } from '@/lib/api';
import { useTheme } from '@/lib/theme';
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Label,
Section,
} from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { PageHeader } from '@/components/ui/page-header';
import {
Dialog,
DialogContent,
@@ -27,7 +40,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useState, type ReactNode } from 'react';
import { useState } from 'react';
import { usePageTitle } from '@/lib/title';
import { AdminSettings } from '@/components/AdminSettings';
import { toast } from 'sonner';
@@ -47,25 +60,21 @@ export function Settings() {
return (
<div className="space-y-6 pb-4">
<header className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Personal preferences, agent credentials, and server-managed integration readiness.
</p>
</div>
{me?.isPlatformAdmin ? <Badge tone="warning">Platform admin view</Badge> : null}
</header>
<PageHeader
title="Settings"
description="Personal preferences, agent credentials, and server-managed integration readiness."
actions={me?.isPlatformAdmin ? <Badge tone="warning">Platform admin view</Badge> : null}
/>
<Section title="Your account">
<div className="grid gap-6 xl:grid-cols-2">
<Section title="Your account" tone="micro" level={2}>
<div className="grid items-start gap-3 xl:grid-cols-2">
<Appearance />
{/*
Profile and Session share a column so the page keeps two even
columns instead of stranding a short card on a row of its own, and
because signing out belongs with the identity it ends.
*/}
<div className="flex min-w-0 flex-col gap-6">
<div className="flex min-w-0 flex-col gap-3">
<Profile me={me} />
<SessionCard />
</div>
@@ -79,11 +88,11 @@ export function Settings() {
key on a phone, directly under it. It used to say "create one below"
with nothing below, which is the dead end this section closes.
*/}
<Section title="Agent access">
<Section title="Agent access" tone="micro" level={2}>
{/* `items-start` because the instruction card is a third of the height
of the credential list, and stretching it leaves a card that is
mostly empty space. */}
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]">
<div className="grid items-start gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]">
<ConnectAgent />
{me ? <ApiKeys me={me} /> : null}
</div>
@@ -92,15 +101,6 @@ export function Settings() {
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
{children}
</section>
);
}
function Appearance() {
const { mode, accent, resolved, setMode, setAccent, accents } = useTheme();
@@ -114,7 +114,7 @@ function Appearance() {
</CardHeader>
<CardContent className="space-y-5">
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Theme</p>
<Label className="mb-2">Theme</Label>
<div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">
{THEME_MODES.map((value) => {
const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor;
@@ -125,7 +125,7 @@ function Appearance() {
onClick={() => setMode(value as ThemeMode)}
aria-pressed={mode === value}
className={[
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors sm:flex-none',
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors duration-1 ease-enter sm:flex-none',
mode === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
@@ -138,7 +138,7 @@ function Appearance() {
</div>
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Accent</p>
<Label className="mb-2">Accent</Label>
<div className="flex flex-wrap gap-2">
{accents.map((option) => {
const selected = option.key === accent;
@@ -152,7 +152,7 @@ function Appearance() {
aria-label={option.label}
title={option.label}
className={[
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors duration-1 ease-enter',
selected ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2',
].join(' ')}
>
@@ -221,15 +221,15 @@ function Profile({ me }: { me: Me | undefined }) {
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">Name</dt>
<Label as="dt">Name</Label>
<dd>{me.name}</dd>
</div>
<div>
<dt className="text-xs text-muted">Email</dt>
<Label as="dt">Email</Label>
<dd className="break-all">{me.email}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">Teams</dt>
<Label as="dt">Teams</Label>
<dd className="mt-1 flex flex-wrap gap-1.5">
{me.teams.length === 0 ? (
<span className="text-muted">No team membership</span>
@@ -252,12 +252,10 @@ function Profile({ me }: { me: Me | undefined }) {
save.mutate();
}}
>
<label className="block" htmlFor="profile-display-name">
<span className="mb-1 block text-xs font-medium text-muted">Display name</span>
<FormField label="Display name">
<Input id="profile-display-name" name="displayName" value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
</label>
<label className="block" htmlFor="profile-title">
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
</FormField>
<FormField label="Title">
<Input
id="profile-title"
name="title"
@@ -265,7 +263,7 @@ function Profile({ me }: { me: Me | undefined }) {
onChange={(e) => setTitle(e.target.value)}
placeholder="Head of Compute"
/>
</label>
</FormField>
<div className="sm:col-span-2">
<Button
type="submit"
@@ -439,8 +437,10 @@ function ApiKeys({ me }: { me: Me }) {
create.mutate();
}}
>
<label className="flex flex-col gap-1.5" htmlFor="api-key-name">
<span className="text-sm font-medium">Name</span>
<FormField
label="Name"
hint="The name is all you will have to go on when deciding which key to revoke."
>
<Input
id="api-key-name"
value={name}
@@ -448,13 +448,9 @@ function ApiKeys({ me }: { me: Me }) {
placeholder="Claude Code on my laptop"
maxLength={120}
/>
<span className="text-xs text-muted">
The name is all you will have to go on when deciding which key to revoke.
</span>
</label>
</FormField>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Access</span>
<FormField label="Access">
<Select value={scope} onValueChange={(value) => setScope(value as 'read' | 'write')}>
<SelectTrigger>
<SelectValue />
@@ -466,16 +462,15 @@ function ApiKeys({ me }: { me: Me }) {
</SelectGroup>
</SelectContent>
</Select>
</label>
<label className="flex flex-col gap-1.5" htmlFor="api-key-expiry">
<span className="text-sm font-medium">Expires, optional</span>
</FormField>
<FormField label="Expires, optional">
<Input
id="api-key-expiry"
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
</FormField>
</div>
{create.error ? (
<p role="alert" className="text-sm text-danger">
@@ -488,14 +483,16 @@ function ApiKeys({ me }: { me: Me }) {
</form>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Your keys</p>
<Label>Your keys</Label>
{isLoading ? (
<p className="py-6 text-center text-sm text-muted">Loading keys</p>
) : data.length === 0 ? (
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted">
No keys yet. Create one above, then paste it into the snippet under Connect
your agent.
</p>
<EmptyState
size="inline"
className="rounded-xl border border-dashed border-border"
title="No keys yet"
description="Create one above, then paste it into the snippet under “Connect your agent”."
/>
) : (
data.map((key) => (
<ApiKeyRow
+9 -12
View File
@@ -14,7 +14,8 @@
import { useState } from 'react';
import { Mail } from 'lucide-react';
import { getSupabase, type PublicConfig } from '@/lib/api';
import { Button, Input } from '@/components/ui';
import { Button, Input, Label } from '@/components/ui';
import { FormField } from '@/components/ui/form-field';
import { AuthShell } from '@/components/AuthShell';
import { usePageTitle } from '@/lib/title';
@@ -99,13 +100,11 @@ export function SignIn({
) : (
<>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
Private workspace
</p>
<Label>Private workspace</Label>
<h1 className="text-3xl font-medium tracking-[-0.035em] sm:text-4xl">
Sign in to PIG
</h1>
<p className="max-w-md text-sm leading-6 text-muted sm:text-[0.9375rem]">
<p className="max-w-md text-sm leading-6 text-muted">
Authentication stays with the deployment's identity provider. PIG never stores your
password.
</p>
@@ -132,7 +131,7 @@ export function SignIn({
setMessage('');
}}
className={[
'tap relative flex flex-1 items-center justify-center px-3 text-sm font-medium transition-colors',
'tap relative flex flex-1 items-center justify-center px-3 text-sm font-medium transition-colors duration-1 ease-enter',
method === option.key
? 'text-fg after:absolute after:inset-x-3 after:-bottom-px after:h-px after:bg-primary'
: 'text-muted hover:text-fg',
@@ -144,8 +143,7 @@ export function SignIn({
</div>
<form onSubmit={submit} className="mt-5 flex flex-col gap-4">
<label className="block" htmlFor="sign-in-email">
<span className="mb-1 block text-sm font-medium">Email</span>
<FormField label="Email">
<Input
id="sign-in-email"
name="email"
@@ -159,11 +157,10 @@ export function SignIn({
autoCorrect="off"
spellCheck={false}
/>
</label>
</FormField>
{method === 'password' ? (
<label className="block" htmlFor="sign-in-password">
<span className="mb-1 block text-sm font-medium">Password</span>
<FormField label="Password">
<Input
id="sign-in-password"
name="password"
@@ -175,7 +172,7 @@ export function SignIn({
// offer to fill, and iOS to offer a saved credential.
autoComplete="current-password"
/>
</label>
</FormField>
) : null}
<Button