Merge gitea/main into the Motion branch
Motion was written against a base five commits behind main, so the integration is the interesting part of this commit: - The migration is renumbered 0014 -> 0015. Main shipped 0014_piggy_conversations, and two migrations sharing an index is a journal that applies one of them. - The seed-idempotency gate keeps main's all-tables diff rather than the motion_templates counter this branch added; the general check subsumes the specific one. - Nav gains a Motion group alongside main's new Workspace group, and Piggy keeps the mark main gave it. - Stat keeps main's container-scaled figure, which already carries the min-w-0 this branch added for the same reason. - Piggy's page labels keep main's refusal wording for the four pages with no tool of their own, and gain the three Motion routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
* they had already drifted — the tab bar's active pill and the sidebar's
|
||||
* active row used different tokens.
|
||||
*/
|
||||
import { Fragment } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||||
import { useIdentity } from '@/lib/identity';
|
||||
import { NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { Button } from './ui';
|
||||
import { Button, Label } from './ui';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -42,9 +43,9 @@ export function AppSidebar() {
|
||||
device where neither is discoverable. */}
|
||||
{isMobile ? (
|
||||
<div className="flex items-center justify-between pl-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
Navigate
|
||||
</span>
|
||||
{/* Same spec as every other group heading in the rail — this one
|
||||
had its own copy of the retired 10px/600/0.16em values. */}
|
||||
<Label>Navigate</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -66,17 +67,28 @@ export function AppSidebar() {
|
||||
// A heading over nothing is worse than a missing section: it reads
|
||||
// as a section that failed to load rather than one you cannot use.
|
||||
if (!groupItems.length) return null;
|
||||
const heading = NAV_GROUP_HEADING[group];
|
||||
return (
|
||||
<SidebarGroup key={group}>
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{groupItems.map((item) => (
|
||||
<NavItemRow key={item.to} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Fragment key={group}>
|
||||
<SidebarGroup>
|
||||
{heading ? <SidebarGroupLabel>{heading}</SidebarGroupLabel> : null}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{groupItems.map((item) => (
|
||||
<NavItemRow key={item.to} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{/*
|
||||
An unlabelled group has no heading to separate it from the next
|
||||
one, so it gets a rule instead. This is also the only separation
|
||||
that survives collapse: at icon width every heading is pulled up
|
||||
and faded out, so without the rule the front door would be just
|
||||
one more glyph in an undifferentiated stack of them.
|
||||
*/}
|
||||
{heading === null ? <SidebarSeparator /> : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Bot, CircleStop, Database, Loader2, MessageCircleMore, Send, Sparkles, XCircle } from 'lucide-react';
|
||||
import { CircleStop, Database, Loader2, Send, XCircle } from 'lucide-react';
|
||||
import type { PiggyApprovalDecision } from '@pig/core';
|
||||
import { get } from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { PIGGY_ASK_LABEL, piggyCopy, piggyLine } from '@/lib/piggy-copy';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import {
|
||||
PIGGY_MESSAGE_MAX_LENGTH,
|
||||
@@ -16,13 +25,17 @@ import {
|
||||
type PiggyStatus,
|
||||
type TranscriptMessage,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } from '@/lib/piggy-suggestions';
|
||||
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps } from '@/lib/piggy-suggestions';
|
||||
import { PiggyApprovalCard } from './piggy/approval-card';
|
||||
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
|
||||
import { PiggyMessageActions } from './piggy/message-actions';
|
||||
import { PiggyReasoning } from './piggy/reasoning';
|
||||
import { PiggyResponse } from './piggy/response';
|
||||
import { PiggyToolStep } from './piggy/tool';
|
||||
import { Badge, Button, EmptyState, cn } from './ui';
|
||||
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './piggy/workspace/controls';
|
||||
import { PiggyWorkspaceStarters } from './piggy/workspace/starters';
|
||||
import { PiggyMark } from './PiggyMark';
|
||||
import { Button, Badge, EmptyState, cn } from './ui';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -47,10 +60,78 @@ import { Textarea } from './ui/textarea';
|
||||
*/
|
||||
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;
|
||||
|
||||
/**
|
||||
* How narrow a Piggy surface has to be before it takes the tight treatment.
|
||||
*
|
||||
* 28rem, and measured on the panel rather than the window, because the four
|
||||
* surfaces this panel is drawn in disagree with the viewport: the dock is 22rem
|
||||
* inside a 1440px window, the phone drawer is 393px inside a 393px one. It used
|
||||
* to be a prop that only the dock passed, so the narrowest surface in the
|
||||
* product — the drawer a phone gets — was drawing the roomy layout.
|
||||
*/
|
||||
const COMPACT_WIDTH = 448;
|
||||
|
||||
/** `max-h-36` on the field, in pixels, for the auto-grow below to cap at. */
|
||||
const COMPOSER_MAX_HEIGHT = 144;
|
||||
|
||||
// ------------------------------------------------------------- one front door
|
||||
|
||||
/**
|
||||
* A question a page asked on the user's behalf.
|
||||
*
|
||||
* `id` is new on every press so that pressing the same button twice puts the
|
||||
* question back in the composer, rather than the second press doing nothing
|
||||
* because the request happened to be identical.
|
||||
*/
|
||||
export interface PiggyAsk {
|
||||
id: number;
|
||||
context?: PiggyChatContext;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
let nextAskId = 1;
|
||||
const askListeners = new Set<(ask: PiggyAsk) => void>();
|
||||
|
||||
/**
|
||||
* Ask Piggy something from anywhere, on whichever surface Piggy is showing.
|
||||
*
|
||||
* PIG used to have two front doors on the same row of pixels: the pig in the
|
||||
* header opened the dock, and a speech bubble labelled "Ask Piggy" beside it
|
||||
* opened a *different* Piggy in a sheet on top of it. Two surfaces, two
|
||||
* transcripts, one agent — and no sentence that told them apart.
|
||||
*
|
||||
* So a page button no longer opens anything. It publishes the question, and
|
||||
* the one Piggy this viewport has room for takes it: the docked column above
|
||||
* 1280px, the sheet or drawer below it. The question is seeded into the
|
||||
* composer and never sent, because the user asked for a starting point, not
|
||||
* for a turn to be spent on their behalf.
|
||||
*/
|
||||
export function requestPiggyAsk(request: Omit<PiggyAsk, 'id'>): void {
|
||||
const ask: PiggyAsk = { ...request, id: nextAskId++ };
|
||||
for (const listener of askListeners) listener(ask);
|
||||
}
|
||||
|
||||
/** Subscribe to those requests for as long as this component is mounted. */
|
||||
export function usePiggyAskRequests(handler: (ask: PiggyAsk) => void): void {
|
||||
const latest = useRef(handler);
|
||||
// Written in a layout effect rather than during render: a handler captured
|
||||
// during a render React later discards would answer with stale state.
|
||||
useLayoutEffect(() => {
|
||||
latest.current = handler;
|
||||
});
|
||||
useEffect(() => {
|
||||
const listener = (ask: PiggyAsk) => latest.current(ask);
|
||||
askListeners.add(listener);
|
||||
return () => {
|
||||
askListeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function PiggyAskButton({
|
||||
context,
|
||||
prompt,
|
||||
label = 'Ask Piggy',
|
||||
label = PIGGY_ASK_LABEL,
|
||||
variant = 'outline',
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
@@ -58,7 +139,6 @@ export function PiggyAskButton({
|
||||
label?: string;
|
||||
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = usePiggyStatus();
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
// An explicit prop always wins. Every existing call site passes the record
|
||||
@@ -66,63 +146,37 @@ export function PiggyAskButton({
|
||||
// that must never displace it.
|
||||
const ambient = usePiggyCurrentContext();
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
disabled={Boolean(unavailable)}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MessageCircleMore aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<ResponsivePiggyChat
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
context={context ?? ambient}
|
||||
initialPrompt={prompt}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
disabled={Boolean(unavailable)}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
|
||||
onClick={() => requestPiggyAsk({ context: context ?? ambient, prompt })}
|
||||
>
|
||||
<PiggyMark className="size-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The height the workspace panel and its placeholder both take.
|
||||
* Piggy is unavailable, said the same way wherever it is discovered.
|
||||
*
|
||||
* Named once because the two must agree: a placeholder of a different height
|
||||
* makes the page jump the moment the status query answers. It is sized to land
|
||||
* just inside the page rather than just outside it — the panel scrolls, so a
|
||||
* page scrolling behind it means following an answer moves two things at once
|
||||
* and the composer drifts under the fold. Below `lg` the subtraction is larger:
|
||||
* the phone layout stacks the page header above and the tab bar below.
|
||||
*
|
||||
* The floor yields to the viewport rather than being a flat 32rem, because a
|
||||
* flat one is taller than a phone held sideways: at 852x393 the panel was 512px
|
||||
* inside a 393px window, which put the composer 230px below the fold on a page
|
||||
* whose only control is the composer. `min()` keeps the comfortable floor
|
||||
* everywhere it fits and stops claiming space that does not exist.
|
||||
* The relay answers 503 when the runtime is off, so every surface that draws a
|
||||
* composer has to ask `usePiggyStatus` first; this is what they draw instead.
|
||||
*/
|
||||
const WORKSPACE_HEIGHT =
|
||||
'h-[calc(100dvh-19rem)] min-h-[min(32rem,calc(100dvh-11rem))] lg:h-[calc(100dvh-13rem)]';
|
||||
|
||||
export function PiggyChatWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
if (status.isLoading) return <div className={cn(WORKSPACE_HEIGHT, 'animate-pulse rounded-xl bg-surface-2')} />;
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Bot />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status.data?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <PiggyChatPanel className={cn(WORKSPACE_HEIGHT, 'rounded-xl border border-border bg-surface')} />;
|
||||
export function PiggyUnavailable({ status }: { status: PiggyStatus | undefined }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<PiggyMark className="size-6" />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status?.enabled
|
||||
? 'This credential does not have read access.'
|
||||
: 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsivePiggyChat({
|
||||
@@ -130,11 +184,14 @@ export function ResponsivePiggyChat({
|
||||
onOpenChange,
|
||||
context,
|
||||
initialPrompt,
|
||||
seed,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
/** A later question from a page's Ask Piggy button. See `PiggyChatPanel`. */
|
||||
seed?: PiggySeed;
|
||||
}) {
|
||||
// The same breakpoint the shell switches navigation at. It used to be `md`,
|
||||
// which meant a 900px tablet got the desktop side sheet sliding in behind
|
||||
@@ -143,17 +200,31 @@ export function ResponsivePiggyChat({
|
||||
// Held here, one level above the overlay, because both the Sheet and the
|
||||
// Drawer unmount their children when they close. With the thread inside,
|
||||
// dismissing the overlay for two seconds to look at the record underneath
|
||||
// destroyed the conversation, the draft and any answer still streaming.
|
||||
const conversation = usePiggyConversation({ context, initialPrompt });
|
||||
// destroyed the conversation, the draft and any answer still streaming — and
|
||||
// with the controls inside, the mode went with it.
|
||||
const { conversation, controls } = usePiggyChatSession({ context, initialPrompt });
|
||||
const subtitle = context
|
||||
? `Working from ${contextLabel(context)}`
|
||||
: 'Working from your PIG workspace';
|
||||
if (desktop) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-border px-5 py-4 pt-[max(1rem,var(--safe-top))]">
|
||||
<SheetTitle>Ask Piggy</SheetTitle>
|
||||
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
|
||||
{/* `band` rather than a hand-rolled top strip: the padding, the rule
|
||||
and the safe-area inset are the overlay primitive's job, and this
|
||||
file was one of four keeping its own copy of them. */}
|
||||
<SheetHeader band>
|
||||
<SheetTitle>{PIGGY_ASK_LABEL}</SheetTitle>
|
||||
<SheetDescription>{subtitle}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<PiggyChatPanel conversation={conversation} context={context} autoFocusComposer className="min-h-0 flex-1" />
|
||||
<PiggyChatPanel
|
||||
conversation={conversation}
|
||||
controls={controls}
|
||||
context={context}
|
||||
seed={seed}
|
||||
autoFocusComposer
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
@@ -161,60 +232,119 @@ export function ResponsivePiggyChat({
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[92dvh]">
|
||||
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
|
||||
<DrawerTitle>Ask Piggy</DrawerTitle>
|
||||
<DrawerDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</DrawerDescription>
|
||||
<DrawerHeader className="border-b border-border px-5 py-4 text-left">
|
||||
<DrawerTitle>{PIGGY_ASK_LABEL}</DrawerTitle>
|
||||
<DrawerDescription>{subtitle}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{/* No autofocus on the phone: focusing the composer raises the keyboard
|
||||
over most of the drawer before the user has read anything. */}
|
||||
<PiggyChatPanel conversation={conversation} context={context} className="min-h-0 flex-1" />
|
||||
over most of the drawer before the user has read anything. A seeded
|
||||
question fills the composer here too, but silently — see the seed
|
||||
effect in `PiggyChatPanel`. */}
|
||||
<PiggyChatPanel
|
||||
conversation={conversation}
|
||||
controls={controls}
|
||||
context={context}
|
||||
seed={seed}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A question handed to the composer from outside. See `requestPiggyAsk`. */
|
||||
export interface PiggySeed {
|
||||
/** Changes on every request, so the same question can be seeded twice. */
|
||||
id: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript and composer. Width-agnostic on purpose — it is used at a
|
||||
* full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
|
||||
*
|
||||
* `compact` is for the dock only. At 22rem the ordinary spacing does not fail,
|
||||
* it just crowds: the assistant avatar takes a tenth of the line, a user
|
||||
* bubble at 88% leaves no gutter to read the alignment from, and the
|
||||
* suggestion buttons wrap to three lines each.
|
||||
* It measures itself rather than being told how much room it has. At 22rem the
|
||||
* ordinary spacing does not fail, it just crowds: the assistant avatar takes a
|
||||
* tenth of the line, a user bubble at 88% leaves no gutter to read the
|
||||
* alignment from, and the suggestion chips wrap to three lines each. That used
|
||||
* to be a `compact` prop, which only the dock ever passed — so the phone
|
||||
* drawer, the narrowest surface in the product, drew the roomy layout while a
|
||||
* 22rem column inside a 1440px window drew the tight one. The surface's own
|
||||
* width is the honest question, and it is the one asked here.
|
||||
*/
|
||||
export function PiggyChatPanel({
|
||||
context,
|
||||
initialPrompt = '',
|
||||
className,
|
||||
compact = false,
|
||||
conversation,
|
||||
controls,
|
||||
emptyState,
|
||||
seed,
|
||||
autoFocusComposer = false,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
/**
|
||||
* A conversation owned by something that outlives this panel. The overlays
|
||||
* pass one because they unmount their children on close; the dock and the
|
||||
* workspace page stay mounted and let the panel keep its own.
|
||||
*/
|
||||
conversation?: PiggyConversationState;
|
||||
/**
|
||||
* The model and mode controls, bound to that conversation by whoever owns it.
|
||||
*
|
||||
* Passed in rather than built here because `usePiggyMode` and
|
||||
* `usePiggyModelChoice` each hold their own copy of the stored preference: a
|
||||
* second binding inside the panel would mean the workspace header and the
|
||||
* composer disagreeing about what the next turn may do, which is precisely
|
||||
* the disagreement the mode control exists to prevent. Omitted, the composer
|
||||
* simply shows no controls — the surface above it has them.
|
||||
*/
|
||||
controls?: PiggyControlsState;
|
||||
/**
|
||||
* Replaces the default openers. The workspace passes its own so it can wire
|
||||
* the escalation to the thread that owns the mode.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
/** A question from a page's Ask Piggy button, seeded into the composer. */
|
||||
seed?: PiggySeed;
|
||||
autoFocusComposer?: boolean;
|
||||
}) {
|
||||
// Called unconditionally — hooks must be — and then ignored when a
|
||||
// conversation was handed in. It holds no resources until something is sent.
|
||||
const own = usePiggyConversation({ context, initialPrompt });
|
||||
const { messages, draft, setDraft, running, send, stop, retry } = conversation ?? own;
|
||||
const active = conversation ?? own;
|
||||
const { messages, draft, setDraft, running, send, stop, retry, approve } = active;
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// The dock keeps one. Nothing fits two on a line at 22rem, so the second is a
|
||||
// whole extra row of chrome taken off the shortest transcript of the three.
|
||||
const followUps = messages.length
|
||||
? piggyFollowUps(context, userQuestions(messages)).slice(0, compact ? 1 : PIGGY_FOLLOW_UP_COUNT)
|
||||
: [];
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const compact = useIsNarrow(rootRef);
|
||||
// Never on a phone, however the composer was filled: focusing a textarea
|
||||
// raises the keyboard over the drawer the user has not read yet.
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocusComposer) return;
|
||||
/*
|
||||
* A pending approval owns the conversation until it is answered.
|
||||
*
|
||||
* The chips offer a different question, and offering one under a card that
|
||||
* is asking whether to write to the book invites the user to walk away from
|
||||
* a decision that is still open.
|
||||
*/
|
||||
const awaitingApproval = messages.some((message) =>
|
||||
message.approvals?.some((approval) => approval.state === 'pending' || approval.state === 'submitting'),
|
||||
);
|
||||
// One chip on a narrow surface. Nothing fits two on a line at 22rem, so the
|
||||
// second is a whole extra row of chrome taken off the shortest transcript in
|
||||
// the product.
|
||||
const followUps =
|
||||
messages.length && !awaitingApproval
|
||||
? piggyFollowUps(context, userQuestions(messages)).slice(
|
||||
0,
|
||||
compact ? 1 : PIGGY_FOLLOW_UP_COUNT,
|
||||
)
|
||||
: [];
|
||||
|
||||
const focusComposer = useCallback(() => {
|
||||
const composer = composerRef.current;
|
||||
if (!composer) return;
|
||||
// Radix moves focus to the first tabbable element in the sheet — its own
|
||||
@@ -227,20 +357,158 @@ export function PiggyChatPanel({
|
||||
composer.setSelectionRange(composer.value.length, composer.value.length);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [autoFocusComposer]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocusComposer) return;
|
||||
return focusComposer();
|
||||
}, [autoFocusComposer, focusComposer]);
|
||||
|
||||
/*
|
||||
* A write opener pressed while Piggy is in Read only.
|
||||
*
|
||||
* The mode has to be committed before the turn leaves, because `send` reads
|
||||
* it off the conversation — so the text is parked here and sent by the effect
|
||||
* below once the conversation is actually holding the new mode. Sending in
|
||||
* the same tick would ask Piggy to change something with the write tools
|
||||
* still withheld, and the answer would be a polite refusal.
|
||||
*/
|
||||
const [escalating, setEscalating] = useState<string | null>(null);
|
||||
const conversationMode = active.mode;
|
||||
const askWithChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!controls?.canWrite) return;
|
||||
if (conversationMode === 'read_only') {
|
||||
controls.setMode('confirm');
|
||||
setEscalating(text);
|
||||
return;
|
||||
}
|
||||
send(text);
|
||||
},
|
||||
[controls, conversationMode, send],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (escalating === null) return;
|
||||
if (conversationMode === 'read_only') return;
|
||||
setEscalating(null);
|
||||
send(escalating);
|
||||
}, [escalating, conversationMode, send]);
|
||||
|
||||
/*
|
||||
* The composer grows with what is in it, up to `COMPOSER_MAX_HEIGHT`.
|
||||
*
|
||||
* It never did, and a seeded question made that impossible to ignore: a page
|
||||
* button hands the composer a whole sentence, the caret lands at the end, and
|
||||
* a 44px box scrolled to the caret shows the user the middle of a question
|
||||
* they have not read yet — with the first line sliced through the middle of
|
||||
* its letters. Growing is also what the `max-h-36` already on the field was
|
||||
* plainly written for.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const composer = composerRef.current;
|
||||
if (!composer) return;
|
||||
const fit = () => {
|
||||
// Collapse first: `scrollHeight` on an already-tall box reports the
|
||||
// height it currently has, so without this the field can only ever grow.
|
||||
composer.style.height = 'auto';
|
||||
composer.style.height = `${Math.min(composer.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
|
||||
};
|
||||
fit();
|
||||
/*
|
||||
* Again next frame, and again on every resize.
|
||||
*
|
||||
* An inline height measured once at mount is a height that goes stale, and
|
||||
* measurably did: on a 393px-wide phone the first pass read 62px for an
|
||||
* empty single-row field that measures 42px a frame later, so the composer
|
||||
* settled 20px taller than its own content for the life of the mount — and
|
||||
* a phone rotated after mount kept the height it was given in the other
|
||||
* orientation. The listener costs nothing and the read is cheap; a stale
|
||||
* measurement on the control the whole company types into is not.
|
||||
*/
|
||||
const frame = requestAnimationFrame(fit);
|
||||
window.addEventListener('resize', fit);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', fit);
|
||||
};
|
||||
// `compact` too: it flips when the surface's measured width crosses the
|
||||
// narrow threshold, which is the other way this field's line count changes
|
||||
// without a keystroke.
|
||||
}, [draft, compact]);
|
||||
|
||||
const seedId = seed?.id;
|
||||
const seedText = seed?.text;
|
||||
useEffect(() => {
|
||||
if (seedId == null) return;
|
||||
// Replaces the draft rather than appending to it: the user pressed a
|
||||
// button asking for this exact question, and a half-typed line joined to a
|
||||
// canned one is a sentence neither of them wrote.
|
||||
if (seedText) setDraft(seedText);
|
||||
if (isMobile) return;
|
||||
return focusComposer();
|
||||
}, [seedId, seedText, setDraft, focusComposer, isMobile]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-h-0 flex-col', className)}>
|
||||
<div ref={rootRef} className={cn('flex min-h-0 flex-col', className)}>
|
||||
{/* The viewport owns the scrolling, the log role and the follow-the-tail
|
||||
behaviour. There is deliberately no scroll effect left in this file:
|
||||
the `scrollIntoView` it replaced fired once per streamed token, which
|
||||
made re-reading an earlier answer mid-stream impossible and dragged
|
||||
the page behind the dock down with it. Gutters go on the scrollport
|
||||
so they scroll with the transcript rather than fencing it. */}
|
||||
<PiggyConversation busy={running} className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}>
|
||||
{messages.length === 0 ? (
|
||||
<PiggyStarters compact={compact} context={context} onAsk={send} />
|
||||
) : (
|
||||
{messages.length === 0 ? (
|
||||
/*
|
||||
* The blank state is deliberately NOT inside the transcript viewport.
|
||||
* That viewport sticks to the bottom of its content, which is right for
|
||||
* an answer arriving and wrong for a page of openers: at 393x852 the
|
||||
* workspace's front door opened already scrolled past its own pig, its
|
||||
* headline and the first column heading. There is nothing to follow
|
||||
* here and nothing to announce, so it is a plain scrollport anchored at
|
||||
* the top, and the viewport below takes over the moment a turn exists.
|
||||
*/
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain',
|
||||
// Measured: at 393x852 the workspace's front door overran its own
|
||||
// scrollport by 32px, which cut the last note in half — a sliced
|
||||
// line of type reads as a rendering fault rather than as something
|
||||
// to scroll to. Sixteen of those pixels are here.
|
||||
compact ? 'px-3 py-3' : 'px-4 py-5 sm:px-5',
|
||||
)}
|
||||
>
|
||||
{/* One front door. The dock used to draw its own openers — three
|
||||
read questions under a Sparkles glyph, with no mention that
|
||||
Piggy can write, which is the product's headline capability
|
||||
missing from the surface people keep open all day. */}
|
||||
{emptyState ?? (
|
||||
<PiggyWorkspaceStarters
|
||||
context={context}
|
||||
mode={controls?.mode ?? conversationMode}
|
||||
canWrite={controls?.canWrite ?? false}
|
||||
onAsk={send}
|
||||
onAskWithChange={askWithChange}
|
||||
narrow={compact}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PiggyConversation
|
||||
/*
|
||||
* `busy` is dropped the moment a proposal is parked, not when the
|
||||
* turn ends. A write tool holds its call open across the whole
|
||||
* approval, so `running` stays true for as long as the card is on
|
||||
* screen — and `aria-busy` on the live-region ROOT is precisely the
|
||||
* flag an assistive technology consults before deciding whether to
|
||||
* speak what arrived inside it. Left set, the one announcement the
|
||||
* product's entire safety argument rests on ("Piggy wants to change
|
||||
* this; nothing has happened yet") was the announcement being
|
||||
* suppressed. The stream is still running; it is running *waiting for
|
||||
* this person*, which is not the state the attribute means.
|
||||
*/
|
||||
busy={running && !awaitingApproval}
|
||||
className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}
|
||||
>
|
||||
<div
|
||||
// The column is capped at a reading measure rather than filling the
|
||||
// page: at 1440 the workspace panel is over a thousand pixels wide,
|
||||
@@ -254,72 +522,156 @@ export function PiggyChatPanel({
|
||||
key={message.id}
|
||||
message={message}
|
||||
compact={compact}
|
||||
onApprove={approve}
|
||||
onRetry={isRetryable(message) && !running ? () => retry(message.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PiggyConversationScrollButton />
|
||||
</PiggyConversation>
|
||||
<PiggyConversationScrollButton />
|
||||
</PiggyConversation>
|
||||
)}
|
||||
|
||||
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4')} onSubmit={(event) => { event.preventDefault(); send(); }}>
|
||||
{followUps.length ? (
|
||||
// Wrapped, not scrolled sideways. A row of whole questions is wider
|
||||
// than every surface but the full page, and a chip sliced off by the
|
||||
// panel edge reads as a rendering fault — where a second line reads
|
||||
// as a second suggestion.
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" aria-label="Suggested questions">
|
||||
{followUps.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
// Dead rather than absent while a turn runs: `send` refuses
|
||||
// anything mid-stream, and a row that vanishes and returns
|
||||
// moves the composer under the user's thumb.
|
||||
disabled={running}
|
||||
// Each chip is one line whatever the width, so the row can only
|
||||
// ever be as tall as the number of suggestions.
|
||||
title={suggestion}
|
||||
className="min-h-11 max-w-full shrink-0 truncate rounded-full border border-border px-3 text-xs text-muted hover:bg-surface-2 hover:text-fg disabled:opacity-50"
|
||||
onClick={() => send(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge> : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
|
||||
className="min-h-11 max-h-36 resize-none"
|
||||
placeholder="Ask about capacity, margin, paper or next actions…"
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label="Stop Piggy" onClick={stop}><CircleStop aria-hidden /></Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2 text-[11px] leading-4 text-muted">
|
||||
<p className="flex-1 text-center">{compact ? 'Read-only session' : 'Read-only session · Check source records before acting on material terms.'}</p>
|
||||
{/* No live region: this changes on every keystroke, and the cap is
|
||||
already announced from the textarea's own `maxLength`. */}
|
||||
{draft.length >= COUNTER_VISIBLE_FROM ? (
|
||||
<p className={cn('shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
|
||||
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
|
||||
</p>
|
||||
{/*
|
||||
The composer's short-viewport diet.
|
||||
|
||||
Measured on a stored thread: at 852x393 this form was 173px of a 393px
|
||||
screen and at 393x390 it was 171px of 390 — 44% of the viewport, on the
|
||||
page whose point is the transcript above it. Nothing in it was wrong;
|
||||
there was simply no gate on a screen with no room. So under
|
||||
`(max-height: 500px)` the two pieces that are conveniences give way —
|
||||
the follow-up suggestions and the standing safety sentence — and the
|
||||
padding halves, leaving the textarea, the Send button and the counter,
|
||||
which are the parts a person on a landscape phone actually came for.
|
||||
The sentence is a standing reminder, not a warning about this turn; the
|
||||
approval card states the stakes at the moment they exist.
|
||||
*/}
|
||||
<form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4', '[@media(max-height:500px)]:p-2')} onSubmit={(event) => { event.preventDefault(); send(); }}>
|
||||
{/* The same measure the transcript is set to. Without it the composer
|
||||
ran the full width of the workspace pane while every answer above it
|
||||
stopped at 48rem, so the box you type into and the column you read
|
||||
back were visibly different documents. */}
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col">
|
||||
{followUps.length ? (
|
||||
// Wrapped, not scrolled sideways. A row of whole questions is wider
|
||||
// than every surface but the full page, and a chip sliced off by the
|
||||
// panel edge reads as a rendering fault — where a second line reads
|
||||
// as a second suggestion.
|
||||
<section className="mb-2 flex flex-wrap gap-1.5 [@media(max-height:500px)]:hidden" aria-label="Suggested questions">
|
||||
{followUps.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
// Dead rather than absent while a turn runs: `send` refuses
|
||||
// anything mid-stream, and a row that vanishes and returns
|
||||
// moves the composer under the user's thumb.
|
||||
disabled={running}
|
||||
// Wraps rather than truncating, and 12px-radius rather than a
|
||||
// pill, because a chip cut off mid-word — "How much of this
|
||||
// block is still un…" — is a question nobody can decide
|
||||
// whether they want asked. A second line costs less than a
|
||||
// suggestion nobody presses.
|
||||
title={suggestion}
|
||||
className={cn(
|
||||
'flex min-h-11 max-w-full items-center rounded-lg border border-border px-3 py-2',
|
||||
'text-left text-xs leading-4 text-muted transition-colors duration-1 ease-enter',
|
||||
'hover:bg-surface-2 hover:text-fg disabled:opacity-50',
|
||||
)}
|
||||
onClick={() => send(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
{/* Above the textarea, not below it: these decide what the next turn may
|
||||
do, and they are read at the moment the send button is looked at.
|
||||
Disabled while a turn runs, because that turn's settings are already
|
||||
fixed — changing them mid-answer would suggest otherwise. */}
|
||||
{controls ? (
|
||||
<PiggyControls controls={controls} compact={compact} disabled={running} className="mb-2">
|
||||
{context ? (
|
||||
// `basis-full` so the badge takes a row of its own rather than
|
||||
// sitting beside the controls and pushing the row wider than the
|
||||
// dock: an inline-flex badge sizes to its content, and a page
|
||||
// context's label is a whole sentence of it.
|
||||
<Badge className="flex min-w-0 basis-full">
|
||||
<Database aria-hidden className="shrink-0" />
|
||||
<span className="truncate">{contextLabel(context)}</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</PiggyControls>
|
||||
) : context ? (
|
||||
<Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
maxLength={PIGGY_MESSAGE_MAX_LENGTH}
|
||||
/*
|
||||
* One row at rest, not the element default of two.
|
||||
*
|
||||
* The effect above sizes the field to its own `scrollHeight`, and
|
||||
* `scrollHeight` counts `rows` — so an empty composer was 64px, a
|
||||
* blank second line the user has not typed, beside a 44px Send
|
||||
* button it therefore never lined up with. On a 390px keyboard-up
|
||||
* screen those 20px are 5% of the viewport taken from the
|
||||
* transcript, which is what made this worth chasing; on every
|
||||
* other surface it is simply the composer finally matching the
|
||||
* 44px floor it already declares. It still grows to `max-h-36`
|
||||
* from the first keystroke, so nothing about typing changes.
|
||||
*/
|
||||
rows={1}
|
||||
// `overflow-y-auto` so the field scrolls once it has grown to its
|
||||
// cap rather than hiding the rest of a long paste.
|
||||
className="min-h-11 max-h-36 resize-none overflow-y-auto"
|
||||
placeholder={piggyLine(piggyCopy.composerPlaceholder, compact)}
|
||||
aria-label="Message Piggy"
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Stop Piggy"
|
||||
// The Stop button is replaced by Send the instant the turn
|
||||
// ends, so focus was landing on `<body>` — on the workspace,
|
||||
// 121 Tab presses from the composer the user was about to type
|
||||
// the next question into.
|
||||
onClick={() => {
|
||||
stop();
|
||||
focusComposer();
|
||||
}}
|
||||
>
|
||||
<CircleStop aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2 text-xs leading-4 text-muted [@media(max-height:500px)]:mt-0">
|
||||
{/* No longer "Read-only session": Piggy writes now, and what it may
|
||||
do this turn is stated by the mode control above rather than by a
|
||||
line of copy that would have to be kept in step with it. What is
|
||||
left is the part that is true in every mode. */}
|
||||
<p className="flex-1 text-center [@media(max-height:500px)]:hidden">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
|
||||
{/* No live region: this changes on every keystroke, and the cap is
|
||||
already announced from the textarea's own `maxLength`.
|
||||
`ml-auto` keeps it right-aligned on a short viewport, where the
|
||||
sentence that was pushing it there is gone. */}
|
||||
{draft.length >= COUNTER_VISIBLE_FROM ? (
|
||||
<p className={cn('ml-auto shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
|
||||
{draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -327,40 +679,26 @@ export function PiggyChatPanel({
|
||||
}
|
||||
|
||||
/**
|
||||
* The blank transcript.
|
||||
* How wide the panel actually is, as a boolean.
|
||||
*
|
||||
* The openers come from `piggySuggestions`, which chooses them by the one read
|
||||
* tool this context resolves to rather than by what the page is called — so
|
||||
* every line offered here is one Piggy can actually ground. The dock takes
|
||||
* three of them: at 22rem each opener wraps to two lines, and a fourth turns a
|
||||
* quick way in into a page of text to read before typing.
|
||||
* A media query cannot answer this: the dock is 22rem inside a 1440px window,
|
||||
* and asking the window would give the roomy layout to a column that has no
|
||||
* room. Measured in a layout effect so the first paint is already correct —
|
||||
* a panel that renders roomy and reflows to tight one frame later is a panel
|
||||
* that visibly twitches every time an overlay opens.
|
||||
*/
|
||||
function PiggyStarters({
|
||||
context,
|
||||
compact,
|
||||
onAsk,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
compact: boolean;
|
||||
onAsk: (text: string) => void;
|
||||
}) {
|
||||
const suggestions = piggySuggestions(context);
|
||||
return (
|
||||
// `flex-1`, not `h-full`: the conversation's content element is sized by its
|
||||
// children, so a percentage height here resolves to nothing.
|
||||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col items-center justify-center text-center">
|
||||
<div className={cn('flex items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg', compact ? 'size-10' : 'size-12')}><Sparkles aria-hidden /></div>
|
||||
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
|
||||
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records.</p>
|
||||
<div className="mt-4 grid w-full gap-2">
|
||||
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
|
||||
// Sends rather than fills the composer. Filling it looked like
|
||||
// nothing had happened, so the chip read as a dead control.
|
||||
<button key={suggestion} type="button" className={cn('min-h-11 rounded-lg border border-border px-3 py-2 text-left hover:bg-surface-2', compact ? 'text-xs leading-5' : 'text-sm')} onClick={() => onAsk(suggestion)}>{suggestion}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function useIsNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const measure = () => setNarrow(element.getBoundingClientRect().width < COMPACT_WIDTH);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
/** What the user has already asked, so a follow-up chip cannot offer back a
|
||||
@@ -380,10 +718,13 @@ function contextLabel(context: PiggyChatContext): string {
|
||||
function ChatMessage({
|
||||
message,
|
||||
compact = false,
|
||||
onApprove,
|
||||
onRetry,
|
||||
}: {
|
||||
message: TranscriptMessage;
|
||||
compact?: boolean;
|
||||
/** Answer a proposed write. Absent only where no conversation is driving. */
|
||||
onApprove?: (changeId: string, decision: PiggyApprovalDecision) => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
if (message.role === 'user') {
|
||||
@@ -394,13 +735,24 @@ function ChatMessage({
|
||||
</div>
|
||||
{/* The question is still on screen after a failed send, so the user's
|
||||
words are never lost — but the bubble alone reads as sent. */}
|
||||
{message.failed ? <p className="mt-1 text-[11px] leading-4 text-muted">Not sent</p> : null}
|
||||
{message.failed ? <p className="mt-1 text-xs leading-4 text-muted">Not sent</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
|
||||
<div className={cn('flex shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg', compact ? 'size-7 [&>svg]:size-4' : 'size-9')}><Bot aria-hidden /></div>
|
||||
{/* The signature on every answer the company will read this year, and
|
||||
until now it was a stock robot glyph — the one place Piggy's identity
|
||||
is seen most, wearing somebody else's face. Round, because this is an
|
||||
avatar and avatars are round in this product. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-full bg-accent-subtle text-accent-fg',
|
||||
compact ? 'size-7' : 'size-9',
|
||||
)}
|
||||
>
|
||||
<PiggyMark className={compact ? 'size-4' : 'size-5'} />
|
||||
</div>
|
||||
{/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
|
||||
on, and it is repeated here on purpose: the footer marks itself, so
|
||||
without this the only way to find Copy is to sweep the pointer across
|
||||
@@ -414,13 +766,34 @@ function ChatMessage({
|
||||
show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
|
||||
<PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
|
||||
{message.tools?.length ? (
|
||||
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
|
||||
// `aria-live="off"`: these rows change several times a second while a
|
||||
// turn runs, and they sit inside the transcript's own live region, so
|
||||
// without it a screen reader reads out every tool starting and
|
||||
// finishing before the answer the user asked for arrives.
|
||||
<section className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity" aria-live="off">
|
||||
{message.tools.map((tool) => (
|
||||
<PiggyToolStep key={tool.id} step={tool} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{message.content ? <PiggyResponse content={message.content} /> : null}
|
||||
{/* Below the answer, because the answer is where Piggy says what it
|
||||
intends to do and the card is the thing that lets it. A card above
|
||||
the sentence explaining it would ask for a decision before giving
|
||||
the reason for it. */}
|
||||
{message.approvals?.length ? (
|
||||
<div className={cn('flex flex-col gap-2', message.content && 'mt-3')}>
|
||||
{message.approvals.map((approval) => (
|
||||
<PiggyApprovalCard
|
||||
key={approval.change.id}
|
||||
change={approval.change}
|
||||
state={approval.state}
|
||||
error={approval.error}
|
||||
onDecide={(decision) => onApprove?.(approval.change.id, decision)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Only while the turn has produced nothing at all. Once a tool chip or
|
||||
the reasoning panel is on screen, the turn is visibly working and a
|
||||
second spinner saying so is noise. */}
|
||||
|
||||
@@ -16,22 +16,67 @@
|
||||
* runtime is disabled, so a dock that renders its composer without asking
|
||||
* first is a permanent third of the window that fails on first use.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { PanelRightClose, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { PanelRightClose } from 'lucide-react';
|
||||
import { useHasDockRoom } from '@/hooks/use-media-query';
|
||||
import { PIGGY_ASK_LABEL } from '@/lib/piggy-copy';
|
||||
import { useLayout } from '@/lib/layout';
|
||||
import { usePiggyCurrentContext } from '@/lib/piggy-context';
|
||||
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
|
||||
import type { PiggyChatContext } from '@/lib/piggy-chat';
|
||||
import { usePiggyChatSession } from './piggy/workspace/controls';
|
||||
import {
|
||||
PiggyChatPanel,
|
||||
ResponsivePiggyChat,
|
||||
usePiggyAskRequests,
|
||||
usePiggyStatus,
|
||||
type PiggyAsk,
|
||||
} from './PiggyChat';
|
||||
import { PiggyMark } from './PiggyMark';
|
||||
import { Button, EmptyState, Skeleton, cn } from './ui';
|
||||
|
||||
/**
|
||||
* Where Piggy is the page rather than the panel.
|
||||
*
|
||||
* The dock and the workspace are the same agent, so on `/piggy` an open dock
|
||||
* put two composers, two empty states and two conversations side by side.
|
||||
* Nothing broke; it just made the product look like it did not know what it was.
|
||||
*/
|
||||
const PIGGY_WORKSPACE_PATH = '/piggy';
|
||||
|
||||
export function PiggyDock() {
|
||||
const { dockOpen, setDockOpen } = useLayout();
|
||||
const hasRoom = useHasDockRoom();
|
||||
const status = usePiggyStatus();
|
||||
const context = usePiggyCurrentContext();
|
||||
const ambient = usePiggyCurrentContext();
|
||||
const { pathname } = useLocation();
|
||||
const onWorkspace = pathname === PIGGY_WORKSPACE_PATH;
|
||||
|
||||
if (!hasRoom || !dockOpen) return null;
|
||||
/*
|
||||
* A question pressed on the page, taken by the column rather than by a
|
||||
* second Piggy in a sheet on top of it.
|
||||
*
|
||||
* The ask carries its own context because a record button knows something
|
||||
* the route does not: pressing Ask Piggy on a contract row must put the dock
|
||||
* on that contract, not on the page it happens to be listed under.
|
||||
*
|
||||
* Cleared on navigation. A dock still pinned to a contract while you read an
|
||||
* account is a dock whose badge is lying about what it is looking at, and
|
||||
* the cost — a record-scoped thread ending when you leave the record — is
|
||||
* the same cost the ambient key already pays for every record page.
|
||||
*/
|
||||
const [ask, setAsk] = useState<PiggyAsk | null>(null);
|
||||
usePiggyAskRequests((next) => {
|
||||
if (hasRoom) setAsk(next);
|
||||
});
|
||||
useEffect(() => setAsk(null), [pathname]);
|
||||
|
||||
const context = ask?.context ?? ambient;
|
||||
|
||||
// The remembered `dockOpen` is deliberately left alone: the column comes back
|
||||
// by itself on the next page, so visiting the workspace does not silently
|
||||
// close a panel the user had open everywhere else.
|
||||
if (onWorkspace || !hasRoom || !dockOpen) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -70,7 +115,7 @@ export function PiggyDock() {
|
||||
</div>
|
||||
) : !status.data?.canUse ? (
|
||||
<EmptyState
|
||||
icon={<Sparkles />}
|
||||
icon={<PiggyMark className="size-6" />}
|
||||
title="Piggy is unavailable"
|
||||
description={
|
||||
status.data?.enabled
|
||||
@@ -87,11 +132,10 @@ export function PiggyDock() {
|
||||
// pane that stays put while you move around the app. The panel reads
|
||||
// `context` at send time, so the page it is asking about still tracks
|
||||
// the route without a remount.
|
||||
<PiggyChatPanel
|
||||
<DockThread
|
||||
key={context.type === 'page' ? 'page' : JSON.stringify(context)}
|
||||
context={context}
|
||||
compact
|
||||
className="min-h-0 flex-1"
|
||||
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
@@ -99,19 +143,73 @@ export function PiggyDock() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The header control for Piggy.
|
||||
* The dock's own conversation, and the controls bound to it.
|
||||
*
|
||||
* Both live here rather than inside `PiggyChatPanel` because the panel is not
|
||||
* the thing whose lifetime they follow: the key above is what decides when a
|
||||
* docked thread is thrown away and started again, and the mode and model have
|
||||
* to be bound to whichever conversation that key produced.
|
||||
*/
|
||||
function DockThread({
|
||||
context,
|
||||
seed,
|
||||
}: {
|
||||
context: PiggyChatContext;
|
||||
seed?: { id: number; text?: string };
|
||||
}) {
|
||||
const { conversation, controls } = usePiggyChatSession({ context });
|
||||
return (
|
||||
<PiggyChatPanel
|
||||
conversation={conversation}
|
||||
controls={controls}
|
||||
context={context}
|
||||
seed={seed}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header control for Piggy, and the only thing that opens the agent.
|
||||
*
|
||||
* Below `xl` there is no column to toggle, so the same button opens the sheet
|
||||
* or drawer instead — one affordance in one place, whatever the viewport can
|
||||
* accommodate.
|
||||
* accommodate. A page's "Ask Piggy" button no longer opens a surface of its
|
||||
* own: it publishes its question through `requestPiggyAsk` and this control
|
||||
* decides where Piggy is, which is what stops a pig face and a speech bubble
|
||||
* on the same row of pixels from opening two different agents.
|
||||
*/
|
||||
export function PiggyDockToggle({ className }: { className?: string }) {
|
||||
const { dockOpen, setDockOpen } = useLayout();
|
||||
const hasRoom = useHasDockRoom();
|
||||
const status = usePiggyStatus();
|
||||
const context = usePiggyCurrentContext();
|
||||
const ambient = usePiggyCurrentContext();
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
/** The question that opened the overlay, when a page asked one. */
|
||||
const [ask, setAsk] = useState<PiggyAsk | null>(null);
|
||||
const unavailable = status.data && !status.data.canUse;
|
||||
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
|
||||
|
||||
usePiggyAskRequests((next) => {
|
||||
if (unavailable) return;
|
||||
if (hasRoom) {
|
||||
// The column takes it from here — `PiggyDock` is subscribed too, and it
|
||||
// is the surface that holds the thread. All this has to do is make sure
|
||||
// the column the user is about to be answered in is actually on screen.
|
||||
setDockOpen(true);
|
||||
return;
|
||||
}
|
||||
setAsk(next);
|
||||
setOverlayOpen(true);
|
||||
});
|
||||
|
||||
// Nothing for it to open: the whole page is Piggy. Left in the header as a
|
||||
// dead control it would be the only button in PIG that does nothing when
|
||||
// pressed — and pressed on the workspace it would toggle a column that
|
||||
// `PiggyDock` refuses to draw.
|
||||
if (onWorkspace) return null;
|
||||
|
||||
const overlayContext = ask?.context ?? ambient;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,15 +227,35 @@ export function PiggyDockToggle({ className }: { className?: string }) {
|
||||
? dockOpen
|
||||
? 'Close the Piggy panel'
|
||||
: 'Open the Piggy panel'
|
||||
: 'Ask Piggy'
|
||||
: PIGGY_ASK_LABEL
|
||||
}
|
||||
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : 'Piggy'}
|
||||
onClick={() => (hasRoom ? setDockOpen(!dockOpen) : setOverlayOpen(true))}
|
||||
onClick={() => {
|
||||
if (hasRoom) {
|
||||
setDockOpen(!dockOpen);
|
||||
return;
|
||||
}
|
||||
// Pressing the header control asks about the page, not about the
|
||||
// record a button last pointed at: the ask is spent.
|
||||
setAsk(null);
|
||||
setOverlayOpen(true);
|
||||
}}
|
||||
>
|
||||
<PiggyMark className="size-5" />
|
||||
</Button>
|
||||
{hasRoom ? null : (
|
||||
<ResponsivePiggyChat open={overlayOpen} onOpenChange={setOverlayOpen} context={context} />
|
||||
<ResponsivePiggyChat
|
||||
// Keyed on the record the question is about, so a question pressed on
|
||||
// a contract opens a thread about that contract — and pressing the
|
||||
// same button again returns to the thread already in progress rather
|
||||
// than discarding it.
|
||||
key={overlayContext.type === 'page' ? 'page' : JSON.stringify(overlayContext)}
|
||||
open={overlayOpen}
|
||||
onOpenChange={setOverlayOpen}
|
||||
context={overlayContext}
|
||||
initialPrompt={ask?.prompt}
|
||||
seed={ask ? { id: ask.id, text: ask.prompt } : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,19 @@ export function Shell() {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/*
|
||||
First in the DOM, so the first Tab of a fresh page offers it. Without
|
||||
it a keyboard reader crosses the header, the whole sidebar and the
|
||||
Piggy dock — measured at over a hundred stops on /piggy — before
|
||||
reaching the thing they came for.
|
||||
*/}
|
||||
<a
|
||||
href="#page-content"
|
||||
className="sr-only text-sm font-medium text-fg focus:not-sr-only focus:absolute focus:left-2 focus:top-[max(0.5rem,var(--safe-top))] focus:z-50 focus:flex focus:min-h-11 focus:items-center focus:rounded-lg focus:bg-surface focus:px-4 focus:shadow-lg focus:ring-2 focus:ring-brand"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex w-full min-w-0 flex-1">
|
||||
@@ -69,11 +82,16 @@ export function Shell() {
|
||||
// their own `md:pb-0` on top of this; keeping `lg` here means they
|
||||
// still have their padding between md and lg, where the tab bar is
|
||||
// very much still on screen.
|
||||
className="pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0"
|
||||
className="pb-[calc(4.5rem+var(--safe-bottom))] [@media(max-height:500px)]:pb-[var(--safe-bottom)] lg:pb-0"
|
||||
>
|
||||
<div
|
||||
id="page-content"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'mx-auto w-full min-w-0 px-4 py-5 sm:px-6 lg:px-8 lg:py-8',
|
||||
// The one page canvas inset in the product: 16 / 24 / 32 across,
|
||||
// 24 / 32 down. Pages set their own rhythm inside it and nothing
|
||||
// else may set a page margin.
|
||||
'mx-auto w-full min-w-0 px-4 py-6 focus-visible:outline-none sm:px-6 lg:px-8 lg:py-8',
|
||||
// With Piggy docked the middle pane is already a column in a
|
||||
// three-column layout; capping it at 7xl and centring it again
|
||||
// strands the content between two gutters it does not need.
|
||||
@@ -95,6 +113,20 @@ export function Shell() {
|
||||
/**
|
||||
* The phone tab bar. Unchanged in look and behaviour — it is the thing this
|
||||
* product is best at and the rebuild had no business touching it.
|
||||
*
|
||||
* With one exception, and it was a real defect. `WorkspaceRoute` and
|
||||
* `SidebarInset` both give the bar's 72px padding reserve back under
|
||||
* `(max-height: 500px)` — a landscape phone, or a phone with the keyboard up,
|
||||
* cannot spend a fifth of its screen on a bar it can reach by turning the
|
||||
* handset back — and both files' comments said the bar itself stood down at the
|
||||
* same height. It did not: it stayed `fixed` at the bottom with nothing holding
|
||||
* content clear of it, so at 852x393 it painted over the Piggy composer's
|
||||
* safety line and the lower 23px of the 44px Send button. Confirmed by
|
||||
* hit-testing, not by a screenshot: `elementFromPoint` at the Send button's
|
||||
* centre returned a tab-bar link.
|
||||
*
|
||||
* Standing down is safe because navigation does not go with it — `SidebarTrigger`
|
||||
* is in the header on every viewport below `lg` and opens the same nav sheet.
|
||||
*/
|
||||
function MobileTabBar({ items }: { items: NavItem[] }) {
|
||||
return (
|
||||
@@ -102,6 +134,7 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/95 shadow-[0_-8px_24px_hsl(var(--shadow)/0.08)] backdrop-blur-xl lg:hidden',
|
||||
'supports-[backdrop-filter]:bg-surface/80',
|
||||
'[@media(max-height:500px)]:hidden',
|
||||
)}
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
aria-label="Primary"
|
||||
@@ -112,13 +145,13 @@ function MobileTabBar({ items }: { items: NavItem[] }) {
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-[11px] font-medium text-muted"
|
||||
className="tap flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1.5 text-xs font-medium text-muted"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors',
|
||||
'grid min-h-7 min-w-12 place-items-center rounded-full transition-colors duration-1 ease-enter',
|
||||
isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* here — so the UI's job is to be legible, and disabling the option would only
|
||||
* hide a refusal the server is going to make anyway with a better message.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@pig/core';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -91,7 +92,7 @@ export function AddResourceDialog({
|
||||
create.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="Share link" htmlFor="learn-url">
|
||||
<FormField label="Share link">
|
||||
<Input
|
||||
id="learn-url"
|
||||
value={url}
|
||||
@@ -100,24 +101,24 @@ export function AddResourceDialog({
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Title" htmlFor="learn-title">
|
||||
</FormField>
|
||||
<FormField label="Title">
|
||||
<Input
|
||||
id="learn-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Summary" htmlFor="learn-summary">
|
||||
</FormField>
|
||||
<FormField label="Summary">
|
||||
<Input
|
||||
id="learn-summary"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
placeholder="What someone learns from it"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<Field label="Track" htmlFor="learn-track">
|
||||
<FormField label="Track">
|
||||
<NativeSelect
|
||||
id="learn-track"
|
||||
value={track}
|
||||
@@ -127,8 +128,8 @@ export function AddResourceDialog({
|
||||
label: LEARN_TRACK_LABELS[value],
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Visibility" htmlFor="learn-visibility">
|
||||
</FormField>
|
||||
<FormField label="Visibility">
|
||||
<NativeSelect
|
||||
id="learn-visibility"
|
||||
value={visibility}
|
||||
@@ -138,9 +139,9 @@ export function AddResourceDialog({
|
||||
label: value === 'code' ? 'Anyone with the code' : 'Members only',
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="Length in minutes" htmlFor="learn-minutes">
|
||||
<FormField label="Length in minutes">
|
||||
<Input
|
||||
id="learn-minutes"
|
||||
value={minutes}
|
||||
@@ -148,7 +149,7 @@ export function AddResourceDialog({
|
||||
inputMode="decimal"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
<div className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
@@ -167,39 +168,22 @@ export function AddResourceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label htmlFor={htmlFor} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
...field
|
||||
}: {
|
||||
id: string;
|
||||
id?: string;
|
||||
'aria-label'?: string;
|
||||
'aria-describedby'?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-11 w-full min-w-0 rounded-lg border border-border bg-surface px-3 text-base text-fg focus-visible:border-accent"
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ArchiveControl({
|
||||
aria-label={confirming ? `Confirm archiving ${title}` : `Archive ${title}`}
|
||||
className={cn(
|
||||
'tap inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium',
|
||||
'bg-surface/90 backdrop-blur-sm transition-colors disabled:opacity-50',
|
||||
'bg-surface/90 backdrop-blur-sm transition-colors duration-1 ease-enter disabled:opacity-50',
|
||||
confirming ? 'text-danger hover:bg-danger/10' : 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function LearnPoster({
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full border border-border',
|
||||
'bg-surface/85 text-fg shadow-sm backdrop-blur-sm',
|
||||
'transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105',
|
||||
'transition-transform duration-1 ease-enter group-hover:scale-105 group-focus-visible:scale-105',
|
||||
compact ? 'size-9' : 'size-14',
|
||||
)}
|
||||
aria-hidden
|
||||
|
||||
@@ -41,7 +41,7 @@ export function LearnTrackPanel({
|
||||
return (
|
||||
<Card className="flex min-w-0 flex-col gap-5 p-5 sm:p-6">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
|
||||
<h2 className="text-base font-semibold leading-tight">{heading}</h2>
|
||||
<p className="min-w-0 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function LearnVideoCard({
|
||||
}) {
|
||||
const playback = learnPlayback(resource);
|
||||
return (
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlay(resource)}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { formatLearnDuration } from '@pig/core';
|
||||
import { Badge, Card } from '@/components/ui';
|
||||
import { Badge, Card, Label } from '@/components/ui';
|
||||
import { ArchiveControl } from './ArchiveControl';
|
||||
import { learnPlayback } from './model';
|
||||
import { LearnPoster } from './LearnPoster';
|
||||
@@ -35,7 +35,7 @@ export function LearnWalkthroughList({
|
||||
const playback = learnPlayback(resource);
|
||||
return (
|
||||
<li key={resource.id} className="min-w-0">
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow duration-1 ease-enter hover:shadow-md sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlay(resource)}
|
||||
@@ -54,9 +54,7 @@ export function LearnWalkthroughList({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p className="nums text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-accent-fg">
|
||||
Step {index + 1}
|
||||
</p>
|
||||
<Label className="nums">Step {index + 1}</Label>
|
||||
<h3 className="min-w-0 break-words font-semibold leading-snug">
|
||||
{resource.title}
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* What Piggy has been doing, and what it has cost.
|
||||
*
|
||||
* This is the audit surface. An agent-native CRM is only defensible if the
|
||||
* agent's work is legible after the fact, so everything the ledger knows is
|
||||
* shown rather than summarised away: the turn that failed, the task that is
|
||||
* still queued, the money that has gone.
|
||||
*
|
||||
* The one rule that matters here is the money. `costMicroCents` is millionths
|
||||
* of a cent — the unit the provider bills in and the unit the column stores —
|
||||
* and a turn genuinely costs a few ten-thousandths of a cent, so the naive
|
||||
* rendering rounds every real figure to `$0.00`. So no raw factor is ever
|
||||
* written in this file: the conversion goes through `MICRO_CENTS_PER_DOLLAR`
|
||||
* every time, `spendMoney` is the only thing that formats money, and every
|
||||
* figure carries its exact micro-cent value in a title attribute so a reader
|
||||
* who does not believe the conversion can check it. Getting this wrong by a
|
||||
* factor of anything is the worst error this panel could make.
|
||||
*
|
||||
* Layout: a single column that scrolls inside whatever height its parent gives
|
||||
* it, so the same component is a right-hand rail on a desktop and the contents
|
||||
* of a sheet on a phone. Each section collapses, which is what makes it usable
|
||||
* at 393px — the spend figures stay, the two lists fold away.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { compactNumber, get, relativeTime } from '@/lib/api';
|
||||
import { Button, EmptyState, Section, Skeleton, Stat, cn } from '@/components/ui';
|
||||
import { RunStatusBadge, TaskStateBadge } from '@/components/status';
|
||||
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
|
||||
|
||||
// -------------------------------------------------------------- the wire
|
||||
|
||||
/** Mirrors `PiggyRunSummary` in apps/api/src/services/piggy-activity.ts. */
|
||||
export interface PiggyRunSummary {
|
||||
id: string;
|
||||
kind: 'chat' | 'task';
|
||||
agent: string;
|
||||
/** Free text on purpose — see the note on the server type. */
|
||||
status: string;
|
||||
model: string | null;
|
||||
label: string;
|
||||
summary: string | null;
|
||||
error: string | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
costMicroCents: number | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
durationMs: number | null;
|
||||
taskKind: string | null;
|
||||
/** Present only when the transcript is the viewer's own. */
|
||||
conversation: { id: string; title: string } | null;
|
||||
/** Present only when the run was somebody else's — a platform admin's view. */
|
||||
principal: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** Mirrors `PiggyTaskSummary`. */
|
||||
export interface PiggyTaskSummary {
|
||||
id: string;
|
||||
kind: string;
|
||||
subject: string;
|
||||
reason: string | null;
|
||||
state: 'running' | 'queued' | 'scheduled' | 'succeeded' | 'failed' | 'skipped' | 'cancelled';
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
priority: number;
|
||||
dueAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PiggyActivityResponse {
|
||||
runs: PiggyRunSummary[];
|
||||
tasks: PiggyTaskSummary[];
|
||||
spend: { todayMicroCents: number; monthMicroCents: number; turns: number };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ formatting
|
||||
|
||||
/**
|
||||
* Micro-cents to US dollars. A cent is 10^6 micro-cents; a dollar is 100 cents.
|
||||
* Written as one constant so the two conversions cannot be applied separately
|
||||
* and end up compounding.
|
||||
*/
|
||||
const MICRO_CENTS_PER_DOLLAR = 100_000_000;
|
||||
|
||||
/** Runs shown before the list asks to be expanded. See `allRuns`. */
|
||||
const RUNS_BEFORE_EXPANDING = 8;
|
||||
|
||||
const EXACT = new Intl.NumberFormat('en-US');
|
||||
|
||||
/**
|
||||
* Money, at whatever precision the figure actually has.
|
||||
*
|
||||
* A month of Piggy costs about a penny and a single turn costs three
|
||||
* ten-thousandths of one, so a fixed two decimal places would render the entire
|
||||
* panel as `$0.00` and quietly answer "what is the credit doing?" with
|
||||
* "nothing". Precision instead widens as the number shrinks, to **two
|
||||
* significant figures** and never past six decimal places.
|
||||
*
|
||||
* Two figures, not the six the panel used to print: `$0.000483` claims a
|
||||
* precision the reader cannot use and cannot check, and a column of them reads
|
||||
* as noise rather than as money. Anyone who does want the exact number has it —
|
||||
* every figure here carries its raw micro-cent value in a title attribute, and
|
||||
* that is the audit trail, not the rendering.
|
||||
*/
|
||||
function decimalsFor(dollars: number): number {
|
||||
const size = Math.abs(dollars);
|
||||
if (size === 0 || size >= 1) return 2;
|
||||
// floor(log10) is the position of the leading digit; one place past it is the
|
||||
// second significant figure.
|
||||
return Math.min(6, 1 - Math.floor(Math.log10(size)));
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported for the conversation's own spend figure, which sits directly beside
|
||||
* this panel in the workspace rail. A second formatter for millionths of a cent
|
||||
* one tab away from this one is exactly how two figures of the same money come
|
||||
* to be shown at two precisions.
|
||||
*/
|
||||
export function spendMoney(microCents: number | null, decimals?: number): string {
|
||||
if (microCents == null) return '—';
|
||||
const dollars = microCents / MICRO_CENTS_PER_DOLLAR;
|
||||
const digits = decimals ?? decimalsFor(dollars);
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
||||
/**
|
||||
* One precision for a pair of figures shown side by side.
|
||||
*
|
||||
* Today at six places beside the month at four reads as two different kinds of
|
||||
* number rather than one number in two windows. The smallest non-zero figure
|
||||
* decides, so the narrower window never rounds away to nothing.
|
||||
*/
|
||||
function sharedDecimals(...microCents: number[]): number {
|
||||
const positive = microCents
|
||||
.map((value) => Math.abs(value) / MICRO_CENTS_PER_DOLLAR)
|
||||
.filter((value) => value > 0);
|
||||
if (positive.length === 0) return 2;
|
||||
return decimalsFor(Math.min(...positive));
|
||||
}
|
||||
|
||||
/**
|
||||
* What went wrong, in the words the person at the keyboard already heard.
|
||||
*
|
||||
* `agent_runs.error` is deliberately the raw reason — the chat stream sanitises
|
||||
* what the browser is told and the ledger keeps the truth, which is the right
|
||||
* division. But this panel rendered that truth verbatim, so PIG's own audit
|
||||
* surface showed a turn stopping as
|
||||
* `turn stopped by the model_calls ceiling: 8 model calls, 5457 tokens, ceiling 8`
|
||||
* and a rate limit as a JSON document from a third party. Both had already been
|
||||
* explained to the same reader, in English, in the transcript a moment earlier.
|
||||
*
|
||||
* So the prefixes the relay writes are mapped back to the sentences the relay
|
||||
* emits (`chat-server.ts` → `reportBreach` / `reportInferenceFailure`). Prose is
|
||||
* left alone — an error that is already a sentence is somebody's considered
|
||||
* wording and this table is not an improvement on it. The raw string is always
|
||||
* one hover away in `title`, which is what makes the mapping safe.
|
||||
*/
|
||||
const ERROR_SENTENCES: readonly {
|
||||
match: RegExp;
|
||||
say: (groups: RegExpExecArray) => string;
|
||||
}[] = [
|
||||
{
|
||||
match: /^turn stopped by the model_calls ceiling: (\d+) model calls/,
|
||||
say: ([, calls]) =>
|
||||
`Piggy stopped after ${calls} step${calls === '1' ? '' : 's'}, which is the most one ` +
|
||||
'question may take, so this answer is incomplete.',
|
||||
},
|
||||
{
|
||||
match: /^turn stopped by the tokens ceiling: \d+ model calls, (\d+) tokens/,
|
||||
say: ([, tokens]) =>
|
||||
`Piggy reached the size limit for a single question (${Number(tokens).toLocaleString(
|
||||
'en-GB',
|
||||
)} tokens), so this answer is incomplete.`,
|
||||
},
|
||||
{
|
||||
// The AbortError, which is what Stop and a closed tab both leave behind.
|
||||
// The badge beside it already says "Stopped by you"; this says what it cost.
|
||||
match: /^This operation was aborted/,
|
||||
say: () => 'Stopped before Piggy finished the answer.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The shapes a rate limit arrives in. Matches `isRateLimited` in the relay. */
|
||||
const RATE_LIMITED = /\b429\b|rate.?limit|rate_limited|too many requests|resourceexhausted/i;
|
||||
|
||||
function readableError(error: string): string {
|
||||
const trimmed = error.trim();
|
||||
|
||||
for (const { match, say } of ERROR_SENTENCES) {
|
||||
const found = match.exec(trimmed);
|
||||
if (found) return say(found);
|
||||
}
|
||||
|
||||
// `429: {"message":…}` and friends: a status code and a provider's JSON body,
|
||||
// which is the one shape that is never anybody's considered wording.
|
||||
const wire = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(trimmed);
|
||||
if (!wire) return error;
|
||||
if (RATE_LIMITED.test(trimmed)) {
|
||||
return 'The inference endpoint was rate limiting us, so this turn was turned away. Waiting a few seconds and asking again usually clears it.';
|
||||
}
|
||||
const [, status, payload] = wire;
|
||||
try {
|
||||
const body = JSON.parse(payload!) as { message?: unknown; error?: unknown };
|
||||
const message =
|
||||
typeof body.message === 'string'
|
||||
? body.message
|
||||
: typeof body.error === 'string'
|
||||
? body.error
|
||||
: null;
|
||||
// The status is kept: "500" is the part an operator acts on.
|
||||
return message ? `${status}: ${message}` : error;
|
||||
} catch {
|
||||
// Not JSON after all. Showing it unchanged beats showing nothing.
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The unit, spelled out, for the title attribute on every money figure. */
|
||||
export function spendTitle(microCents: number | null): string | undefined {
|
||||
if (microCents == null) return undefined;
|
||||
return `${EXACT.format(microCents)} micro-cents (millionths of a US cent)`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string | null {
|
||||
if (ms == null || ms < 0) return null;
|
||||
if (ms < 1_000) return `${ms} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1_000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/** The model name without its vendor prefix, which is the same on every row. */
|
||||
function shortModel(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const parts = model.split('/');
|
||||
return parts[parts.length - 1] ?? model;
|
||||
}
|
||||
|
||||
function humanise(value: string): string {
|
||||
return value.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- primitives
|
||||
|
||||
/**
|
||||
* The copy module owns the sentence; an empty state needs it as a heading and a
|
||||
* body. Split once here rather than restated, so the panel and Piggy's front
|
||||
* door cannot end up describing the ledger in two different ways.
|
||||
*/
|
||||
function firstSentence(line: string): { title: string; description?: string } {
|
||||
const at = line.indexOf('. ');
|
||||
return at === -1
|
||||
? { title: line }
|
||||
: { title: line.slice(0, at), description: line.slice(at + 2) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A meta line: small, muted, wrapping.
|
||||
*
|
||||
* Separated by space rather than by interpunct characters, because these lines
|
||||
* wrap at every width the panel is used at and a dot between items lands at the
|
||||
* start of the next line as often as between two of them.
|
||||
*/
|
||||
function Meta({ parts }: { parts: (ReactNode | null)[] }) {
|
||||
const kept = parts.filter((part): part is ReactNode => part != null && part !== '');
|
||||
if (kept.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
{kept.map((part, index) => (
|
||||
<span key={index}>{part}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A run's reason, tinted by whether anybody has to do something about it.
|
||||
*
|
||||
* `danger` is reserved for a failure a person must resolve. A turn you stopped
|
||||
* yourself, or one that ran into its own step ceiling, is a fact about the turn
|
||||
* — so it reads as an inset note rather than as an alarm. A column in which
|
||||
* every ended turn is red is a column nobody reads.
|
||||
*/
|
||||
function RunReason({ status, error }: { status: string; error: string }) {
|
||||
const alarming = status === 'failed';
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1.5 flex items-start gap-1.5 break-words rounded-md px-2 py-1.5 text-xs leading-relaxed',
|
||||
alarming ? 'bg-danger/10 text-danger' : 'bg-surface-2 text-muted',
|
||||
)}
|
||||
// The whole of it, for an operator who needs the provider's own words.
|
||||
title={error}
|
||||
>
|
||||
{alarming ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden /> : null}
|
||||
<span className="min-w-0">{readableError(error)}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- rows
|
||||
|
||||
/**
|
||||
* Is the thread's name just the run's label again?
|
||||
*
|
||||
* A conversation is titled from its opening question, so on the turn that
|
||||
* started it the two strings are the same one and the row printed it twice —
|
||||
* once as the heading and once, clipped, at the end of the meta line. Compared
|
||||
* by prefix, with the server's own truncation mark stripped, because a title
|
||||
* cut at 120 characters ends in an ellipsis the label it was cut from does not.
|
||||
*/
|
||||
function sameWords(label: string, title: string): boolean {
|
||||
const trim = (value: string) => value.trim().toLowerCase().replace(/(\.\.\.|…)$/, '');
|
||||
const one = trim(label);
|
||||
const other = trim(title);
|
||||
return one.startsWith(other) || other.startsWith(one);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: PiggyRunSummary }) {
|
||||
const duration = formatDuration(run.durationMs);
|
||||
const tokens =
|
||||
run.inputTokens == null && run.outputTokens == null
|
||||
? null
|
||||
: `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Clamped rather than truncated to one line: two lines is enough to
|
||||
tell two similar questions apart, and a turn's whole prompt can be a
|
||||
paragraph that would otherwise own the panel. The full text stays in
|
||||
the title attribute. */}
|
||||
<p
|
||||
className="line-clamp-2 min-w-0 flex-1 break-words text-sm font-medium leading-snug"
|
||||
title={run.label}
|
||||
>
|
||||
{run.label}
|
||||
</p>
|
||||
<RunStatusBadge status={run.status} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
{run.summary ? (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 break-words text-xs leading-relaxed text-muted"
|
||||
title={run.summary}
|
||||
>
|
||||
{run.summary}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{run.error ? <RunReason status={run.status} error={run.error} /> : null}
|
||||
|
||||
<Meta
|
||||
parts={[
|
||||
<span key="when">{relativeTime(run.startedAt)}</span>,
|
||||
duration ? <span key="took" className="nums">{duration}</span> : null,
|
||||
tokens ? <span key="tokens" className="nums">{tokens}</span> : null,
|
||||
run.costMicroCents == null ? null : (
|
||||
<span key="cost" className="nums" title={spendTitle(run.costMicroCents)}>
|
||||
{spendMoney(run.costMicroCents)}
|
||||
</span>
|
||||
),
|
||||
shortModel(run.model),
|
||||
run.kind === 'task' && run.taskKind ? humanise(run.taskKind) : null,
|
||||
// Present only when the run was somebody else's — see the server type.
|
||||
run.principal ? run.principal.name : null,
|
||||
run.conversation && !sameWords(run.label, run.conversation.title) ? (
|
||||
<span
|
||||
key="conversation"
|
||||
// `inline-block` is load-bearing: `max-width` and `overflow` do
|
||||
// nothing on a non-replaced inline box, so the truncation here was
|
||||
// inert and a run whose title is a question with a UUID in it
|
||||
// rendered 624px wide inside a 320px rail — clipped mid-word by
|
||||
// the column rather than ellipsised.
|
||||
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 group-hover/run:underline"
|
||||
title={run.conversation.title}
|
||||
>
|
||||
{run.conversation.title}
|
||||
</span>
|
||||
) : null,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
/*
|
||||
* Only the caller's own conversations resolve to a link — the server refuses
|
||||
* to name anybody else's — so an admin reading the workspace ledger sees the
|
||||
* run without a doorway into a private transcript.
|
||||
*
|
||||
* The whole row is the target, not the thread name at the end of the meta
|
||||
* line. That name was a 14px-tall link at the bottom of a hundred-pixel row,
|
||||
* which is a 44px rule broken by the one control in this panel that goes
|
||||
* anywhere; and the row already reads as a unit, so the visible affordance
|
||||
* was in the wrong place as well as the wrong size.
|
||||
*/
|
||||
return (
|
||||
<li className="border-t border-border first:border-t-0">
|
||||
{run.conversation ? (
|
||||
<Link
|
||||
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
|
||||
aria-label={`Open the conversation “${run.conversation.title}”`}
|
||||
className={cn(
|
||||
'group/run -mx-2 block min-h-11 rounded-md px-2 py-3',
|
||||
'transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="py-3">{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: PiggyTaskSummary }) {
|
||||
const outstanding = task.state === 'queued' || task.state === 'scheduled' || task.state === 'running';
|
||||
return (
|
||||
<li className="border-t border-border py-3 first:border-t-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
|
||||
{humanise(task.kind)}
|
||||
</p>
|
||||
<TaskStateBadge state={task.state} className="shrink-0" />
|
||||
</div>
|
||||
|
||||
{task.reason ? (
|
||||
<p
|
||||
className="mt-1 line-clamp-3 break-words text-xs leading-relaxed text-muted"
|
||||
title={task.reason}
|
||||
>
|
||||
{task.reason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{task.error ? <RunReason status="failed" error={task.error} /> : null}
|
||||
|
||||
<Meta
|
||||
parts={[
|
||||
// A pending task is described by when it may next run; a finished one
|
||||
// by when it finished. Showing `dueAt` for both would render a task
|
||||
// that completed last week as though it were a week overdue.
|
||||
<span key="when" className="nums">
|
||||
{outstanding
|
||||
? `due ${relativeTime(task.dueAt)}`
|
||||
: `finished ${relativeTime(task.finishedAt ?? task.dueAt)}`}
|
||||
</span>,
|
||||
task.attempts > 0 ? (
|
||||
<span key="attempts" className="nums">
|
||||
attempt {task.attempts} of {task.maxAttempts}
|
||||
</span>
|
||||
) : null,
|
||||
<span key="subject" className="nums font-mono" title={task.subject}>
|
||||
{task.subject.slice(0, 8)}
|
||||
</span>,
|
||||
]}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ panel
|
||||
|
||||
export function PiggyActivityPanel({ className }: { className?: string }) {
|
||||
const activity = useQuery({
|
||||
queryKey: ['piggy-activity'],
|
||||
queryFn: () => get<PiggyActivityResponse>('/api/piggy/activity'),
|
||||
/*
|
||||
* Poll faster while something is in flight. A ledger that only updates on
|
||||
* navigation shows a turn as running long after it finished, which is the
|
||||
* one thing an activity view must not do; polling every ten seconds
|
||||
* regardless would be a request a minute from an idle tab for nothing.
|
||||
*/
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.runs.some((run) => run.status === 'running') ? 10_000 : 60_000,
|
||||
/*
|
||||
* One retry, not three. The default backoff leaves the panel showing
|
||||
* loading skeletons for the better part of a minute before it admits the
|
||||
* read failed, and an audit surface that looks like it is still thinking
|
||||
* is worse than one that says it could not read the ledger.
|
||||
*/
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
/*
|
||||
* The ledger opens on a readable number of rows and keeps the rest one click
|
||||
* away. Without this the queue below sits under twenty-five runs, which on a
|
||||
* phone means the pending work — the half of this panel that needs a person —
|
||||
* is off the bottom of a very long scroll.
|
||||
*/
|
||||
const [allRuns, setAllRuns] = useState(false);
|
||||
|
||||
const spend = activity.data?.spend;
|
||||
const runs = activity.data?.runs ?? [];
|
||||
const tasks = activity.data?.tasks ?? [];
|
||||
const shownRuns = allRuns ? runs : runs.slice(0, RUNS_BEFORE_EXPANDING);
|
||||
const average =
|
||||
spend && spend.turns > 0 ? Math.round(spend.monthMicroCents / spend.turns) : null;
|
||||
const spendDigits = spend
|
||||
? sharedDecimals(spend.todayMicroCents, spend.monthMicroCents)
|
||||
: 2;
|
||||
|
||||
const runsEmpty = firstSentence(piggyLine(piggyCopy.activityEmpty));
|
||||
|
||||
/*
|
||||
* Not a landmark. The workspace already wraps this in an `<aside>` called
|
||||
* "Piggy activity" on a wide screen, and in a Sheet titled "Activity" on a
|
||||
* phone, so a complementary landmark of the same name nested inside it gave a
|
||||
* screen-reader user two doors into one panel.
|
||||
*/
|
||||
return (
|
||||
<div className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}>
|
||||
{/* First, not last: a ledger that could not be read must say so before it
|
||||
shows anything that looks like a figure. */}
|
||||
{activity.isError ? (
|
||||
<div className="card order-1 min-w-0 p-4 sm:p-5">
|
||||
<p className="flex items-start gap-2 text-sm text-danger">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 break-words">
|
||||
{activity.error instanceof Error
|
||||
? activity.error.message
|
||||
: 'The activity ledger could not be read.'}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => void activity.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
Spend sits under the runs on a narrow screen. What people open this
|
||||
panel for is what Piggy just did; the month's spend is a figure they
|
||||
check occasionally and it was pushing the first run below the fold of a
|
||||
393px sheet. On a wide screen the rail is tall enough for both, and the
|
||||
figure reads better at the top of the column.
|
||||
*/}
|
||||
<Section
|
||||
title="Spend"
|
||||
level={3}
|
||||
action={<span className="text-xs text-muted">US dollars</span>}
|
||||
className="card order-3 p-4 sm:order-2 sm:p-5"
|
||||
>
|
||||
{/*
|
||||
Nothing here falls back to zero. A figure the panel could not read is
|
||||
an em dash, never `$0.00`: on a spend surface those two are opposite
|
||||
claims, and only one of them is true.
|
||||
*/}
|
||||
{spend || activity.isError ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SpendFigure
|
||||
label="Today"
|
||||
microCents={spend ? spend.todayMicroCents : null}
|
||||
digits={spendDigits}
|
||||
/>
|
||||
<SpendFigure
|
||||
label="This month"
|
||||
microCents={spend ? spend.monthMicroCents : null}
|
||||
digits={spendDigits}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{spend ? (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted">
|
||||
{spend.turns > 0 ? (
|
||||
<>
|
||||
<span className="nums">{EXACT.format(spend.turns)}</span> turns this month,
|
||||
averaging{' '}
|
||||
<span className="nums" title={spendTitle(average)}>
|
||||
{spendMoney(average)}
|
||||
</span>{' '}
|
||||
each. Billed in micro-cents — millionths of a cent — and converted here.
|
||||
</>
|
||||
) : (
|
||||
'No turns have been billed this month. Every question you ask Piggy is priced per token and lands here.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Recent runs"
|
||||
level={3}
|
||||
count={activity.data ? runs.length : undefined}
|
||||
collapsible
|
||||
className="card order-2 p-4 sm:order-3 sm:p-5"
|
||||
>
|
||||
{/*
|
||||
`activity.data`, not `isPending`: after a failed read the query is
|
||||
neither pending nor holding rows, and keying the empty state off
|
||||
pending would announce "nothing has run yet" about a ledger nobody
|
||||
managed to open.
|
||||
*/}
|
||||
{!activity.data ? (
|
||||
activity.isError ? (
|
||||
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
)
|
||||
) : runs.length === 0 ? (
|
||||
<EmptyState size="inline" title={runsEmpty.title} description={runsEmpty.description} />
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col">
|
||||
{shownRuns.map((run) => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</ul>
|
||||
{runs.length > RUNS_BEFORE_EXPANDING ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full"
|
||||
onClick={() => setAllRuns((was) => !was)}
|
||||
>
|
||||
{allRuns ? 'Show fewer' : `Show all ${runs.length} runs`}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Queue"
|
||||
level={3}
|
||||
count={activity.data ? tasks.length : undefined}
|
||||
collapsible
|
||||
className="card order-4 p-4 sm:p-5"
|
||||
>
|
||||
{!activity.data ? (
|
||||
activity.isError ? (
|
||||
<EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
)
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState
|
||||
size="inline"
|
||||
title="No background work is queued"
|
||||
description="Enrichment, renewal watches and supplier research are written here as tasks before Piggy runs them, and stay with their result afterwards."
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One spend figure, with its exact value where a doubter can find it.
|
||||
*
|
||||
* The `title` carries the raw micro-cent integer, which is the audit trail: the
|
||||
* rendered figure is a rounding of a number stored in millionths of a cent, and
|
||||
* a money surface that cannot be checked against its own source is a claim
|
||||
* rather than a record.
|
||||
*/
|
||||
function SpendFigure({
|
||||
label,
|
||||
microCents,
|
||||
digits,
|
||||
}: {
|
||||
label: string;
|
||||
microCents: number | null;
|
||||
digits: number;
|
||||
}) {
|
||||
const text = spendMoney(microCents, digits);
|
||||
return (
|
||||
<Stat
|
||||
size="md"
|
||||
surface="bare"
|
||||
label={label}
|
||||
value={microCents == null ? text : <span title={spendTitle(microCents)}>{text}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* The moment a person decides whether an agent may change the company's records.
|
||||
*
|
||||
* Everything else in the Piggy workspace is reversible or read-only; this card
|
||||
* is not. So it is built around three refusals:
|
||||
*
|
||||
* it never claims more than it knows — only an `approval_resolved` event moves
|
||||
* a card to `applied`, so `submitting` is drawn as its own state rather than
|
||||
* as an optimistic tick that would have to be taken back;
|
||||
* it never invites a press by accident — nothing here is autofocused, the
|
||||
* actions sit below the evidence rather than under the reader's thumb, and a
|
||||
* held Enter cannot fire Apply twice;
|
||||
* it never shows a change without its context — where a field has a
|
||||
* `previous`, both values are on screen, because "Move Aurelian to legal"
|
||||
* means nothing to someone who cannot see where Aurelian was.
|
||||
*
|
||||
* The five states come from `ApprovalStep` in lib/piggy-chat, which owns the
|
||||
* transitions. This file renders them and reports a decision; it decides nothing
|
||||
* about the change itself.
|
||||
*
|
||||
* Colour follows the product's rule rather than this card's own instincts: the
|
||||
* only state drawn in a status colour is the one that has stopped and is waiting
|
||||
* for a person. A settled card — applied, rejected — returns to the ordinary
|
||||
* border, and the confirmation keeps exactly one positive mark. A transcript in
|
||||
* which every approved write is a green block is a transcript where the one card
|
||||
* that still needs answering is invisible.
|
||||
*/
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { ArrowRight, CheckCircle2, Loader2, ShieldAlert, TriangleAlert, XCircle } from 'lucide-react';
|
||||
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
|
||||
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
|
||||
import { ApprovalStateBadge } from '@/components/status';
|
||||
import { RecordLink, recordHref } from '@/components/RecordLink';
|
||||
import { Button, Card, Label, cn } from '@/components/ui';
|
||||
import { Disclosure } from '@/components/ui/disclosure';
|
||||
|
||||
export type PiggyApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
|
||||
|
||||
// --------------------------------------------------------------------- card
|
||||
|
||||
export function PiggyApprovalCard({
|
||||
change,
|
||||
state,
|
||||
error,
|
||||
onDecide,
|
||||
}: {
|
||||
change: PiggyProposedChange;
|
||||
state: PiggyApprovalState;
|
||||
error?: string;
|
||||
onDecide: (decision: PiggyApprovalDecision) => void;
|
||||
}) {
|
||||
const headingId = useId();
|
||||
|
||||
/**
|
||||
* Which answer is in flight.
|
||||
*
|
||||
* The contract hands this card a state, not a decision, so `submitting` alone
|
||||
* cannot say whether the user pressed Apply or Reject — and "Sending your
|
||||
* decision" is a poor thing to read when you have just authorised a write to a
|
||||
* customer record. Holding it locally also guards the double press: the parent
|
||||
* moves to `submitting` on the same tick, but a second click dispatched before
|
||||
* React re-renders would post the decision twice, and applying a change twice
|
||||
* logs two activities on someone's account.
|
||||
*/
|
||||
const [choice, setChoice] = useState<PiggyApprovalDecision | null>(null);
|
||||
|
||||
// Cleared whenever the card is answerable again — a POST that never reached
|
||||
// the relay puts the state back to `pending`, and a stale "Applying" label on
|
||||
// a card that is waiting for a decision would be a lie about a write.
|
||||
useEffect(() => {
|
||||
if (state === 'pending' || state === 'failed') setChoice(null);
|
||||
}, [state]);
|
||||
|
||||
/**
|
||||
* Whether a decision has already been dispatched from this render.
|
||||
*
|
||||
* The buttons are disabled the moment the parent moves the card to
|
||||
* `submitting`, which it does synchronously inside `onDecide` — but that is
|
||||
* one render away, and two clicks (or a click and a synthesised one) in the
|
||||
* same tick would both get through and post the decision twice. Applying twice
|
||||
* logs two calls on someone's account. Reset after every commit rather than
|
||||
* only on a state change, so a parent that answers with an error instead of a
|
||||
* new state leaves the buttons usable rather than dead.
|
||||
*/
|
||||
const dispatched = useRef(false);
|
||||
useEffect(() => {
|
||||
dispatched.current = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Where the keyboard goes when the buttons stop existing.
|
||||
*
|
||||
* Applying removes the pair the user just pressed, and focus falls to
|
||||
* `<body>` — 121 Tab presses from the transcript on /piggy, and the outcome
|
||||
* of the write announced to nobody in particular. Focus moves here instead,
|
||||
* onto the live region that says what happened, so the answer to "did it
|
||||
* land?" is both spoken and one Tab from the record link that proves it.
|
||||
*/
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
const decided = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!decided.current || state === 'pending') return;
|
||||
decided.current = false;
|
||||
statusRef.current?.focus();
|
||||
}, [state]);
|
||||
|
||||
const answerable = state === 'pending' || state === 'failed';
|
||||
|
||||
const decide = (decision: PiggyApprovalDecision) => {
|
||||
if (!answerable || dispatched.current) return;
|
||||
dispatched.current = true;
|
||||
decided.current = true;
|
||||
setChoice(decision);
|
||||
onDecide(decision);
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-repeat must not decide anything.
|
||||
*
|
||||
* Holding Enter on a focused button fires a click per repeat, and this is the
|
||||
* one control in PIG where the second one is a duplicate write rather than a
|
||||
* duplicate render. The first press still works; only the repeats are dropped.
|
||||
*/
|
||||
const swallowRepeat = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.repeat) event.preventDefault();
|
||||
};
|
||||
|
||||
const record = change.record;
|
||||
const linkable = Boolean(record && recordHref(record.type, record.id));
|
||||
// The note explains why a card appeared at all, so it retires once the change
|
||||
// is settled and the question is no longer live.
|
||||
const showForcedNote = Boolean(change.forcedConfirm) && state !== 'applied' && state !== 'rejected';
|
||||
const showActions = state === 'pending' || state === 'submitting' || state === 'failed';
|
||||
// An applied card carries its record inside the confirmation sentence, so the
|
||||
// footer link would be the same destination twice in two consecutive rows.
|
||||
const showFooterLink = linkable && state !== 'applied';
|
||||
const hasFooterRow = showActions || showFooterLink;
|
||||
|
||||
return (
|
||||
<Card
|
||||
// A group rather than a region: a turn can propose several writes, and a
|
||||
// transcript full of landmarks makes the landmark list useless.
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
className={cn(
|
||||
'w-full overflow-hidden',
|
||||
// Warning is spent on the one state that has stopped and is waiting for
|
||||
// a person; `submitting` keeps it because the question is still open
|
||||
// until the relay answers, and a border that changes twice in a second
|
||||
// reads as a flicker rather than as progress.
|
||||
state === 'pending' || state === 'submitting'
|
||||
? 'border-warning/50'
|
||||
: state === 'rejected'
|
||||
? 'border-border bg-surface-2/40'
|
||||
: 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2 p-4 sm:p-5">
|
||||
<StateIcon state={state} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label>{piggyToolLabel(change.tool)}</Label>
|
||||
{/* The summary is the headline: everything below it is evidence for
|
||||
this one sentence, so it is the only thing set at full weight. */}
|
||||
<h4 id={headingId} className="mt-1 break-words text-sm font-semibold leading-snug">
|
||||
{change.summary}
|
||||
</h4>
|
||||
</div>
|
||||
<ApprovalStateBadge state={state} decision={choice} />
|
||||
</div>
|
||||
|
||||
{showForcedNote ? <ForcedConfirmNote kind={change.kind} /> : null}
|
||||
|
||||
{change.fields.length === 0 ? null : state === 'rejected' ? (
|
||||
/*
|
||||
A rejected change is history, and history the user has already
|
||||
declined. Folding the evidence away keeps a long transcript readable
|
||||
while leaving it recoverable — deleting it outright would remove the
|
||||
only record of what was declined, which is exactly what an audit asks
|
||||
for.
|
||||
*/
|
||||
<Disclosure
|
||||
summary="What was proposed"
|
||||
className="border-t border-border"
|
||||
summaryClassName="px-4 text-xs font-normal text-muted sm:px-5"
|
||||
>
|
||||
<FieldList fields={change.fields} settled />
|
||||
</Disclosure>
|
||||
) : (
|
||||
<div className="border-t border-border">
|
||||
<FieldList fields={change.fields} settled={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col border-t border-border p-4 sm:p-5">
|
||||
{/*
|
||||
One live region, mounted for the life of the card and never empty: a
|
||||
status element that appears at the same moment as its text is
|
||||
announced unreliably, and this is exactly the transition — pending to
|
||||
applied — that a screen-reader user must not miss. The spacing hangs
|
||||
off the child rather than off a `gap` so that a settled card, whose
|
||||
footer holds nothing else, does not end in a band of dead space.
|
||||
|
||||
`tabIndex={-1}` is the target of the focus move above; it is never in
|
||||
the tab order.
|
||||
*/}
|
||||
<div
|
||||
ref={statusRef}
|
||||
tabIndex={-1}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn('min-w-0 outline-none', hasFooterRow && '[&>*]:mb-3')}
|
||||
>
|
||||
{/*
|
||||
An error is the status. Left to the generic line as well, a failed
|
||||
card stated the same fact three times — the badge, "The change was
|
||||
not applied", and the reason — and a card that repeats itself reads
|
||||
as a card that is guessing.
|
||||
*/}
|
||||
{error ? (
|
||||
<p className="flex items-start gap-2 rounded-md bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 break-words">{error}</span>
|
||||
</p>
|
||||
) : (
|
||||
<StatusLine state={state} choice={choice} record={record} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The record and the decision share a row: the link is the one thing a
|
||||
reader might want *before* answering — open the account, check the
|
||||
note is not already there — and putting it beside the buttons keeps
|
||||
the footer to a single line on a phone. It wraps above them when the
|
||||
dock is too narrow for both.
|
||||
*/}
|
||||
{hasFooterRow ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{showFooterLink && record ? (
|
||||
<RecordLink
|
||||
type={record.type}
|
||||
id={record.id}
|
||||
label={record.label ?? record.id}
|
||||
verb={answerable || state === 'submitting' ? 'Check' : 'Open'}
|
||||
// A new tab, always, on this card. The escape hatch was
|
||||
// destroying the proposal it exists to help verify: a same-tab
|
||||
// navigation unmounts the transcript and takes the pending card
|
||||
// with it, so the reader came back to no question at all.
|
||||
newTab
|
||||
// `mr-auto` rather than `justify-between` on the row: when the
|
||||
// pair of buttons wraps to its own line in a narrow dock, the
|
||||
// row must still hold them at the right edge, and `between`
|
||||
// would strand a lone wrapped item at the left.
|
||||
className="mr-auto"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showActions ? (
|
||||
/*
|
||||
The decision sits last, after the evidence, and never under the
|
||||
reader's eye while they are still reading the diff. Reject comes
|
||||
first so the hand travelling rightwards ends on the deliberate
|
||||
action rather than passing over it, and Apply carries the only
|
||||
filled treatment on the card. Nothing is autofocused: the card
|
||||
arrives mid-stream, and a button that grabs focus while someone is
|
||||
typing turns their next Enter into a write.
|
||||
|
||||
Full width below `sm` so the two buttons split a phone row evenly,
|
||||
content width above it so they sit as a pair at the right of the
|
||||
footer — and so that in a 22rem dock the pair wraps to its own line
|
||||
intact rather than stacking one button above the other.
|
||||
*/
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!answerable}
|
||||
onKeyDown={swallowRepeat}
|
||||
onClick={() => decide('reject')}
|
||||
className="min-w-[6rem] flex-1 sm:flex-none"
|
||||
>
|
||||
{state === 'submitting' && choice === 'reject' ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!answerable}
|
||||
onKeyDown={swallowRepeat}
|
||||
onClick={() => decide('apply')}
|
||||
className="min-w-[6rem] flex-1 sm:flex-none"
|
||||
>
|
||||
{state === 'submitting' && choice === 'apply' ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{state === 'failed' ? 'Try again' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- pieces
|
||||
|
||||
/**
|
||||
* The card's mark, coloured only while the card is waiting.
|
||||
*
|
||||
* A settled outcome is a process fact, and process facts get no colour here —
|
||||
* the single positive mark this card is allowed to spend belongs on the
|
||||
* confirmation sentence, next to the record the change landed on.
|
||||
*/
|
||||
function StateIcon({ state }: { state: PiggyApprovalState }) {
|
||||
const className = 'mt-0.5 size-4 shrink-0';
|
||||
if (state === 'applied') {
|
||||
return <CheckCircle2 className={cn(className, 'text-muted')} aria-hidden />;
|
||||
}
|
||||
if (state === 'rejected') return <XCircle className={cn(className, 'text-muted')} aria-hidden />;
|
||||
if (state === 'failed') {
|
||||
return <TriangleAlert className={cn(className, 'text-danger')} aria-hidden />;
|
||||
}
|
||||
return <ShieldAlert className={cn(className, 'text-warning')} aria-hidden />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a card appeared in a mode that promised not to ask.
|
||||
*
|
||||
* Without this the user reads a stopped write as a broken mode and turns the
|
||||
* guardrail off. The four guarded kinds are the ones that move money or make a
|
||||
* promise to a counterparty, so the note names the kind rather than reciting the
|
||||
* policy.
|
||||
*/
|
||||
function ForcedConfirmNote({ kind }: { kind: string }) {
|
||||
return (
|
||||
// The bottom margin matches the header's own padding above it: without it
|
||||
// the note sat flush against the divider under it and read as part of the
|
||||
// diff rather than as a note about why the card exists.
|
||||
<p className="mx-4 mb-4 flex items-start gap-2 rounded-md bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-5 sm:mb-5">
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 break-words">
|
||||
Auto mode stopped here on purpose. A {kindNoun(kind)} change always needs a person, whatever
|
||||
the mode is set to.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function kindNoun(kind: string): string {
|
||||
return kind.replaceAll('_', ' ').trim() || 'guarded';
|
||||
}
|
||||
|
||||
function StatusLine({
|
||||
state,
|
||||
choice,
|
||||
record,
|
||||
}: {
|
||||
state: PiggyApprovalState;
|
||||
choice: PiggyApprovalDecision | null;
|
||||
record?: PiggyProposedChange['record'];
|
||||
}) {
|
||||
// Pending says the thing the reader most needs to be sure of, and says it in
|
||||
// the live region so that the card's arrival is heard rather than merely
|
||||
// drawn. The badge says a person is needed; this says what has happened so
|
||||
// far, which is nothing.
|
||||
if (state === 'pending') {
|
||||
return <p className="text-xs text-muted">Nothing has changed yet. Piggy is waiting for your answer.</p>;
|
||||
}
|
||||
|
||||
if (state === 'submitting') {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||
{choice === 'reject' ? 'Rejecting the change…' : 'Applying the change to PIG…'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'applied') {
|
||||
// The one positive mark on the card, spent here rather than on the border
|
||||
// or the badge, and spent beside the record so the confirmation and the
|
||||
// proof of it are the same sentence.
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 text-xs text-fg">
|
||||
<CheckCircle2 className="size-3.5 shrink-0 text-positive" aria-hidden />
|
||||
{/* The trailing space is for the announcement, not the layout: the flex
|
||||
gap separates the words on screen, and without it a screen reader
|
||||
reads "Applied to PIG onDEMO — Northwind Robotics". */}
|
||||
<span>Applied to PIG{record?.label ? ' on ' : '.'}</span>
|
||||
{record ? (
|
||||
<RecordLink type={record.type} id={record.id} label={record.label ?? record.id} newTab />
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'rejected') {
|
||||
return <p className="text-xs text-muted">Rejected. Nothing was changed.</p>;
|
||||
}
|
||||
|
||||
// `failed` covers both a write PIG refused and a turn that ended before the
|
||||
// decision could be delivered. Reached only when no reason came with it — the
|
||||
// reason replaces this line when there is one.
|
||||
return <p className="text-xs text-danger">The change was not applied.</p>;
|
||||
}
|
||||
|
||||
function FieldList({
|
||||
fields,
|
||||
settled,
|
||||
}: {
|
||||
fields: PiggyProposedChange['fields'];
|
||||
settled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<dl className="flex flex-col gap-2 p-4 sm:p-5">
|
||||
{fields.map((field, index) => (
|
||||
// Keyed by position as well as label: nothing stops a tool proposing two
|
||||
// rows with the same label, and a duplicate key drops one of them.
|
||||
<FieldRow key={`${index}:${field.label}`} field={field} settled={settled} />
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One field, with its old value where there is one.
|
||||
*
|
||||
* A diff without the before is not a diff, and this is the moment where the old
|
||||
* value matters most: "Stage: Legal" is agreeable to anybody, "Stage: Discovery
|
||||
* → Legal" is the thing you either recognise or stop. `del`/`ins` carry the
|
||||
* before and after semantically, with the words spelled out for readers whose
|
||||
* software announces neither.
|
||||
*/
|
||||
function FieldRow({
|
||||
field,
|
||||
settled,
|
||||
}: {
|
||||
field: PiggyProposedChange['fields'][number];
|
||||
settled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Label as="dt">{field.label}</Label>
|
||||
<dd className="mt-1 min-w-0">
|
||||
{field.previous === undefined ? (
|
||||
<span
|
||||
className={cn('block break-words text-sm leading-5', settled ? 'text-muted' : 'text-fg')}
|
||||
>
|
||||
{field.value}
|
||||
</span>
|
||||
) : (
|
||||
// Wraps rather than truncates: a stage name is short, a reason is a
|
||||
// sentence, and the 22rem dock has to hold both without a scrollbar.
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<del className="min-w-0 break-words text-sm leading-5 text-muted decoration-muted/70">
|
||||
<span className="sr-only">Was: </span>
|
||||
{field.previous}
|
||||
</del>
|
||||
<ArrowRight className="size-3.5 shrink-0 self-center text-muted" aria-hidden />
|
||||
<ins
|
||||
className={cn(
|
||||
'min-w-0 break-words text-sm font-medium leading-5 no-underline',
|
||||
settled ? 'text-muted' : 'text-fg',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Becomes: </span>
|
||||
{field.value}
|
||||
</ins>
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
/**
|
||||
* Piggy's history rail: every conversation this person has had, newest first.
|
||||
*
|
||||
* Four decisions here are worth stating, because each replaces something more
|
||||
* obvious that would have been wrong.
|
||||
*
|
||||
* **A thread with nothing in it is not shown.** See `conversations` below: a
|
||||
* row exists from the moment New is pressed, so the rail was mostly abandoned
|
||||
* drafts sharing one derived title.
|
||||
*
|
||||
* **Recency buckets, not a flat list.** History is scanned, not read — the
|
||||
* question is "where was that thing I asked on Tuesday", and a wall of relative
|
||||
* timestamps answers it one row at a time. Today / Yesterday / This week /
|
||||
* Earlier is how people already hold the week in their heads, and the headers
|
||||
* stick so the answer stays on screen while the list scrolls under it.
|
||||
*
|
||||
* **`running` is a prop, never a field this component fetches.** The server
|
||||
* does not persist "a turn is in flight" and should not: it is live state
|
||||
* belonging to the open stream, and a flag in Postgres would survive a crashed
|
||||
* relay and mark a thread busy forever. `PiggyConversationSummary.running` is
|
||||
* honoured if a future endpoint ever sets it, but the workspace's own
|
||||
* `runningId` is the source of truth.
|
||||
*
|
||||
* **No `window.confirm` for the delete.** It blocks the event loop, so an
|
||||
* answer still streaming into another conversation stalls behind a modal the
|
||||
* browser drew, and it cannot name the thread being destroyed in a way anyone
|
||||
* would read. `AlertDialog` does both, and refuses to be dismissed by a click
|
||||
* landing somewhere else.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { PiggyConversationSummary } from '@pig/core';
|
||||
import { api, get, patch, post, shortDate } from '@/lib/api';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { Button, EmptyState, Input, Label, Skeleton, cn } from '@/components/ui';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
/** One key for the whole history, so every mutation invalidates the same list. */
|
||||
const CONVERSATIONS_KEY = ['piggy', 'conversations'] as const;
|
||||
|
||||
/**
|
||||
* Mirrors `PIGGY_TITLE_MAX` on the server, which truncates silently rather than
|
||||
* refusing. Enforcing it in the input means the title the user reads back is the
|
||||
* title that was stored, instead of one that lost its last few words on save.
|
||||
*/
|
||||
const TITLE_MAX = 120;
|
||||
|
||||
/** A stable empty array, so `conversations` does not change identity per render. */
|
||||
const NO_CONVERSATIONS: PiggyConversationSummary[] = [];
|
||||
|
||||
const TIME_OF_DAY = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
const WEEKDAY = new Intl.DateTimeFormat('en-US', { weekday: 'short' });
|
||||
|
||||
// ------------------------------------------------------------------ the data
|
||||
|
||||
function useConversationsQuery() {
|
||||
return useQuery({
|
||||
queryKey: CONVERSATIONS_KEY,
|
||||
queryFn: () => get<PiggyConversationSummary[]>('/api/piggy/conversations'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The list plus the three writes that change it.
|
||||
*
|
||||
* Rename and delete are optimistic. Not for the milliseconds — the endpoint is
|
||||
* fast — but because both are direct manipulations of a row the user is looking
|
||||
* at: a title that stays wrong until a refetch lands reads as the rename having
|
||||
* failed, and people press it again.
|
||||
*/
|
||||
function useConversationMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const settle = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => post<{ id: string }>('/api/piggy/conversations', {}),
|
||||
onSuccess: settle,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: ({ id, title }: { id: string; title: string }) =>
|
||||
patch<{ id: string }>(`/api/piggy/conversations/${id}`, { title }),
|
||||
onMutate: async ({ id, title }) => {
|
||||
// Without the cancel, a refetch already in flight can land after the
|
||||
// optimistic write and paint the old title back over the new one.
|
||||
await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
|
||||
queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
|
||||
current?.map((entry) => (entry.id === id ? { ...entry, title } : entry)),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
|
||||
toast.error(error instanceof Error ? error.message : 'The rename did not save.');
|
||||
},
|
||||
onSettled: settle,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api<{ id: string; deleted: boolean }>(`/api/piggy/conversations/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: CONVERSATIONS_KEY });
|
||||
const previous = queryClient.getQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY);
|
||||
queryClient.setQueryData<PiggyConversationSummary[]>(CONVERSATIONS_KEY, (current) =>
|
||||
current?.filter((entry) => entry.id !== id),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, _id, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(CONVERSATIONS_KEY, context.previous);
|
||||
toast.error(error instanceof Error ? error.message : 'The conversation was not deleted.');
|
||||
},
|
||||
onSettled: settle,
|
||||
});
|
||||
|
||||
return { create, rename, remove };
|
||||
}
|
||||
|
||||
export function usePiggyConversations(): {
|
||||
conversations: PiggyConversationSummary[];
|
||||
isLoading: boolean;
|
||||
create: () => Promise<string>;
|
||||
rename: (id: string, title: string) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
} {
|
||||
const query = useConversationsQuery();
|
||||
const { create, rename, remove } = useConversationMutations();
|
||||
|
||||
const createConversation = useCallback(async () => {
|
||||
const created = await create.mutateAsync();
|
||||
return created.id;
|
||||
}, [create]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
await rename.mutateAsync({ id, title });
|
||||
},
|
||||
[rename],
|
||||
);
|
||||
|
||||
const removeConversation = useCallback(
|
||||
async (id: string) => {
|
||||
await remove.mutateAsync(id);
|
||||
},
|
||||
[remove],
|
||||
);
|
||||
|
||||
return {
|
||||
conversations: query.data ?? NO_CONVERSATIONS,
|
||||
// `isPending` rather than `isFetching`: this is "there is nothing to draw
|
||||
// yet", so a background refresh does not flash the skeletons back in.
|
||||
isLoading: query.isPending,
|
||||
create: createConversation,
|
||||
rename: renameConversation,
|
||||
remove: removeConversation,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the grouping
|
||||
|
||||
type Bucket = 'today' | 'yesterday' | 'week' | 'earlier';
|
||||
|
||||
const BUCKET_LABELS: Record<Bucket, string> = {
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
week: 'This week',
|
||||
earlier: 'Earlier',
|
||||
};
|
||||
|
||||
const BUCKET_ORDER: readonly Bucket[] = ['today', 'yesterday', 'week', 'earlier'];
|
||||
|
||||
interface ConversationGroup {
|
||||
bucket: Bucket;
|
||||
label: string;
|
||||
items: PiggyConversationSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets are computed from local midnights stepped with `setDate`, not from
|
||||
* subtracting 86,400,000 milliseconds: on the two days a year the clocks move,
|
||||
* a fixed-millisecond day puts 23:30 yesterday into "Today".
|
||||
*/
|
||||
function groupConversations(
|
||||
conversations: readonly PiggyConversationSummary[],
|
||||
now: number,
|
||||
): ConversationGroup[] {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(weekStart.getDate() - 6);
|
||||
|
||||
const buckets: Record<Bucket, PiggyConversationSummary[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
week: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
// Sorted here as well as by the endpoint. The order is the product promise —
|
||||
// "your last thread is the top row" — and it should not depend on a query
|
||||
// plan in another process staying the way it is today.
|
||||
const sorted = [...conversations].sort(
|
||||
(left, right) => timestamp(right.updatedAt) - timestamp(left.updatedAt),
|
||||
);
|
||||
|
||||
for (const entry of sorted) {
|
||||
const at = timestamp(entry.updatedAt);
|
||||
if (at >= today.getTime()) buckets.today.push(entry);
|
||||
else if (at >= yesterday.getTime()) buckets.yesterday.push(entry);
|
||||
else if (at >= weekStart.getTime()) buckets.week.push(entry);
|
||||
else buckets.earlier.push(entry);
|
||||
}
|
||||
|
||||
return BUCKET_ORDER.filter((bucket) => buckets[bucket].length > 0).map((bucket) => ({
|
||||
bucket,
|
||||
label: BUCKET_LABELS[bucket],
|
||||
items: buckets[bucket],
|
||||
}));
|
||||
}
|
||||
|
||||
/** An unparseable date sorts to the bottom rather than throwing the whole list away. */
|
||||
function timestamp(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The time a row shows, chosen so it never repeats the header above it.
|
||||
*
|
||||
* A relative stamp would: under "Yesterday", every single row says "yesterday".
|
||||
* Within a day the useful detail is the hour; within a week, which day; beyond
|
||||
* that, the date.
|
||||
*/
|
||||
function formatWhen(bucket: Bucket, value: string): string {
|
||||
const at = timestamp(value);
|
||||
if (!at) return '';
|
||||
const date = new Date(at);
|
||||
if (bucket === 'today' || bucket === 'yesterday') return TIME_OF_DAY.format(date);
|
||||
if (bucket === 'week') return WEEKDAY.format(date);
|
||||
return shortDate(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's name, short enough to be read as a question.
|
||||
*
|
||||
* Titles are derived from the opening prompt and run to the server's
|
||||
* 120-character cap, so a confirmation headed with one in full is three lines
|
||||
* of somebody's own question with "?" stuck on the end — a heading nobody
|
||||
* finishes before they decide. Cut at a word boundary; the row behind the
|
||||
* dialog still carries the whole of it.
|
||||
*/
|
||||
function shortTitle(title: string, max = 64): string {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed.length <= max) return trimmed;
|
||||
const cut = trimmed.slice(0, max);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
const kept = space > max * 0.6 ? cut.slice(0, space) : cut;
|
||||
return `${kept.replace(/[\s.,;:—–-]+$/, '')}…`;
|
||||
}
|
||||
|
||||
/** The letter the collapsed rail shows. Punctuation and emoji are skipped. */
|
||||
function railInitial(title: string): string {
|
||||
const letter = title.match(/[\p{L}\p{N}]/u);
|
||||
return letter ? letter[0].toUpperCase() : '·';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- the component
|
||||
|
||||
export interface PiggyConversationListProps {
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
/** Icon rail for narrow desktop. Ignored on a phone — see the component. */
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* The conversation with a turn in flight, if any.
|
||||
*
|
||||
* Live state the workspace owns; nothing here fetches it. Pass
|
||||
* `running ? conversationId ?? null : null` from `usePiggyConversation`.
|
||||
*/
|
||||
runningId?: string | null;
|
||||
}
|
||||
|
||||
export function PiggyConversationList({
|
||||
activeId,
|
||||
onSelect,
|
||||
onNew,
|
||||
collapsed = false,
|
||||
runningId = null,
|
||||
}: PiggyConversationListProps): JSX.Element {
|
||||
const query = useConversationsQuery();
|
||||
const { rename, remove } = useConversationMutations();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<PiggyConversationSummary | null>(null);
|
||||
|
||||
/**
|
||||
* The dialog animates out over 240ms, and it is still on screen for all of
|
||||
* them. Reading `pendingDelete` directly meant the heading became `Delete
|
||||
* “”?` the instant either button was pressed — the confirmation forgetting
|
||||
* what it had just asked about, in front of the person who answered it.
|
||||
*/
|
||||
const lastPendingDelete = useRef<PiggyConversationSummary | null>(null);
|
||||
if (pendingDelete) lastPendingDelete.current = pendingDelete;
|
||||
const deleting = pendingDelete ?? lastPendingDelete.current;
|
||||
|
||||
/**
|
||||
* The ⋯ button the confirmation was opened from.
|
||||
*
|
||||
* The overlay primitive restores focus to whatever held it when the dialog
|
||||
* mounted, which here is the dropdown menu — a node that has been removed
|
||||
* from the document by the time anyone answers. So the row hands over its own
|
||||
* button and the dialog is told explicitly where to go back to; without it,
|
||||
* Escape dropped a keyboard user on `<body>`, a hundred-odd tab stops from
|
||||
* the row they were working on.
|
||||
*/
|
||||
const deleteOpener = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
/** Where focus goes when the row it came from no longer exists. */
|
||||
const newConversationRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
/**
|
||||
* Threads with nothing said in them are not history.
|
||||
*
|
||||
* A conversation row is created the moment somebody presses New, and again
|
||||
* whenever a turn is refused before a word is stored — so the demo book holds
|
||||
* 58 empty threads against 14 real ones, and the rail people scan to find
|
||||
* Tuesday's question is four-fifths abandoned drafts with the same derived
|
||||
* title. Filtered here rather than on the server because the rows are real
|
||||
* and something else may legitimately want them; this is a reading decision.
|
||||
*
|
||||
* The open thread is always kept. A conversation created a second ago has no
|
||||
* messages yet, and it must not disappear from under the person typing in it.
|
||||
*/
|
||||
const conversations = useMemo(() => {
|
||||
const all = query.data ?? NO_CONVERSATIONS;
|
||||
const said = all.filter((entry) => entry.messageCount > 0 || entry.id === activeId);
|
||||
return said.length === all.length ? all : said;
|
||||
}, [activeId, query.data]);
|
||||
|
||||
/**
|
||||
* Recomputed when the list changes rather than on a timer. The boundary only
|
||||
* matters at midnight, and a component that re-rendered every minute to catch
|
||||
* it would cost more than the one row that would briefly sit under the wrong
|
||||
* header until the next fetch.
|
||||
*/
|
||||
const groups = useMemo(() => groupConversations(conversations, Date.now()), [conversations]);
|
||||
|
||||
/**
|
||||
* A rail is a compromise for a screen that has width to spare but not enough.
|
||||
* A phone has neither, and 60px of initials taken off a 393px column would
|
||||
* leave the chat unusable — so on a phone this ignores `collapsed` entirely
|
||||
* and renders in full, expecting to be inside the Sheet the workspace opens.
|
||||
*/
|
||||
const rail = collapsed && !isMobile;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
setRenamingId(null);
|
||||
onSelect(id);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
const target = pendingDelete;
|
||||
if (!target) return;
|
||||
setPendingDelete(null);
|
||||
try {
|
||||
await remove.mutateAsync(target.id);
|
||||
toast.success('Conversation deleted');
|
||||
// Deleting the thread you are reading has to leave you somewhere. A fresh
|
||||
// conversation is the only destination that is certainly still there.
|
||||
if (target.id === activeId) onNew();
|
||||
} catch {
|
||||
/* Reported by the mutation's onError, which also rolls the row back. */
|
||||
}
|
||||
}, [activeId, onNew, pendingDelete, remove]);
|
||||
|
||||
const body = query.isPending ? (
|
||||
<ListSkeleton rail={rail} />
|
||||
) : query.isError ? (
|
||||
<ListError rail={rail} message={query.error.message} onRetry={() => void query.refetch()} />
|
||||
) : conversations.length === 0 ? (
|
||||
rail ? null : (
|
||||
// No button here. There is already one directly above it, highlighted
|
||||
// because nothing is selected, and two identical calls to action a
|
||||
// centimetre apart read as a mistake rather than an invitation.
|
||||
<EmptyState
|
||||
icon={<MessageSquarePlus className="size-7" aria-hidden />}
|
||||
title="Ask Piggy your first question"
|
||||
description="Piggy reads the book — accounts, deals, contracts, utilisation — and can draft the follow-up. Start one above and it will be kept here."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<ul className="flex flex-col gap-px">
|
||||
{groups.map((group) => (
|
||||
<li key={group.bucket}>
|
||||
{rail ? (
|
||||
// The header has nowhere to go at 60px, so the grouping survives as
|
||||
// a rule between runs of conversations. First group gets none.
|
||||
group.bucket === groups[0]?.bucket ? null : (
|
||||
<div className="mx-auto my-1.5 h-px w-6 bg-border" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
/* h4, not h3: these name a run of rows inside the rail, and the
|
||||
workspace's own headings sit above them. */
|
||||
<Label as="h4" className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3">
|
||||
{group.label}
|
||||
</Label>
|
||||
)}
|
||||
<ul className={cn('flex flex-col', rail ? 'items-center gap-1' : 'gap-px')}>
|
||||
{group.items.map((conversation) =>
|
||||
rail ? (
|
||||
<RailRow
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
bucket={group.bucket}
|
||||
active={conversation.id === activeId}
|
||||
running={Boolean(conversation.running) || conversation.id === runningId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
) : (
|
||||
<ConversationRow
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
bucket={group.bucket}
|
||||
active={conversation.id === activeId}
|
||||
running={Boolean(conversation.running) || conversation.id === runningId}
|
||||
renaming={conversation.id === renamingId}
|
||||
onSelect={handleSelect}
|
||||
onStartRename={() => setRenamingId(conversation.id)}
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onCommitRename={(title) => {
|
||||
setRenamingId(null);
|
||||
if (title && title !== conversation.title) {
|
||||
rename.mutate({ id: conversation.id, title });
|
||||
}
|
||||
}}
|
||||
onRequestDelete={(opener) => {
|
||||
deleteOpener.current = opener;
|
||||
setPendingDelete(conversation);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<nav
|
||||
aria-label="Piggy conversations"
|
||||
className={cn(
|
||||
// `min-h-0` is what lets the list below scroll instead of pushing the
|
||||
// whole column past the bottom of the viewport in a flex parent.
|
||||
'flex h-full min-h-0 flex-col bg-surface',
|
||||
rail ? 'w-[3.75rem] shrink-0' : 'w-full',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-border',
|
||||
rail ? 'flex justify-center p-2' : 'p-2',
|
||||
// On a phone this list lives inside the workspace's Sheet, whose own
|
||||
// dismiss control is pinned to the top-right corner — directly over
|
||||
// a full-width button. The corner is reserved rather than fought
|
||||
// over.
|
||||
!rail && isMobile && 'pr-14',
|
||||
)}
|
||||
>
|
||||
{rail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={newConversationRef}
|
||||
type="button"
|
||||
variant={activeId === null ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
aria-label="New conversation"
|
||||
onClick={onNew}
|
||||
>
|
||||
<Plus className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">New conversation</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
ref={newConversationRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-start gap-2',
|
||||
// No thread selected means the composer is already on a blank
|
||||
// one; showing that state stops the button reading as dead.
|
||||
activeId === null && 'border-brand/40 bg-accent-subtle text-accent-fg',
|
||||
)}
|
||||
onClick={onNew}
|
||||
>
|
||||
<MessageSquarePlus className="size-4" aria-hidden />
|
||||
New conversation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
// `overscroll-contain` stops a flick at the end of the history from
|
||||
// scrolling the page behind it, which on a phone drags the sheet.
|
||||
// The `calc` form, not `max(...)`: Tailwind's arbitrary-value parser
|
||||
// drops the latter and the utility is silently never generated,
|
||||
// which on a notched phone means the last row sits under the home
|
||||
// indicator with nothing to say it is there.
|
||||
'min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(0.5rem+var(--safe-bottom))]',
|
||||
rail ? 'px-1.5 pt-1.5' : 'px-1.5',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/*
|
||||
The destructive choice comes FIRST in the DOM and last on the screen.
|
||||
A screen reader reads a footer in source order, so the consequence has
|
||||
to arrive before the way out of it; `AlertDialogAction`'s own `order`
|
||||
classes put "Keep it" back on the left where the platform puts it. The
|
||||
title names the thread, because "this conversation" is not something
|
||||
anyone can check before agreeing to destroy it.
|
||||
*/}
|
||||
<AlertDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDelete(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
// After a delete the row's button is gone, and focusing a detached
|
||||
// node silently lands on <body>. The list's own New conversation
|
||||
// button is the nearest thing that certainly still exists — and is
|
||||
// where `confirmDelete` has just sent the reader anyway.
|
||||
const back = deleteOpener.current?.isConnected
|
||||
? deleteOpener.current
|
||||
: newConversationRef.current;
|
||||
if (!back) return;
|
||||
event.preventDefault();
|
||||
back.focus();
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="break-words">
|
||||
Delete “{deleting ? shortTitle(deleting.title) : ''}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The thread and everything said in it will be removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction disabled={remove.isPending} onClick={() => void confirmDelete()}>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
Delete conversation
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>Keep it</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- a row
|
||||
|
||||
function ConversationRow({
|
||||
conversation,
|
||||
bucket,
|
||||
active,
|
||||
running,
|
||||
renaming,
|
||||
onSelect,
|
||||
onStartRename,
|
||||
onCancelRename,
|
||||
onCommitRename,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
conversation: PiggyConversationSummary;
|
||||
bucket: Bucket;
|
||||
active: boolean;
|
||||
running: boolean;
|
||||
renaming: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onStartRename: () => void;
|
||||
onCancelRename: () => void;
|
||||
onCommitRename: (title: string) => void;
|
||||
onRequestDelete: (opener: HTMLButtonElement | null) => void;
|
||||
}) {
|
||||
/* Handed to the confirmation so it knows where to send focus back to. */
|
||||
const actionsRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<li className="px-1 py-1">
|
||||
<RenameField
|
||||
initial={conversation.title}
|
||||
onCancel={onCancelRename}
|
||||
onCommit={onCommitRename}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const when = formatWhen(bucket, conversation.updatedAt);
|
||||
|
||||
return (
|
||||
<li className="group/row relative">
|
||||
{/*
|
||||
* The selected row needs a marker that does not depend on the accent.
|
||||
* `accent-subtle` is 96% lightness under the default monochrome palette
|
||||
* and 97% under rose — against a white surface that is a tint you have to
|
||||
* look for, and in a list you are scanning it disappears. The bar is the
|
||||
* brand at full strength, so selection is legible whatever the user's
|
||||
* colour and whichever theme they are in.
|
||||
*/}
|
||||
{active ? (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-brand"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 flex-col gap-0.5 rounded-lg py-2 pl-2.5 pr-12 text-left',
|
||||
'transition-colors duration-1 ease-enter',
|
||||
active ? 'bg-accent-subtle text-accent-fg' : 'hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
* Two lines, then an ellipsis. Titles are derived from the opening
|
||||
* question and routinely run to a full sentence; one line loses the
|
||||
* distinguishing half of "Draft a follow-up to …" and three turns the
|
||||
* rail into a wall. `break-words` only splits a word that could not fit
|
||||
* on a line of its own, so an ordinary title still breaks at a space.
|
||||
*/}
|
||||
<span
|
||||
className={cn('line-clamp-2 break-words text-sm leading-5', active && 'font-medium')}
|
||||
title={conversation.title}
|
||||
>
|
||||
{conversation.title}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-4 text-muted">
|
||||
{running ? (
|
||||
<>
|
||||
<RunningDot />
|
||||
<span>Working…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{when ? <span className="tabular-nums">{when}</span> : null}
|
||||
{when && conversation.messageCount > 0 ? <span aria-hidden>·</span> : null}
|
||||
{conversation.messageCount > 0 ? (
|
||||
<span className="truncate">
|
||||
{conversation.messageCount} message{conversation.messageCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
* Outside the row button rather than inside it: a button inside a button
|
||||
* is invalid markup, and browsers resolve it by firing both handlers, so
|
||||
* opening the menu would also switch conversations.
|
||||
*/}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
ref={actionsRef}
|
||||
type="button"
|
||||
// Clipped, because a conversation's title is its opening question
|
||||
// and these run to 120 characters — "Actions for List the three
|
||||
// commitments closest to expiry as a markdown table with columns
|
||||
// Provider, GPU, Ends, Idle hours, Margin…" is a label nobody
|
||||
// listens to the end of.
|
||||
aria-label={`Actions for ${shortTitle(conversation.title)}`}
|
||||
className={cn(
|
||||
'absolute right-0.5 top-0.5 flex size-11 items-center justify-center rounded-lg',
|
||||
'text-muted transition duration-1 ease-enter hover:bg-border hover:text-fg',
|
||||
// Focus reveals it as well as ringing it: the button is invisible
|
||||
// until hover, and hover is not how a keyboard reaches it.
|
||||
'focus-visible:opacity-100 data-[state=open]:opacity-100',
|
||||
// Hidden until hovered only where hovering is possible. On a touch
|
||||
// screen there is no hover, so the same rule would hide rename and
|
||||
// delete for good.
|
||||
'[@media(hover:hover)]:opacity-0',
|
||||
'[@media(hover:hover)]:group-hover/row:opacity-100',
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" aria-hidden />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem className="min-h-11" onSelect={() => onStartRename()}>
|
||||
<Pencil aria-hidden />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-11 text-danger focus:text-danger"
|
||||
onSelect={() => onRequestDelete(actionsRef.current)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline rename editor.
|
||||
*
|
||||
* Deliberately not a `<form>`: this list is dropped into whatever the workspace
|
||||
* is, and a form nested inside the composer's form would be invalid markup with
|
||||
* a submit that fires the wrong one. Enter and Escape are handled directly.
|
||||
*/
|
||||
function RenameField({
|
||||
initial,
|
||||
onCancel,
|
||||
onCommit,
|
||||
}: {
|
||||
initial: string;
|
||||
onCancel: () => void;
|
||||
onCommit: (title: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
/**
|
||||
* Escape blurs the field, and blur commits — so without this the cancel key
|
||||
* would save. Set synchronously in the key handler, read in the blur that
|
||||
* follows it.
|
||||
*/
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Selected backwards on purpose. `select()` leaves the caret at the end,
|
||||
// which scrolls a 120-character title so that only its last few words are
|
||||
// visible — the half the user is least likely to be editing. A backward
|
||||
// selection puts the caret at the start and shows the beginning.
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current.value.length, 'backward');
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (cancelledRef.current) return;
|
||||
onCommit(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={TITLE_MAX}
|
||||
autoFocus
|
||||
aria-label="Conversation title"
|
||||
// No `text-sm` here, however well it would match the rows: the base
|
||||
// stylesheet floors every input at 16px so that focusing one does not
|
||||
// make mobile Safari zoom the viewport and never zoom back out.
|
||||
className="h-11"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelledRef.current = true;
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="px-1 text-xs leading-4 text-muted">Enter to save · Escape to cancel</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RailRow({
|
||||
conversation,
|
||||
bucket,
|
||||
active,
|
||||
running,
|
||||
onSelect,
|
||||
}: {
|
||||
conversation: PiggyConversationSummary;
|
||||
bucket: Bucket;
|
||||
active: boolean;
|
||||
running: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const when = formatWhen(bucket, conversation.updatedAt);
|
||||
return (
|
||||
<li>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
aria-label={conversation.title}
|
||||
className={cn(
|
||||
'relative flex size-11 items-center justify-center rounded-lg text-sm font-semibold',
|
||||
'transition-colors duration-1 ease-enter',
|
||||
// A solid fill, not the subtle tint the wide list uses. At 44px
|
||||
// there is no title to carry the selection, so the square itself
|
||||
// has to be unmistakable — and `accent-subtle` against
|
||||
// `surface-2` is a one-percent difference in lightness.
|
||||
active
|
||||
? 'bg-brand text-accent-on'
|
||||
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{railInitial(conversation.title)}</span>
|
||||
{running ? (
|
||||
// The dot sits on its own patch of the rail's background, because
|
||||
// the selected square is painted in the same brand colour and the
|
||||
// marker would otherwise vanish on exactly the conversation most
|
||||
// likely to be running.
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-surface p-0.5">
|
||||
<RunningDot />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{/* The rail shows one letter, so the tooltip is the only place the
|
||||
thread is actually named. It carries the timestamp too, because the
|
||||
headers that would have grouped it are gone at this width. */}
|
||||
<TooltipContent side="right" className="max-w-[16rem]">
|
||||
<p className="line-clamp-3 break-words">{conversation.title}</p>
|
||||
<p className="mt-0.5 text-muted-foreground">
|
||||
{running ? 'Working…' : when}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** A turn in flight. `motion-reduce` because a pulse in a list is decoration. */
|
||||
function RunningDot() {
|
||||
return (
|
||||
<span className="relative flex size-1.5 shrink-0" aria-hidden>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-brand opacity-75 motion-reduce:animate-none" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-brand" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- loading and failure
|
||||
|
||||
function ListSkeleton({ rail }: { rail: boolean }) {
|
||||
if (rail) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1" aria-busy>
|
||||
<span className="sr-only">Loading conversations…</span>
|
||||
{[0, 1, 2, 3].map((row) => (
|
||||
<Skeleton key={row} className="size-11 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pt-3" aria-busy>
|
||||
<span className="sr-only">Loading conversations…</span>
|
||||
{/* Uneven widths, because a column of identical bars reads as a loaded
|
||||
table rather than as something still arriving. */}
|
||||
{['w-3/4', 'w-full', 'w-2/3', 'w-5/6', 'w-1/2'].map((width, index) => (
|
||||
<div key={width} className="flex flex-col gap-1.5 px-1.5 py-2">
|
||||
<Skeleton className={cn('h-4', width)} />
|
||||
<Skeleton className={cn('h-3', index % 2 === 0 ? 'w-1/3' : 'w-1/4')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListError({
|
||||
rail,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
rail: boolean;
|
||||
message: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (rail) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="History unavailable. Try again."
|
||||
onClick={onRetry}
|
||||
>
|
||||
<AlertTriangle className="size-5 text-warning" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[16rem]">
|
||||
History unavailable. Press to try again.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertTriangle className="size-7" aria-hidden />}
|
||||
title="History unavailable"
|
||||
description={message}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,13 @@ export function PiggyConversation({
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}
|
||||
// `scroll-pb-14` keeps the last 56px of the scrollport out of the
|
||||
// resting position of anything the browser scrolls to itself — a
|
||||
// focused follow-up chip, a revealed step, the tail of an answer. The
|
||||
// jump-to-latest pill floats in that band, and without the padding it
|
||||
// came to rest on top of the final line of the answer it had just
|
||||
// brought into view.
|
||||
className={cn('min-h-0 flex-1 scroll-pb-14 overflow-y-auto overscroll-contain', className)}
|
||||
role="log"
|
||||
aria-label="Piggy conversation"
|
||||
// Announce the finished answer rather than each token: a live region
|
||||
@@ -194,14 +200,17 @@ export function PiggyConversationScrollButton(): ReactElement | null {
|
||||
onClick={scroll.scrollToLatest}
|
||||
aria-label="Jump to the latest message"
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-3 z-10 mx-auto rounded-full border border-border',
|
||||
// Right-aligned, not centred. Centred it sat over the middle of the
|
||||
// measure — which is where the sentence is — and a reader scrolled up
|
||||
// mid-answer had a disc parked on the words. The right gutter is empty
|
||||
// in every surface this panel is used in, from the 22rem dock to the
|
||||
// full page.
|
||||
'absolute bottom-3 right-3 z-10 rounded-full border border-border',
|
||||
// The `secondary` fill, left opaque. A translucent disc ghosted the
|
||||
// sentence it covered in light mode and disappeared into the panel
|
||||
// altogether in dark; `surface-2` reads against `surface` in both.
|
||||
'text-muted shadow-lg hover:text-fg',
|
||||
// `mx-auto` between `inset-x-0` centres it without a transform, which
|
||||
// the entrance animation below needs for itself.
|
||||
'animate-in fade-in zoom-in-95',
|
||||
'animate-in fade-in zoom-in-95 duration-2 ease-enter',
|
||||
)}
|
||||
>
|
||||
<ArrowDown className="size-4" aria-hidden />
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { TranscriptMessage } from '@/lib/piggy-chat';
|
||||
import { piggyModeSummary } from '@/components/piggy/mode-control';
|
||||
import { usePiggyModelLabel } from '@/components/piggy/model-picker';
|
||||
import { Badge, Button, cn } from '@/components/ui';
|
||||
|
||||
/** How long the copy button admits it worked before returning to its label. */
|
||||
@@ -34,6 +36,19 @@ export function PiggyMessageActions({
|
||||
|
||||
const state = stateLabel(message);
|
||||
const usage = formatUsage(message);
|
||||
const modelLabel = usePiggyModelLabel(message.model);
|
||||
/*
|
||||
* What this turn was allowed to do, recorded on the turn itself.
|
||||
*
|
||||
* PIG's safety argument is that nothing lands until a person presses Apply,
|
||||
* and until now the transcript held no record of which permission each turn
|
||||
* ran under — so a header reading "Read only" could sit above two write
|
||||
* proposals made ten minutes earlier and nothing in the thread contradicted
|
||||
* it. The mode is a fact about a turn, not about the control, so it belongs
|
||||
* beside the model that answered.
|
||||
*/
|
||||
const mode = message.mode ? piggyModeSummary(message.mode) : null;
|
||||
const ModeIcon = mode?.icon;
|
||||
// Only Piggy's words are worth a copy button. A user turn reaches this
|
||||
// footer too — a question the relay refused carries the `failed` chip — and
|
||||
// offering to copy back what they typed a second ago is noise.
|
||||
@@ -45,7 +60,7 @@ export function PiggyMessageActions({
|
||||
// Nothing to press and nothing to report is a row of whitespace under every
|
||||
// message. There is nothing to say, so say nothing.
|
||||
if (message.pending) return null;
|
||||
if (!copyable && !retryable && !state && !usage && !message.model) return null;
|
||||
if (!copyable && !retryable && !state && !usage && !message.model && !mode) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
// `navigator.clipboard` is absent outside a secure context, which is not a
|
||||
@@ -75,20 +90,36 @@ export function PiggyMessageActions({
|
||||
<div className="group/actions mt-1.5 flex flex-col gap-0.5">
|
||||
{/* The run line keeps its own row rather than sharing one with the
|
||||
buttons, and comes first so that it stays against the answer it
|
||||
describes: at 22rem the buttons' reserved width truncated the model id
|
||||
to "nvidia/nemotron-3-nan…", which defeats the point of showing it. */}
|
||||
{state || message.model || usage ? (
|
||||
<p className="flex min-w-0 items-baseline gap-1.5 text-[11px] leading-4 text-muted">
|
||||
{state ? <Badge className="shrink-0 px-2 text-[11px] font-normal">{state}</Badge> : null}
|
||||
{message.model ? (
|
||||
describes: at 22rem the buttons' reserved width truncated the model
|
||||
name away entirely, which defeats the point of showing it. It wraps
|
||||
rather than truncating now, because the mode is on it and "was this
|
||||
turn allowed to write?" is not a fact a narrow column may drop. */}
|
||||
{state || mode || modelLabel || usage ? (
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-4 text-muted">
|
||||
{state ? <Badge className="shrink-0 px-2 font-normal">{state}</Badge> : null}
|
||||
{mode && ModeIcon ? (
|
||||
// Neutral, not coloured: a mode is a setting, not an outcome, and a
|
||||
// footer where every turn is amber is a footer nobody reads. Auto
|
||||
// takes the same warning tint the mode control gives it, on the
|
||||
// glyph only — it is the one mode that can write unattended.
|
||||
<Badge className="shrink-0 px-2 font-normal" title={`Piggy ran this turn in ${mode.label}`}>
|
||||
<ModeIcon aria-hidden className={cn('size-3', message.mode === 'auto' && 'text-warning')} />
|
||||
{mode.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
{modelLabel ? (
|
||||
// Sans, and the catalogue's own name for the model rather than the
|
||||
// wire id: `nvidia/nemotron-3-super-120b` set in monospace under a
|
||||
// sales answer was the product talking to itself. The full id stays
|
||||
// in `title`, so nothing is lost — it is just no longer shouted.
|
||||
// `truncate` only shrinks a flex child that is allowed to: without
|
||||
// `min-w-0` the model id sets the row's minimum width and pushes
|
||||
// the counts off the side of the dock.
|
||||
<span className="min-w-0 truncate font-mono" title={message.model}>
|
||||
{message.model}
|
||||
// `min-w-0` the name sets the row's minimum width and pushes the
|
||||
// counts off the side of the dock.
|
||||
<span className="min-w-0 truncate" title={message.model}>
|
||||
{modelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{message.model && usage ? <span aria-hidden>·</span> : null}
|
||||
{modelLabel && usage ? <span aria-hidden>·</span> : null}
|
||||
{usage ? (
|
||||
<span className="shrink-0 tabular-nums" title={exactUsage(message)}>
|
||||
{usage}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* What Piggy is allowed to do, chosen before the question is asked.
|
||||
*
|
||||
* This is the only control in PIG that decides whether a language model may
|
||||
* write to the company's book, so it is written to be read rather than to be
|
||||
* clever. Four things follow from that and are deliberate:
|
||||
*
|
||||
* names — the segments say "Read only", "Ask first" and "Auto", not
|
||||
* `read_only` / `confirm` / `auto`. The enum is the wire's language
|
||||
* and nobody choosing a permission should have to learn it.
|
||||
* consequence — the sentence under the segments describes the mode that is
|
||||
* selected NOW, and changes as the selection does. A toggle whose
|
||||
* meaning lives in documentation is a toggle people set once and then
|
||||
* misremember.
|
||||
* the keyboard — the arrows move focus and do NOT select. The ARIA pattern
|
||||
* says they should, and for a preference it would be right; for a
|
||||
* permission it meant that arrowing across to read what Auto does
|
||||
* turned Auto on. See `keyboardAt`.
|
||||
* the exceptions — `auto` still stops at a contract, a commitment, an
|
||||
* allocation and anything compliance-shaped. That is `requiresApproval`'s
|
||||
* rule, and if the control does not say so, the first person to choose
|
||||
* auto will reasonably assume nothing stops, and will either be
|
||||
* frightened of the mode or trust it further than it deserves.
|
||||
*
|
||||
* The mode is NOT enforced here. `requiresApproval` in @pig/core is the single
|
||||
* source of truth and the agent applies it server-side; this control only tells
|
||||
* the relay what the user picked. Treating it as a guard would put the
|
||||
* authorisation in the browser, where the user can edit it.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState, type JSX } from 'react';
|
||||
import { Eye, ListChecks, TriangleAlert, Zap, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
PIGGY_ALWAYS_CONFIRM_KINDS,
|
||||
type PiggyGuardedKind,
|
||||
type PiggyMode,
|
||||
} from '@pig/core';
|
||||
import { PIGGY_DEFAULT_MODE } from '@/lib/piggy-chat';
|
||||
import { useOptionalIdentity } from '@/lib/identity';
|
||||
import { Label, cn } from '@/components/ui';
|
||||
|
||||
// ------------------------------------------------------------------- copy
|
||||
|
||||
interface ModeOption {
|
||||
value: PiggyMode;
|
||||
/** The user's word for it. */
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/** What choosing this mode means, in one sentence, present tense. */
|
||||
sentence: string;
|
||||
/** True when picking it hands an agent the ability to write unattended. */
|
||||
consequential?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four kinds `requiresApproval` refuses to automate, spelled for a person.
|
||||
*
|
||||
* Derived from `PIGGY_ALWAYS_CONFIRM_KINDS` rather than typed out, because the
|
||||
* sentence is a promise about policy: if a fifth guarded kind is added upstream
|
||||
* and this copy were a literal, the control would quietly go on promising four.
|
||||
* The map is exhaustive by type, so adding one there fails the build here.
|
||||
*/
|
||||
const GUARDED_KIND_LABELS: Record<PiggyGuardedKind, string> = {
|
||||
contract: 'contracts',
|
||||
commitment: 'commitments',
|
||||
allocation: 'allocations',
|
||||
compliance: 'compliance',
|
||||
};
|
||||
|
||||
const GUARDED_SENTENCE = (() => {
|
||||
const names = PIGGY_ALWAYS_CONFIRM_KINDS.map((kind) => GUARDED_KIND_LABELS[kind]);
|
||||
// en-GB: "contracts, commitments, allocations and compliance".
|
||||
const list = new Intl.ListFormat('en-GB', { style: 'long', type: 'conjunction' }).format(names);
|
||||
return `${list.charAt(0).toUpperCase()}${list.slice(1)} still stop for your approval.`;
|
||||
})();
|
||||
|
||||
const READ_ONLY_OPTION: ModeOption = {
|
||||
value: 'read_only',
|
||||
label: 'Read only',
|
||||
icon: Eye,
|
||||
sentence: 'Piggy answers from your CRM and is offered no tool that could change it.',
|
||||
};
|
||||
|
||||
const MODE_OPTIONS: readonly ModeOption[] = [
|
||||
READ_ONLY_OPTION,
|
||||
{
|
||||
value: 'confirm',
|
||||
label: 'Ask first',
|
||||
icon: ListChecks,
|
||||
sentence: 'Piggy proposes each change and nothing is saved until you press Apply.',
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Auto',
|
||||
icon: Zap,
|
||||
sentence: 'Piggy makes changes to your CRM itself, without asking first.',
|
||||
consequential: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Why the write modes are unavailable. Shown, never merely implied. */
|
||||
const NO_WRITE_REASON =
|
||||
'Your access does not allow changing records, so Piggy can only read.';
|
||||
|
||||
/**
|
||||
* The user's word for a mode, and its icon, for a control that summarises this
|
||||
* one rather than replacing it — the workspace header's trigger.
|
||||
*
|
||||
* Exported rather than restated at the call site: the trigger says what the
|
||||
* segments say, and a second copy of "Ask first" is a second opinion waiting to
|
||||
* disagree with this file the first time the copy is edited.
|
||||
*/
|
||||
export function piggyModeSummary(mode: PiggyMode): { label: string; icon: LucideIcon } {
|
||||
const option = optionFor(mode);
|
||||
return { label: option.label, icon: option.icon };
|
||||
}
|
||||
|
||||
function optionFor(mode: PiggyMode): ModeOption {
|
||||
// The union is closed and the array covers it; the fallback exists so a mode
|
||||
// read back from storage on a future build cannot render an empty control.
|
||||
return MODE_OPTIONS.find((option) => option.value === mode) ?? READ_ONLY_OPTION;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- control
|
||||
|
||||
export function PiggyModeControl({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
canWrite,
|
||||
}: {
|
||||
value: PiggyMode;
|
||||
onChange: (mode: PiggyMode) => void;
|
||||
compact?: boolean;
|
||||
canWrite: boolean;
|
||||
}): JSX.Element {
|
||||
const describedBy = useId();
|
||||
const buttons = useRef(new Map<PiggyMode, HTMLButtonElement>());
|
||||
|
||||
/**
|
||||
* Where the keyboard is, which is not the same as what is chosen.
|
||||
*
|
||||
* The ARIA radiogroup pattern normally selects whatever the arrow keys land
|
||||
* on. That is right for a preference and wrong for a permission: arrowing
|
||||
* across to read what Auto does was granting an agent unattended write access
|
||||
* to the book, and the sentence explaining the consequence appeared *because*
|
||||
* the consequence had already been accepted. Here the arrows move focus, the
|
||||
* sentence updates to describe what is under the cursor, and Space or Enter
|
||||
* is what commits. Null means the keyboard is elsewhere and the tabstop
|
||||
* belongs to the selected segment, so tabbing back in returns to the choice
|
||||
* in force rather than to wherever the last arrow press stopped.
|
||||
*/
|
||||
const [keyboardAt, setKeyboardAt] = useState<PiggyMode | null>(null);
|
||||
|
||||
/**
|
||||
* What is drawn as selected. Not necessarily what the parent holds: a stored
|
||||
* `auto` outlives the capability that justified it, so someone whose write
|
||||
* grant was removed would otherwise open the composer being told Piggy is
|
||||
* about to edit records it will now be refused.
|
||||
*/
|
||||
const selected: PiggyMode = canWrite ? value : 'read_only';
|
||||
|
||||
useEffect(() => {
|
||||
// The correction is pushed up rather than kept local, because the parent is
|
||||
// what puts `mode` on the wire. Showing read-only while sending `auto`
|
||||
// would be the one disagreement this control must never have. It cannot
|
||||
// loop: the parent's next value satisfies the condition.
|
||||
if (!canWrite && value !== 'read_only') onChange('read_only');
|
||||
}, [canWrite, value, onChange]);
|
||||
|
||||
const choices = MODE_OPTIONS.filter((option) => canWrite || option.value === 'read_only');
|
||||
|
||||
/** Roving tabstop: the keyboard's position if it has one, else the choice. */
|
||||
const roving: PiggyMode = keyboardAt ?? selected;
|
||||
|
||||
const moveTo = useCallback((next: PiggyMode | undefined) => {
|
||||
if (!next) return;
|
||||
setKeyboardAt(next);
|
||||
buttons.current.get(next)?.focus();
|
||||
}, []);
|
||||
|
||||
const step = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const index = choices.findIndex((option) => option.value === roving);
|
||||
moveTo(choices[(index + direction + choices.length) % choices.length]?.value);
|
||||
},
|
||||
[choices, moveTo, roving],
|
||||
);
|
||||
|
||||
/**
|
||||
* The sentence describes what the keyboard is on, not what is chosen — so
|
||||
* someone arrowing across Auto reads its consequence before deciding, which
|
||||
* is the whole point of no longer selecting on focus.
|
||||
*/
|
||||
const active = optionFor(roving);
|
||||
/** True while the keyboard is reading a mode that has not been chosen. */
|
||||
const previewing = roving !== selected;
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col', compact ? 'gap-1.5' : 'gap-2')}>
|
||||
{compact ? null : <Label>What Piggy may do</Label>}
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="What Piggy may do"
|
||||
aria-describedby={describedBy}
|
||||
className="grid grid-cols-3 gap-1 rounded-lg border border-border bg-surface-2 p-1"
|
||||
onBlur={(event) => {
|
||||
// Leaving the group hands the tabstop back to the chosen segment, so
|
||||
// the next Tab in lands on the mode in force rather than on whichever
|
||||
// one the reader stopped over last time.
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setKeyboardAt(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
step(1);
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
step(-1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
moveTo(choices[0]?.value);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
moveTo(choices[choices.length - 1]?.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{MODE_OPTIONS.map((option) => {
|
||||
const isSelected = option.value === selected;
|
||||
const disabled = !canWrite && option.value !== 'read_only';
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(node) => {
|
||||
if (node) buttons.current.set(option.value, node);
|
||||
else buttons.current.delete(option.value);
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
// Roving tabstop: a radio group is one stop in the tab order, and
|
||||
// the arrow keys move within it.
|
||||
tabIndex={option.value === roving ? 0 : -1}
|
||||
disabled={disabled}
|
||||
title={disabled ? NO_WRITE_REASON : option.sentence}
|
||||
onClick={() => {
|
||||
setKeyboardAt(option.value);
|
||||
onChange(option.value);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-h-[44px] min-w-0 items-center justify-center rounded-md',
|
||||
'font-medium transition-colors duration-1 ease-enter touch-manipulation select-none',
|
||||
// Tight enough that "Read only" survives whole in a dock
|
||||
// narrower than the 22rem one; the label is what makes this
|
||||
// control legible, so it is the last thing allowed to truncate.
|
||||
compact ? 'gap-1 px-1 text-xs' : 'gap-1.5 px-2 text-xs sm:text-sm',
|
||||
isSelected
|
||||
? 'bg-surface text-fg shadow-sm'
|
||||
: 'text-muted hover:text-fg disabled:hover:text-muted',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
compact ? 'h-3 w-3' : 'h-4 w-4',
|
||||
isSelected && option.consequential ? 'text-warning' : undefined,
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Announced on change, because the consequence arrives a beat after the
|
||||
press and a screen-reader user gets no colour to tell them the tone of
|
||||
the panel changed. It follows the keyboard rather than the choice, so
|
||||
arrowing across Auto reads its consequence — which is the only way to
|
||||
find out, now that arrowing no longer turns it on.
|
||||
*/}
|
||||
<div id={describedBy} aria-live="polite" className="min-w-0 text-xs leading-snug">
|
||||
{active.consequential ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 text-fg',
|
||||
compact ? 'px-2 py-1.5' : 'px-2.5 py-2',
|
||||
)}
|
||||
>
|
||||
<TriangleAlert aria-hidden className="mt-px h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
<span>
|
||||
{previewing ? <span className="font-medium">{active.label}: </span> : null}
|
||||
{active.sentence} <span className="font-medium">{GUARDED_SENTENCE}</span>
|
||||
{previewing ? ' Press Enter to choose it.' : null}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted">
|
||||
{previewing ? <span className="font-medium text-fg">{active.label}: </span> : null}
|
||||
{active.sentence}
|
||||
{previewing ? ' Press Enter to choose it.' : null}
|
||||
</p>
|
||||
)}
|
||||
{canWrite ? null : <p className="mt-1 text-muted">{NO_WRITE_REASON}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- preference
|
||||
|
||||
/**
|
||||
* Per user, not per browser.
|
||||
*
|
||||
* Two people share a laptop far more often than a CRM's security model likes to
|
||||
* admit, and a single `pig.piggy.mode` key would hand the second one an agent
|
||||
* already licensed to write by the first. The signed-in id is part of the key
|
||||
* for that reason alone.
|
||||
*/
|
||||
const MODE_STORAGE_PREFIX = 'pig.piggy.mode.';
|
||||
|
||||
function isMode(value: unknown): value is PiggyMode {
|
||||
return MODE_OPTIONS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
function readStoredMode(key: string | null): PiggyMode | null {
|
||||
if (!key) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
// Validated rather than cast: a value written by an older build, or edited
|
||||
// by hand, would otherwise travel to the relay as a mode and collect a 400
|
||||
// on every turn until someone cleared their storage.
|
||||
return isMode(raw) ? raw : null;
|
||||
} catch {
|
||||
// Private browsing throws on access. The default is the safe one anyway.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored answer to "what may Piggy do", defaulting to `read_only`.
|
||||
*
|
||||
* `PIGGY_DEFAULT_MODE` is imported rather than restated so this cannot become a
|
||||
* second opinion on what "safe" means; it is read-only, which is both the
|
||||
* safest mode and a useful one — Piggy still answers every question it can
|
||||
* answer, and the only thing withheld is the ability to change records, which
|
||||
* is exactly the thing a person should turn on knowingly. Defaulting to
|
||||
* `confirm` would be defensible on the grounds that it never writes unasked,
|
||||
* but it puts write tools in front of the model on first use for someone who
|
||||
* never asked for them, and the relay would then be told so on every turn.
|
||||
*/
|
||||
export function usePiggyMode(): { mode: PiggyMode; setMode: (mode: PiggyMode) => void } {
|
||||
const identity = useOptionalIdentity();
|
||||
const key = identity ? `${MODE_STORAGE_PREFIX}${identity.id}` : null;
|
||||
const [mode, setModeState] = useState<PiggyMode>(() => readStoredMode(key) ?? PIGGY_DEFAULT_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read whenever the person changes. Falling back to the default rather
|
||||
// than keeping what is on screen matters here: a new signed-in user with no
|
||||
// stored preference must not inherit the last one's `auto`.
|
||||
setModeState(readStoredMode(key) ?? PIGGY_DEFAULT_MODE);
|
||||
}, [key]);
|
||||
|
||||
const setMode = useCallback(
|
||||
(next: PiggyMode) => {
|
||||
setModeState(next);
|
||||
if (!key) return;
|
||||
try {
|
||||
localStorage.setItem(key, next);
|
||||
} catch {
|
||||
// Non-fatal: the choice simply does not survive the tab, and the next
|
||||
// one opens read-only, which is the harmless direction to fail in.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return { mode, setMode };
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Which model answers, and what that costs.
|
||||
*
|
||||
* This is a small control carrying a large argument. Every entry in the
|
||||
* catalogue — NVIDIA's Nemotron, DeepSeek, Anthropic's Opus, OpenAI's GPT — is
|
||||
* served by Prime Intellect's own inference on a single API key. Nowhere else
|
||||
* in PIG is that visible; a transcript footer naming the model is a fact, but a
|
||||
* menu of five vendors under one key is the pitch. So the menu says so, once,
|
||||
* quietly, at the bottom.
|
||||
*
|
||||
* The catalogue is the server's, never this file's. The relay refuses any id it
|
||||
* did not send, so a hard-coded option that has been retired upstream is a menu
|
||||
* entry whose only effect is a 400 — and a *stored* id that has been retired is
|
||||
* the same 400 on every turn until someone clears their browser storage. Both
|
||||
* are handled below rather than left to the user to discover.
|
||||
*
|
||||
* On presenting cost: "$0.05 / $0.20 per Mtok" is the unit providers publish
|
||||
* and it is meaningless to the sales lead this product is for. The headline
|
||||
* figure is therefore an estimate of what a hundred questions cost, derived
|
||||
* from a real measured read-only turn, with the raw per-Mtok rates kept on a
|
||||
* secondary line for whoever wants to check the arithmetic.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
// `Cpu`, not `Sparkles`: Sparkles used to mean both "Piggy" and "model", and a
|
||||
// glyph that means two things means neither. Piggy is `PiggyMark` everywhere
|
||||
// now, so this control needs a neutral mark of its own — and inference running
|
||||
// on Prime Intellect's own silicon is the thing this menu is about.
|
||||
import { ChevronsUpDown, Cpu } from 'lucide-react';
|
||||
import type { PiggyModelOption } from '@pig/core';
|
||||
import { fetchPiggyModels } from '@/lib/piggy-chat';
|
||||
import { useIdentityQuery } from '@/lib/identity';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge, Button, Label, Skeleton, cn } from '@/components/ui';
|
||||
|
||||
// ------------------------------------------------------------------ catalogue
|
||||
|
||||
export const PIGGY_MODELS_QUERY_KEY = ['piggy', 'models'] as const;
|
||||
|
||||
/**
|
||||
* A stable empty array for the pending and failed cases.
|
||||
*
|
||||
* `?? []` would hand every render a new reference, which is enough to re-run
|
||||
* any effect below that depends on the list — including the one that repairs an
|
||||
* invalid selection, which would then loop.
|
||||
*/
|
||||
const NO_MODELS: PiggyModelOption[] = [];
|
||||
|
||||
export interface PiggyModelCatalogue {
|
||||
models: PiggyModelOption[];
|
||||
defaultModelId: string | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models this deployment offers.
|
||||
*
|
||||
* Cached for the life of the tab: the catalogue is deployment configuration,
|
||||
* not data. It cannot change while the page is open, and a refetch on window
|
||||
* focus would put a network round trip behind a control the user is in the act
|
||||
* of opening.
|
||||
*/
|
||||
export function usePiggyModels(): PiggyModelCatalogue {
|
||||
const query = useQuery({
|
||||
queryKey: PIGGY_MODELS_QUERY_KEY,
|
||||
queryFn: fetchPiggyModels,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
models: query.data?.models ?? NO_MODELS,
|
||||
defaultModelId: query.data?.defaultModelId ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: toError(query.error),
|
||||
};
|
||||
}
|
||||
|
||||
function toError(value: unknown): Error | null {
|
||||
if (!value) return null;
|
||||
return value instanceof Error ? value : new Error(String(value));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- what it costs
|
||||
|
||||
/**
|
||||
* A real read-only turn, measured against nemotron on the live stack: roughly
|
||||
* 5,300 tokens in (the system prompt, the tool schemas and the CRM context
|
||||
* dominate) and 150 out. Every price in this menu is that same turn priced on a
|
||||
* different model, which is the only way five figures spanning a hundredfold
|
||||
* are comparable at a glance.
|
||||
*/
|
||||
const TYPICAL_INPUT_TOKENS = 5_300;
|
||||
const TYPICAL_OUTPUT_TOKENS = 150;
|
||||
|
||||
/**
|
||||
* A single question on the cheapest model costs three hundredths of a cent, and
|
||||
* "$0.0003" is a number nobody can rank against another number. Quoting a
|
||||
* hundred questions puts the whole catalogue in the range people actually price
|
||||
* things in — three cents to three dollars.
|
||||
*/
|
||||
const QUOTED_QUESTIONS = 100;
|
||||
|
||||
const QUOTE_FORMAT = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Rates are published as round dollars ($5, $25) as often as fractions ($0.05).
|
||||
* Two formatters rather than one: a single `minimumFractionDigits: 0` renders
|
||||
* $0.20 as "$0.2", which reads as a typo next to "$0.05" in the same column.
|
||||
*/
|
||||
const WHOLE_RATE_FORMAT = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
const PART_RATE_FORMAT = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
function formatRate(dollars: number): string {
|
||||
return Number.isInteger(dollars)
|
||||
? WHOLE_RATE_FORMAT.format(dollars)
|
||||
: PART_RATE_FORMAT.format(dollars);
|
||||
}
|
||||
|
||||
/**
|
||||
* US dollars for one typical question.
|
||||
*
|
||||
* `costPerMTok*` is dollars per million tokens — not cents, and deliberately
|
||||
* not, per the note in the protocol. Nothing here may be run through the
|
||||
* `Cents` formatters in lib/api.
|
||||
*/
|
||||
function dollarsPerQuestion(model: PiggyModelOption): number {
|
||||
return (
|
||||
(TYPICAL_INPUT_TOKENS * model.costPerMTokIn + TYPICAL_OUTPUT_TOKENS * model.costPerMTokOut) /
|
||||
1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
function formatQuote(model: PiggyModelOption): string {
|
||||
const total = dollarsPerQuestion(model) * QUOTED_QUESTIONS;
|
||||
// A model cheap enough to round to zero would otherwise be quoted "$0.00",
|
||||
// which reads as free rather than as very cheap.
|
||||
return total > 0 && total < 0.01 ? 'under $0.01' : QUOTE_FORMAT.format(total);
|
||||
}
|
||||
|
||||
function formatRates(model: PiggyModelOption): string {
|
||||
return `${formatRate(model.costPerMTokIn)} in / ${formatRate(model.costPerMTokOut)} out per Mtok`;
|
||||
}
|
||||
|
||||
function formatContext(tokens: number): string {
|
||||
return tokens >= 1_000 ? `${Math.round(tokens / 1_000)}K` : String(tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* How close to the dearest model a model has to be to count as top tier.
|
||||
*
|
||||
* The two frontier entries are priced within a few per cent of each other, and
|
||||
* naming only the very dearest "most capable" would be PIG picking a winner
|
||||
* between two vendors on a rounding difference. A band names the tier instead,
|
||||
* which is the true statement.
|
||||
*/
|
||||
const TOP_TIER_RATIO = 0.85;
|
||||
|
||||
/**
|
||||
* Which entry is cheapest, and which are the ones to reach for when it matters.
|
||||
*
|
||||
* Price is the proxy for capability, because the catalogue carries no capability
|
||||
* score and price is the only ordering the server actually sends. The
|
||||
* alternative — a list of model ids ranked in this file — is a second source of
|
||||
* truth that goes stale the first time the deployment adds a model.
|
||||
*/
|
||||
function priceBands(models: PiggyModelOption[]): {
|
||||
cheapestId: string | null;
|
||||
topTierIds: ReadonlySet<string>;
|
||||
} {
|
||||
const empty: ReadonlySet<string> = new Set<string>();
|
||||
if (models.length < 2) return { cheapestId: null, topTierIds: empty };
|
||||
|
||||
let cheapest: PiggyModelOption | null = null;
|
||||
let dearest = 0;
|
||||
for (const model of models) {
|
||||
const cost = dollarsPerQuestion(model);
|
||||
if (!cheapest || cost < dollarsPerQuestion(cheapest)) cheapest = model;
|
||||
if (cost > dearest) dearest = cost;
|
||||
}
|
||||
if (!cheapest || dearest <= 0) return { cheapestId: null, topTierIds: empty };
|
||||
|
||||
const cheapestId = cheapest.id;
|
||||
const topTierIds = new Set(
|
||||
models
|
||||
// The cheapest model is never also the top tier, however flat the
|
||||
// catalogue's pricing happens to be.
|
||||
.filter(
|
||||
(model) =>
|
||||
model.id !== cheapestId && dollarsPerQuestion(model) >= dearest * TOP_TIER_RATIO,
|
||||
)
|
||||
.map((model) => model.id),
|
||||
);
|
||||
return { cheapestId, topTierIds };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- persistence
|
||||
|
||||
const STORAGE_PREFIX = 'pig:piggy:model';
|
||||
|
||||
function storageKey(userId: string | null): string {
|
||||
return userId ? `${STORAGE_PREFIX}:${userId}` : STORAGE_PREFIX;
|
||||
}
|
||||
|
||||
export function readStoredPiggyModelId(userId: string | null): string | null {
|
||||
try {
|
||||
return localStorage.getItem(storageKey(userId));
|
||||
} catch {
|
||||
/* Private browsing throws on localStorage. The deployment default is fine. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredPiggyModelId(userId: string | null, modelId: string | null): void {
|
||||
try {
|
||||
const key = storageKey(userId);
|
||||
if (modelId) localStorage.setItem(key, modelId);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* As above: an unpersisted preference is a smaller problem than a throw. */
|
||||
}
|
||||
}
|
||||
|
||||
export interface PiggyModelChoice extends PiggyModelCatalogue {
|
||||
/**
|
||||
* The id to send with a turn: the stored choice when the catalogue still
|
||||
* lists it, otherwise the deployment default. Null only while the catalogue
|
||||
* is loading or unavailable, in which case send no `modelId` at all.
|
||||
*/
|
||||
modelId: string | null;
|
||||
/** True when the user picked this, false when it is the deployment default. */
|
||||
isExplicit: boolean;
|
||||
setModelId: (modelId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The choice, persisted per user, with the server as the authority on validity.
|
||||
*
|
||||
* Pair this with `PiggyModelPicker` — `value={modelId}` and
|
||||
* `onChange={setModelId}` — rather than a plain `useState`, or the preference
|
||||
* is remembered for the session only.
|
||||
*/
|
||||
export function usePiggyModelChoice(): PiggyModelChoice {
|
||||
const catalogue = usePiggyModels();
|
||||
const userId = useIdentityQuery().data?.id ?? null;
|
||||
const [stored, setStored] = useState<string | null>(() => readStoredPiggyModelId(userId));
|
||||
|
||||
// On a cold cache the signed-in user arrives a tick after first render, so
|
||||
// the key this first read from was the anonymous one. Re-read once it settles
|
||||
// rather than showing whatever the previous person on this browser chose.
|
||||
useEffect(() => {
|
||||
setStored(readStoredPiggyModelId(userId));
|
||||
}, [userId]);
|
||||
|
||||
const known = catalogue.models.some((model) => model.id === stored);
|
||||
|
||||
// A stored id the relay no longer lists is a guaranteed 400 on every
|
||||
// subsequent turn, and the user has no way to connect that error to a choice
|
||||
// they made weeks ago. Drop it as soon as the catalogue contradicts it.
|
||||
useEffect(() => {
|
||||
if (!stored || known) return;
|
||||
if (catalogue.isLoading || catalogue.models.length === 0) return;
|
||||
writeStoredPiggyModelId(userId, null);
|
||||
setStored(null);
|
||||
}, [stored, known, catalogue.isLoading, catalogue.models, userId]);
|
||||
|
||||
const setModelId = useCallback(
|
||||
(modelId: string) => {
|
||||
writeStoredPiggyModelId(userId, modelId);
|
||||
setStored(modelId);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
|
||||
const isExplicit = Boolean(stored) && known;
|
||||
return {
|
||||
...catalogue,
|
||||
modelId: isExplicit ? stored : catalogue.defaultModelId,
|
||||
isExplicit,
|
||||
setModelId,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ picker
|
||||
|
||||
/**
|
||||
* The label the tight trigger shows.
|
||||
*
|
||||
* The protocol carries no short form, and the docked panel header is 22rem
|
||||
* wide — enough for about a dozen characters once the icon and chevron are
|
||||
* paid for. Dropping the family prefix from a long name keeps the part that
|
||||
* distinguishes it ("Nano 30B", "Super 120B") rather than the part every entry
|
||||
* in a family shares; shorter names are already short enough to leave alone.
|
||||
*/
|
||||
const LONG_LABEL_WORDS = 4;
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean);
|
||||
return words.length >= LONG_LABEL_WORDS ? words.slice(-2).join(' ') : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name for a model id, for surfaces that only have the id.
|
||||
*
|
||||
* The transcript footer used to print `nvidia/nemotron-3-super-120b` in
|
||||
* monospace under every answer — the raw wire value, in the one typeface this
|
||||
* product reserves for machine text, on the line meant to tell a sales lead
|
||||
* which model answered them. The catalogue already carries the name the picker
|
||||
* shows two inches above it, so the footer says the same thing the menu says.
|
||||
*
|
||||
* The fallback drops the provider prefix rather than inventing capitalisation:
|
||||
* a deployment can list a model this browser's cached catalogue has never
|
||||
* seen, and a guessed name is worse than an honest identifier.
|
||||
*/
|
||||
export function usePiggyModelLabel(modelId: string | undefined): string | undefined {
|
||||
const { models } = usePiggyModels();
|
||||
if (!modelId) return undefined;
|
||||
const known = models.find((model) => model.id === modelId);
|
||||
if (known) return known.label;
|
||||
const slash = modelId.lastIndexOf('/');
|
||||
return slash === -1 ? modelId : modelId.slice(slash + 1);
|
||||
}
|
||||
|
||||
export interface PiggyModelPickerProps {
|
||||
/** The chosen model id, or null to follow the deployment default. */
|
||||
value: string | null;
|
||||
onChange: (modelId: string) => void;
|
||||
/** The tight rendering, for the 22rem docked panel header. */
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PiggyModelPicker({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
}: PiggyModelPickerProps): JSX.Element {
|
||||
const { models, defaultModelId, isLoading, error } = usePiggyModels();
|
||||
const userId = useIdentityQuery().data?.id ?? null;
|
||||
|
||||
const selected = models.find((model) => model.id === value) ?? null;
|
||||
const fallback =
|
||||
models.find((model) => model.id === defaultModelId) ??
|
||||
models.find((model) => model.isDefault) ??
|
||||
null;
|
||||
const inForce = selected ?? fallback;
|
||||
/**
|
||||
* Whether the model in force is the deployment's own default.
|
||||
*
|
||||
* Deliberately a fact about the model, not about how it was arrived at. The
|
||||
* caller may resolve a null preference to the default id before passing it
|
||||
* in, so "the user made no choice" is not reliably visible here — and it is
|
||||
* not the interesting question anyway. What the user needs to know is which
|
||||
* model is answering and whether that is the shipped one.
|
||||
*/
|
||||
const isDeploymentDefault = Boolean(inForce && inForce.id === defaultModelId);
|
||||
|
||||
const { cheapestId, topTierIds } = useMemo(() => priceBands(models), [models]);
|
||||
|
||||
/**
|
||||
* Repair a selection the catalogue does not list.
|
||||
*
|
||||
* The owner of `value` may be persisting it itself, or restoring it from
|
||||
* somewhere this component cannot see, so showing the default while the
|
||||
* caller still holds a retired id would render one model and send another.
|
||||
* Correcting the caller is the only fix that reaches the request. Guarded by
|
||||
* the id already repaired, so a caller that ignores `onChange` gets one
|
||||
* attempt rather than an infinite loop.
|
||||
*/
|
||||
const repaired = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!value || isLoading || models.length === 0 || !defaultModelId) return;
|
||||
if (models.some((model) => model.id === value)) return;
|
||||
if (repaired.current === value) return;
|
||||
repaired.current = value;
|
||||
writeStoredPiggyModelId(userId, null);
|
||||
onChange(defaultModelId);
|
||||
}, [value, isLoading, models, defaultModelId, onChange, userId]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
// Written here as well as in `usePiggyModelChoice` so the preference
|
||||
// survives a reload however the caller holds it. Writing the same value
|
||||
// twice costs nothing; losing it because the caller used `useState`
|
||||
// costs the user their choice on every visit.
|
||||
writeStoredPiggyModelId(userId, modelId);
|
||||
onChange(modelId);
|
||||
},
|
||||
[onChange, userId],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-11 rounded-lg', compact ? 'w-28' : 'w-44')} />;
|
||||
}
|
||||
|
||||
if (!inForce) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
className={cn('gap-1.5 font-normal', compact ? 'px-2' : 'px-2.5 text-sm')}
|
||||
aria-label="The model list is unavailable"
|
||||
title={error?.message ?? 'Piggy did not return a model list.'}
|
||||
>
|
||||
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
|
||||
{compact ? null : <span className="text-muted">Model unavailable</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={compact ? 'ghost' : 'outline'}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-label={`Model: ${inForce.label}${isDeploymentDefault ? ', the deployment default' : ''}. Change the model Piggy answers with.`}
|
||||
className={cn(
|
||||
'gap-1.5 font-normal data-[state=open]:bg-surface-2',
|
||||
compact ? 'max-w-[11rem] px-2' : 'max-w-[18rem] px-2.5 text-sm',
|
||||
)}
|
||||
>
|
||||
<Cpu className="size-4 shrink-0 text-muted" aria-hidden />
|
||||
<span className="truncate text-fg">
|
||||
{compact ? shortLabel(inForce.label) : inForce.label}
|
||||
</span>
|
||||
{!compact && isDeploymentDefault ? (
|
||||
<span className="shrink-0 text-xs text-muted">Default</span>
|
||||
) : null}
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-muted" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
{/*
|
||||
Above the sheet and drawer primitives, which sit at z-50 themselves: the
|
||||
same trigger appears inside the mobile drawer, and a menu that opens
|
||||
behind the surface that spawned it is a control that simply does not
|
||||
work on a phone.
|
||||
*/}
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="z-[60] w-[min(26rem,calc(100vw-1.5rem))] p-1.5"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-1.5 pt-1">
|
||||
<Label className="flex items-baseline justify-between gap-2">
|
||||
Model
|
||||
<span className="normal-case tracking-normal">{models.length} available</span>
|
||||
</Label>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuRadioGroup value={inForce.id} onValueChange={handleSelect}>
|
||||
{models.map((model) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
// The indicator is absolutely positioned with no `top`, so it
|
||||
// would ride the top edge of a three-line row; nudged down to sit
|
||||
// against the label rather than the padding above it.
|
||||
className="items-start gap-2 rounded-md py-2.5 pl-8 pr-2 [&>span]:top-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-fg">{model.label}</span>
|
||||
{model.id === defaultModelId ? (
|
||||
<Badge tone="neutral" className="px-1.5 py-0">
|
||||
Default
|
||||
</Badge>
|
||||
) : null}
|
||||
{model.id === cheapestId ? (
|
||||
<Badge tone="positive" className="px-1.5 py-0">
|
||||
Cheapest
|
||||
</Badge>
|
||||
) : null}
|
||||
{topTierIds.has(model.id) ? (
|
||||
<Badge tone="accent" className="px-1.5 py-0">
|
||||
Most capable
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{model.hint ? (
|
||||
<p className="whitespace-normal text-xs leading-snug text-muted">{model.hint}</p>
|
||||
) : null}
|
||||
<p className="nums whitespace-normal text-xs leading-snug text-muted">
|
||||
{formatRates(model)} · {formatContext(model.contextWindow)} context
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end pl-1 text-right">
|
||||
<span className="nums text-sm font-medium text-fg">{formatQuote(model)}</span>
|
||||
<span className="text-xs leading-tight text-muted">per 100 questions</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-xs leading-snug text-muted">
|
||||
Every model here is served by Prime Intellect inference on one API key. Prices are
|
||||
estimated from a measured question — about {TYPICAL_INPUT_TOKENS.toLocaleString('en-US')}{' '}
|
||||
tokens in and {TYPICAL_OUTPUT_TOKENS} out.
|
||||
</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
* payload one further click down for anyone who wants to check the sentence
|
||||
* against it.
|
||||
*/
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CheckCircle2, ChevronRight, Loader2, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CheckCircle2, CircleSlash, Loader2, XCircle } from 'lucide-react';
|
||||
import { money, unitPrice } from '@/lib/api';
|
||||
import type { ToolStep } from '@/lib/piggy-chat';
|
||||
import { cn } from '@/components/ui';
|
||||
import { isPiggyWriteTool, piggyToolLabel } from '@/lib/piggy-tool-labels';
|
||||
import { RecordLink } from '@/components/RecordLink';
|
||||
import { Label, cn } from '@/components/ui';
|
||||
import { Disclosure } from '@/components/ui/disclosure';
|
||||
import { usePiggyConversationReveal } from './conversation';
|
||||
|
||||
/**
|
||||
@@ -36,24 +38,19 @@ const RAW_PAYLOAD_MAX_CHARS = 20_000;
|
||||
// ------------------------------------------------------------------ routing
|
||||
|
||||
/**
|
||||
* Where a record of each kind can be opened.
|
||||
* The record kinds a payload can name, and the noun each is called by.
|
||||
*
|
||||
* Contacts point at /accounts because PIG has no contacts route — the accounts
|
||||
* page carries both views — and everything else points at its list.
|
||||
* The URLs themselves live in `components/RecordLink`, which is the one place a
|
||||
* record id becomes a URL and the one place that knows an account chip opens
|
||||
* the record while everything else lands on the list holding it. This table is
|
||||
* what remains: the vocabulary a summary sentence is written in.
|
||||
*
|
||||
* A contact is the case worth stating: it would want `/accounts/:accountId`,
|
||||
* and the summariser reads contacts out of collections that carry the contact's
|
||||
* own id and not its account's, so a per-record contact link would point at an
|
||||
* account that does not exist.
|
||||
*/
|
||||
const RECORD_ROUTES = {
|
||||
account: '/accounts',
|
||||
contact: '/accounts',
|
||||
demand_deal: '/demand',
|
||||
supply_deal: '/supply',
|
||||
contract: '/contracts',
|
||||
commitment: '/capacity',
|
||||
allocation: '/capacity',
|
||||
} as const;
|
||||
|
||||
type RecordKind = keyof typeof RECORD_ROUTES;
|
||||
|
||||
const RECORD_LABELS: Record<RecordKind, string> = {
|
||||
const RECORD_LABELS = {
|
||||
account: 'account',
|
||||
contact: 'contact',
|
||||
demand_deal: 'demand deal',
|
||||
@@ -61,153 +58,175 @@ const RECORD_LABELS: Record<RecordKind, string> = {
|
||||
contract: 'contract',
|
||||
commitment: 'capacity commitment',
|
||||
allocation: 'allocation',
|
||||
};
|
||||
} as const;
|
||||
|
||||
interface RecordLink {
|
||||
type RecordKind = keyof typeof RECORD_LABELS;
|
||||
|
||||
interface EvidenceRecord {
|
||||
kind: RecordKind;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place a record id becomes a URL.
|
||||
*
|
||||
* `/accounts/:id` now exists, so an account chip opens the record itself —
|
||||
* which is the whole promise of the evidence row, and why the id has been
|
||||
* carried this far rather than dropped at the summariser. Nothing else has a
|
||||
* per-record route yet, so those chips still land on the list, which at least
|
||||
* puts the reader in front of the row. A contact is the case worth stating: it
|
||||
* would want `/accounts/:accountId`, and the summariser reads contacts out of
|
||||
* collections that carry the contact's own id and not its account's, so
|
||||
* appending it here would build a URL to an account that does not exist.
|
||||
*/
|
||||
function recordHref(link: RecordLink): string {
|
||||
return link.kind === 'account'
|
||||
? `${RECORD_ROUTES.account}/${link.id}`
|
||||
: RECORD_ROUTES[link.kind];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ evidence
|
||||
|
||||
interface Evidence {
|
||||
/** One line a human reads instead of the payload. */
|
||||
headline: string | null;
|
||||
/** The records the answer rests on, each openable. */
|
||||
links: RecordLink[];
|
||||
links: EvidenceRecord[];
|
||||
/** Records read but not linked, so the cap is admitted rather than hidden. */
|
||||
hiddenLinkCount: number;
|
||||
/**
|
||||
* What a WRITE tool's call actually came to.
|
||||
*
|
||||
* A write that the user declined returns normally — the tool did its job,
|
||||
* which was to ask — so its step is `succeeded` and it was drawn with the
|
||||
* same green check as a lookup, above a card reading "Rejected. Nothing was
|
||||
* changed." On the surface whose whole promise is that a person decides, the
|
||||
* evidence row was contradicting the decision. `state` alone cannot tell
|
||||
* these apart; the payload's own `status` can.
|
||||
*/
|
||||
outcome?: PigWriteStatus;
|
||||
}
|
||||
|
||||
/** The three ways a write tool's call can end. Mirrors `PigWriteDetails`. */
|
||||
type PigWriteStatus = 'applied' | 'declined' | 'refused';
|
||||
|
||||
const NO_EVIDENCE: Evidence = { headline: null, links: [], hiddenLinkCount: 0 };
|
||||
|
||||
/**
|
||||
* A write that has stopped and is asking, drawn by the approval card instead.
|
||||
*
|
||||
* A write tool holds its own call open across the whole approval — so for as
|
||||
* long as the question is on screen, this step is `running` and renders a
|
||||
* spinning row saying "Log activity" directly above a card that says the same
|
||||
* thing with a diff and two buttons. One of the two is the decision surface and
|
||||
* the other is a spinner that reads as work in progress on a turn where nothing
|
||||
* is progressing. The step returns the moment the decision resolves it.
|
||||
*
|
||||
* The one case this over-reaches is auto mode, where a write runs without ever
|
||||
* asking: its row is withheld for the second or so the call takes, then appears
|
||||
* complete with its duration. That is the cheaper of the two mistakes — the
|
||||
* duplicate row is on the default mode and on the product's most important
|
||||
* card, the silent second is on a mode that has already said it will not ask.
|
||||
*/
|
||||
export function isParkedWriteStep(step: ToolStep): boolean {
|
||||
return step.state === 'running' && isPiggyWriteTool(step.name);
|
||||
}
|
||||
|
||||
export function PiggyToolStep({ step }: { step: ToolStep }) {
|
||||
const reveal = usePiggyConversationReveal();
|
||||
const evidence = describeStep(step);
|
||||
const input = formatArguments(step.arguments);
|
||||
const payload = step.state === 'succeeded' ? formatPayload(step.result) : null;
|
||||
const stepRef = useRef<HTMLDetailsElement>(null);
|
||||
const rawRef = useRef<HTMLDetailsElement>(null);
|
||||
const reveal = usePiggyConversationReveal();
|
||||
|
||||
/**
|
||||
* A transcript pinned to its newest message treats an unfolded step as new
|
||||
* content and scrolls past it, so the evidence the user asked to see leaves
|
||||
* the screen. `open` still holds its pre-click value inside a click handler,
|
||||
* which is both the only moment we can tell an expansion from a collapse and
|
||||
* the last moment before the growth is laid out. A collapse is left alone: it
|
||||
* shrinks the transcript, which the follow handles correctly already.
|
||||
*/
|
||||
const revealOnExpand = (details: HTMLDetailsElement | null) => {
|
||||
if (!details || details.open) return;
|
||||
reveal(details);
|
||||
};
|
||||
if (isParkedWriteStep(step)) return null;
|
||||
|
||||
return (
|
||||
<details ref={stepRef} className="group rounded-lg border border-border text-xs">
|
||||
<summary
|
||||
onClick={() => revealOnExpand(stepRef.current)}
|
||||
className={cn(
|
||||
'flex min-h-11 cursor-pointer list-none items-start gap-2 px-3 py-2',
|
||||
// Safari draws its own disclosure triangle from a pseudo-element that
|
||||
// `list-style: none` does not reach, which left two markers on the row.
|
||||
'[&::-webkit-details-marker]:hidden',
|
||||
)}
|
||||
>
|
||||
<StepIcon state={step.state} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{toolLabel(step.name)}</span>
|
||||
{step.durationMs === undefined ? null : (
|
||||
<span className="shrink-0 tabular-nums text-muted">{formatDuration(step.durationMs)}</span>
|
||||
<Disclosure
|
||||
// A transcript pinned to its newest message treats an unfolded step as
|
||||
// new content and scrolls past it, so the evidence the user asked to see
|
||||
// leaves the screen. `onExpand` fires inside the click, before the
|
||||
// browser lays the expansion out, which is the only moment early enough
|
||||
// to get in front of the follow.
|
||||
onExpand={reveal}
|
||||
className="rounded-lg border border-border text-xs"
|
||||
// The chevron is nudged onto the first line's optical centre, the same
|
||||
// half-step the state icon beside it takes, so a two-line headline does
|
||||
// not leave the two glyphs at different heights.
|
||||
summaryClassName="items-start px-3 text-xs font-normal [&>svg]:mt-0.5"
|
||||
contentClassName="flex flex-col gap-3 border-t border-border p-3"
|
||||
summary={
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
<StepIcon state={step.state} outcome={evidence.outcome} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{piggyToolLabel(step.name)}
|
||||
</span>
|
||||
{step.durationMs === undefined ? null : (
|
||||
<span className="shrink-0 tabular-nums text-muted">
|
||||
{formatDuration(step.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{evidence.headline ? (
|
||||
// Clamped shut, whole when open: a calendar headline runs to
|
||||
// several sentences, and a chip that tall stops being a chip.
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
|
||||
step.state === 'failed' ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{evidence.headline}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{input ? (
|
||||
<EvidenceSection title="Input">
|
||||
<RawBlock text={input} />
|
||||
</EvidenceSection>
|
||||
) : null}
|
||||
<EvidenceSection title="Output">
|
||||
{step.state === 'running' ? (
|
||||
<p className="text-muted">Waiting for PIG…</p>
|
||||
) : step.state === 'failed' ? (
|
||||
// The reason is already in the header, unclamped once open, so
|
||||
// repeating it here would print the same sentence twice in a row.
|
||||
<p className="text-muted">Nothing was returned; the call did not complete.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{evidence.links.length ? (
|
||||
<RecordChips links={evidence.links} hidden={evidence.hiddenLinkCount} />
|
||||
) : null}
|
||||
{payload ? (
|
||||
<Disclosure
|
||||
onExpand={reveal}
|
||||
summary="Raw payload"
|
||||
summaryClassName="text-xs font-normal text-muted"
|
||||
>
|
||||
<RawBlock text={payload} />
|
||||
</Disclosure>
|
||||
) : (
|
||||
<p className="text-muted">The tool returned no payload.</p>
|
||||
)}
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-muted transition-transform group-open:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
{evidence.headline ? (
|
||||
// Clamped shut, whole when open: a calendar headline runs to several
|
||||
// sentences, and a chip that tall stops being a chip.
|
||||
<p
|
||||
className={cn(
|
||||
'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
|
||||
step.state === 'failed' ? 'text-danger' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
{evidence.headline}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border p-3">
|
||||
{input ? (
|
||||
<Section title="Input">
|
||||
<RawBlock text={input} />
|
||||
</Section>
|
||||
) : null}
|
||||
<Section title="Output">
|
||||
{step.state === 'running' ? (
|
||||
<p className="text-muted">Waiting for PIG…</p>
|
||||
) : step.state === 'failed' ? (
|
||||
// The reason is already in the header, unclamped once open, so
|
||||
// repeating it here would print the same sentence twice in a row.
|
||||
<p className="text-muted">Nothing was returned; the call did not complete.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{evidence.links.length ? (
|
||||
<RecordLinks links={evidence.links} hidden={evidence.hiddenLinkCount} />
|
||||
) : null}
|
||||
{payload ? (
|
||||
<details ref={rawRef} className="group/raw">
|
||||
<summary
|
||||
onClick={() => revealOnExpand(rawRef.current)}
|
||||
className="inline-flex min-h-11 cursor-pointer list-none items-center gap-1 text-muted hover:text-fg [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-3.5 transition-transform group-open/raw:rotate-90"
|
||||
aria-hidden
|
||||
/>
|
||||
Raw payload
|
||||
</summary>
|
||||
<RawBlock text={payload} />
|
||||
</details>
|
||||
) : (
|
||||
<p className="text-muted">The tool returned no payload.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</EvidenceSection>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ state }: { state: ToolStep['state'] }) {
|
||||
const label = state === 'running' ? 'Running' : state === 'succeeded' ? 'Succeeded' : 'Failed';
|
||||
/**
|
||||
* What became of one call, in a glyph and a word.
|
||||
*
|
||||
* `outcome` overrides `state` for the write tools, and it has to: a proposal
|
||||
* the user declined is a call that RETURNED, so its step is `succeeded` and it
|
||||
* was drawn "Succeeded" with a positive check directly above the card saying
|
||||
* nothing had changed. Declined and refused are neither successes nor failures
|
||||
* — nothing went wrong and nothing was written — so they take the neutral mark
|
||||
* the decided colour table gives every other process outcome.
|
||||
*/
|
||||
function StepIcon({ state, outcome }: { state: ToolStep['state']; outcome?: PigWriteStatus }) {
|
||||
const unwritten = state === 'succeeded' && (outcome === 'declined' || outcome === 'refused');
|
||||
const label = unwritten
|
||||
? 'Not saved'
|
||||
: state === 'running'
|
||||
? 'Running'
|
||||
: state === 'succeeded'
|
||||
? 'Succeeded'
|
||||
: 'Failed';
|
||||
return (
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{state === 'running' ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted" aria-hidden />
|
||||
) : unwritten ? (
|
||||
<CircleSlash className="size-4 text-muted" aria-hidden />
|
||||
) : state === 'succeeded' ? (
|
||||
<CheckCircle2 className="size-4 text-positive" aria-hidden />
|
||||
) : (
|
||||
@@ -218,11 +237,19 @@ function StepIcon({ state }: { state: ToolStep['state'] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Labelled without a heading: a transcript full of `h4`s wrecks heading navigation. */
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
/**
|
||||
* A named group inside a step, labelled without a heading.
|
||||
*
|
||||
* Deliberately not the shared `Section`: this renders inside `role="log"`,
|
||||
* where every step would contribute an `h3` or `h4` and a transcript of forty
|
||||
* of them turns heading navigation — the way a screen-reader user skims a
|
||||
* page — into a list of "Input, Output, Input, Output". The label itself is the
|
||||
* shared one, so it measures the same as every other micro label in PIG.
|
||||
*/
|
||||
function EvidenceSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-label={title}>
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted">{title}</p>
|
||||
<Label className="mb-1">{title}</Label>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -230,30 +257,40 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
|
||||
function RawBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-[11px] leading-4 text-muted">
|
||||
<pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-xs leading-5 text-muted">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordLinks({ links, hidden }: { links: RecordLink[]; hidden: number }) {
|
||||
/**
|
||||
* The records the answer rests on, each openable.
|
||||
*
|
||||
* Same tail, same link, same honesty about where it lands as the approval
|
||||
* card's escape hatch — this is the other half of the promise that an answer
|
||||
* can be checked against its rows.
|
||||
*
|
||||
* `newTab`, for the same reason the approval card has it, and the reason is
|
||||
* `RecordLink`'s own: "turn it on where leaving would destroy unsubmitted state
|
||||
* — the pending approval card, and any link inside a streaming transcript."
|
||||
* This is a link inside a streaming transcript. Measured with a proposal on
|
||||
* screen, following one of these chips in-tab unmounted the transcript, and
|
||||
* Back returned to the empty starter state — no card, no answer, and no notice
|
||||
* that a decision had been abandoned. The chip that exists so an answer can be
|
||||
* checked was destroying the thing being checked.
|
||||
*/
|
||||
function RecordChips({ links, hidden }: { links: EvidenceRecord[]; hidden: number }) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label="Records read">
|
||||
{links.map((link) => (
|
||||
<li key={`${link.kind}:${link.id}`} className="min-w-0 max-w-full">
|
||||
<Link
|
||||
to={recordHref(link)}
|
||||
// The title has to follow the href: promising a list and opening a
|
||||
// record is the sort of small lie that stops a chip being trusted.
|
||||
title={
|
||||
link.kind === 'account'
|
||||
? `Open the account ${link.label}`
|
||||
: `Open the ${RECORD_LABELS[link.kind]} list`
|
||||
}
|
||||
className="flex min-h-11 max-w-full items-center rounded-md border border-border px-2 text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<span className="truncate">{link.label}</span>
|
||||
</Link>
|
||||
<RecordLink
|
||||
type={link.kind}
|
||||
id={link.id}
|
||||
label={link.label}
|
||||
newTab
|
||||
className="border border-border px-2 hover:bg-surface-2 hover:no-underline"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{hidden > 0 ? (
|
||||
@@ -279,6 +316,14 @@ function summariseResult(result: unknown): Evidence {
|
||||
const payload = asRecord(result);
|
||||
if (!payload) return NO_EVIDENCE;
|
||||
|
||||
// A write tool first: its payload is `{ tool, kind, status, recordId?,
|
||||
// reason? }` and carries no `headline`, no subject and no collections, so it
|
||||
// fell all the way through to `composeHeadline(null, [])` — an empty string.
|
||||
// The measured result was a row reading "Succeeded / Log activity / 1.4s"
|
||||
// with nothing under it, above a card saying the change had been rejected.
|
||||
const written = writeOutcome(payload);
|
||||
if (written) return written;
|
||||
|
||||
// The page tools compose the sentence they want quoted and the system prompt
|
||||
// tells the model to quote it, so deriving a second summary here would put a
|
||||
// subtly different reading of the same numbers next to the model's. They drop
|
||||
@@ -314,6 +359,39 @@ function summariseResult(result: unknown): Evidence {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A write tool's own account of itself.
|
||||
*
|
||||
* The three statuses come straight from `PigWriteDetails` in the agent, and the
|
||||
* sentence deliberately says the same thing the approval card two elements
|
||||
* below says. Two surfaces describing one decision have to agree; before this
|
||||
* they did not, and the one that disagreed was the one wearing a green check.
|
||||
*
|
||||
* `reason` is quoted rather than paraphrased for `refused`, because the reason
|
||||
* is a permission the person holds ("you cannot change a contract") and only
|
||||
* the server knows which one it was.
|
||||
*/
|
||||
function writeOutcome(payload: Record<string, unknown>): Evidence | null {
|
||||
const status = asString(payload.status);
|
||||
if (status !== 'applied' && status !== 'declined' && status !== 'refused') return null;
|
||||
// `tool` and `kind` are the shape's fingerprint: a read payload could carry a
|
||||
// `status` column off a record row (a contract's status is "executed"), and
|
||||
// that must not be read as a write outcome.
|
||||
if (!asString(payload.tool)) return null;
|
||||
|
||||
const reason = asString(payload.reason);
|
||||
const headline =
|
||||
status === 'applied'
|
||||
? 'Saved to PIG.'
|
||||
: status === 'declined'
|
||||
? 'Not saved. You declined this change.'
|
||||
: reason
|
||||
? `Not saved. ${reason}`
|
||||
: 'Not saved.';
|
||||
|
||||
return { headline, links: [], hiddenLinkCount: 0, outcome: status };
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows behind a headline, where the tool kept their ids.
|
||||
*
|
||||
@@ -324,8 +402,8 @@ function summariseResult(result: unknown): Evidence {
|
||||
* that the records behind an answer can be opened. The page tools are untouched:
|
||||
* they carry no `results` or `renewals`, so they still summarise to a sentence.
|
||||
*/
|
||||
function readHeadlineLinks(payload: Record<string, unknown>): RecordLink[] {
|
||||
const links: RecordLink[] = [];
|
||||
function readHeadlineLinks(payload: Record<string, unknown>): EvidenceRecord[] {
|
||||
const links: EvidenceRecord[] = [];
|
||||
// A search hit names its own type, because a search spans five tables.
|
||||
for (const row of asArray(payload.results)) {
|
||||
const record = asRecord(row);
|
||||
@@ -439,10 +517,10 @@ const COLLECTIONS: readonly Collection[] = [
|
||||
|
||||
function readCollections(payload: Record<string, unknown>): {
|
||||
counts: string[];
|
||||
links: RecordLink[];
|
||||
links: EvidenceRecord[];
|
||||
} {
|
||||
const counts: string[] = [];
|
||||
const links: RecordLink[] = [];
|
||||
const links: EvidenceRecord[] = [];
|
||||
|
||||
for (const collection of COLLECTIONS) {
|
||||
const rows = payload[collection.key];
|
||||
@@ -550,42 +628,6 @@ function formatPayload(value: unknown): string | null {
|
||||
: text;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- naming
|
||||
|
||||
/**
|
||||
* Named for the reader, not for the model.
|
||||
*
|
||||
* The generic fallback turns `pig_get_calendar_ahead` into "Get Calendar
|
||||
* Ahead", which is the tool's identifier with the underscores taken out. The
|
||||
* eleven tools interactive chat can actually be given get a name instead —
|
||||
* `createInteractivePigTools` is the list this must keep up with, and the four
|
||||
* lookup tools were the ones reading as "Get Record By Id" until they landed
|
||||
* here.
|
||||
*/
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
pig_get_record: 'Record in focus',
|
||||
pig_get_account_lifecycle: 'Account lifecycle',
|
||||
pig_get_workspace_summary: 'Workspace summary',
|
||||
pig_get_margin_summary: 'Margin book',
|
||||
pig_get_idle_capacity: 'Idle capacity',
|
||||
pig_get_pipeline: 'Open pipeline',
|
||||
pig_get_calendar_ahead: 'Calendar ahead',
|
||||
pig_search_records: 'Record search',
|
||||
pig_get_record_by_id: 'Record lookup',
|
||||
pig_list_renewals: 'Renewal deadlines',
|
||||
pig_list_inventory: 'Provider inventory',
|
||||
};
|
||||
|
||||
function toolLabel(name: string): string {
|
||||
return (
|
||||
TOOL_LABELS[name] ??
|
||||
name
|
||||
.replace(/^pig_/, '')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this the transcript stops quoting a figure and admits a floor instead.
|
||||
*
|
||||
@@ -638,5 +680,5 @@ function asArray(value: unknown): unknown[] {
|
||||
/** A record type the transcript knows how to open, or nothing. */
|
||||
function asRecordKind(value: unknown): RecordKind | null {
|
||||
const key = asString(value);
|
||||
return key !== null && key in RECORD_ROUTES ? (key as RecordKind) : null;
|
||||
return key !== null && key in RECORD_LABELS ? (key as RecordKind) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* The two decisions a person makes before they press send: which model answers,
|
||||
* and what it is allowed to do.
|
||||
*
|
||||
* They are one component because they are one row on every surface that shows
|
||||
* them — the workspace header, the docked panel's composer, the phone drawer —
|
||||
* and because they have to agree with each other and with what is actually put
|
||||
* on the wire. `usePiggyControls` is the half that guarantees the last part: the
|
||||
* preferences live per user in localStorage, the conversation holds what the
|
||||
* next turn will send, and this binds one to the other so the header cannot show
|
||||
* "Ask first" while the composer sends `read_only`.
|
||||
*
|
||||
* The mode control is behind a popover rather than sitting inline. Its segments
|
||||
* carry a consequence sentence that changes with the selection — three lines of
|
||||
* it in `auto` — which is exactly right in a panel and impossible in a header
|
||||
* strip. The trigger names the mode in the same words the segments use, so
|
||||
* nothing is hidden except the explanation, which is one press away.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { PiggyChatContext, PiggyMode } from '@pig/core';
|
||||
import { useOptionalIdentity } from '@/lib/identity';
|
||||
import { canAny } from '@/lib/permissions';
|
||||
import {
|
||||
usePiggyConversation,
|
||||
type PiggyConversation,
|
||||
type TranscriptMessage,
|
||||
} from '@/lib/piggy-chat';
|
||||
import { PiggyModeControl, piggyModeSummary, usePiggyMode } from '@/components/piggy/mode-control';
|
||||
import { PiggyModelPicker, usePiggyModelChoice } from '@/components/piggy/model-picker';
|
||||
import { Button, cn } from '@/components/ui';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
/**
|
||||
* The grants Piggy's write tools actually enforce, server-side, one per tool
|
||||
* family. Held here because `GET /api/piggy/status` does not report whether the
|
||||
* caller may write, and a mode control offered to someone without any of these
|
||||
* is three segments, two of which turn every proposed change into a 403.
|
||||
*
|
||||
* Display only. The tools check capabilities themselves against the team the
|
||||
* record belongs to; this asks the weaker question — could this person write
|
||||
* anywhere at all — because the mode is chosen before any record is named.
|
||||
*/
|
||||
const PIGGY_WRITE_CAPABILITIES = [
|
||||
'deal:write',
|
||||
'activity:write',
|
||||
'commitment:write',
|
||||
'contract:sign',
|
||||
] as const;
|
||||
|
||||
export interface PiggyControlsState {
|
||||
mode: PiggyMode;
|
||||
setMode: (mode: PiggyMode) => void;
|
||||
modelId: string | null;
|
||||
setModelId: (modelId: string) => void;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation and the controls that decide what it may do, created together.
|
||||
*
|
||||
* They are one hook because the order matters and getting it wrong is invisible
|
||||
* until it costs a write. The conversation owns `mode` and `modelId` — it
|
||||
* outlives every panel that draws a control for them — while the preferences
|
||||
* own the same two values because they outlive the conversation. If the
|
||||
* conversation is created first and corrected by an effect afterwards, there is
|
||||
* a window one render wide in which it holds `read_only` while the header says
|
||||
* `Ask first`, and anything that sends inside that window (a suggestion pressed
|
||||
* on mount, a thread resumed with a question already in hand) sends the mode
|
||||
* from before the correction. Seeding at construction closes the window; the
|
||||
* effects below then only carry later changes.
|
||||
*/
|
||||
export function usePiggyChatSession(options: {
|
||||
context?: PiggyChatContext;
|
||||
initialPrompt?: string;
|
||||
/** A stored transcript being resumed. See `usePiggyConversation`. */
|
||||
initialMessages?: TranscriptMessage[];
|
||||
initialConversationId?: string;
|
||||
} = {}): { conversation: PiggyConversation; controls: PiggyControlsState } {
|
||||
const { mode, setMode } = usePiggyMode();
|
||||
const model = usePiggyModelChoice();
|
||||
const identity = useOptionalIdentity();
|
||||
const canWrite = PIGGY_WRITE_CAPABILITIES.some((capability) =>
|
||||
canAny(identity ?? undefined, capability),
|
||||
);
|
||||
|
||||
const conversation = usePiggyConversation({
|
||||
...options,
|
||||
initialMode: canWrite ? mode : 'read_only',
|
||||
// Null is "no stored preference", which the relay reads as its own default.
|
||||
// Resolving it to a model id here would be this file guessing which one.
|
||||
initialModelId: model.modelId ?? undefined,
|
||||
});
|
||||
|
||||
const { setMode: setConversationMode, setModelId: setConversationModelId } = conversation;
|
||||
|
||||
/*
|
||||
* The correction B2's control performs while it is on screen, performed here
|
||||
* as well because on this surface the control is inside a popover and spends
|
||||
* almost all of its life unmounted. A stored `auto` that outlived the grant
|
||||
* that justified it would otherwise sit in localStorage and go on the wire.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!canWrite && mode !== 'read_only') setMode('read_only');
|
||||
}, [canWrite, mode, setMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setConversationMode(canWrite ? mode : 'read_only');
|
||||
}, [canWrite, mode, setConversationMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// `undefined` is "whatever the deployment's default is" — never a guess at
|
||||
// which model that is, which is why the picker's null is passed through
|
||||
// rather than resolved to `defaultModelId` here.
|
||||
setConversationModelId(model.modelId ?? undefined);
|
||||
}, [model.modelId, setConversationModelId]);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
controls: { mode, setMode, modelId: model.modelId, setModelId: model.setModelId, canWrite },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode, as a header-sized control.
|
||||
*
|
||||
* Disabled rather than hidden when nothing may be written: "you cannot change
|
||||
* this" is information, and a control that vanishes reads as a missing feature.
|
||||
*/
|
||||
export function PiggyModeButton({
|
||||
mode,
|
||||
setMode,
|
||||
canWrite,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
mode: PiggyMode;
|
||||
setMode: (mode: PiggyMode) => void;
|
||||
canWrite: boolean;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const shown = canWrite ? mode : 'read_only';
|
||||
const { label, icon: Icon } = piggyModeSummary(shown);
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={compact ? 'ghost' : 'outline'}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'min-w-0 gap-1.5 font-medium',
|
||||
// `text-xs` in both branches: 11px is the micro-LABEL size, and this
|
||||
// is a control's own word, not a label naming one. It was the last
|
||||
// non-Label 11px string in the product. Only the padding gives.
|
||||
compact ? 'h-11 px-2 text-xs' : 'h-11 px-2.5 text-xs',
|
||||
)}
|
||||
aria-label={`What Piggy may do: ${label}`}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={cn('shrink-0', shown === 'auto' && 'text-warning')}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
<ChevronDown aria-hidden className="size-3 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Above the sheet and drawer primitives at z-50, so the same trigger
|
||||
works inside the phone overlays as it does in the header. */}
|
||||
{/* 24rem, because the three segments are a grid of equal thirds and
|
||||
"Read only" needs about 7rem of it: at 20rem the control opened with
|
||||
two of its three labels truncated to "Read …" and "Ask fir…". */}
|
||||
<PopoverContent align="end" className="z-[60] w-[min(24rem,calc(100vw-1.5rem))]">
|
||||
<PiggyModeControl value={mode} onChange={setMode} canWrite={canWrite} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Both controls, in the order they are decided in: what may it do, then which
|
||||
* model does it. They wrap rather than shrink — at 22rem the pair is a whisker
|
||||
* over one line, and a truncated model name is worse than a second row.
|
||||
*/
|
||||
export function PiggyControls({
|
||||
controls,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
controls: PiggyControlsState;
|
||||
compact?: boolean;
|
||||
/** A turn is running: the next one's settings are already fixed. */
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-wrap items-center gap-1.5', className)}>
|
||||
<PiggyModeButton
|
||||
mode={controls.mode}
|
||||
setMode={controls.setMode}
|
||||
canWrite={controls.canWrite}
|
||||
compact={compact}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<PiggyModelPicker
|
||||
value={controls.modelId}
|
||||
onChange={controls.setModelId}
|
||||
compact={compact}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* What Piggy is doing, and what it has touched.
|
||||
*
|
||||
* Two views of the same question at two scopes, so they are two tabs rather
|
||||
* than two stacked panels: THIS CHAT is what the conversation on screen has
|
||||
* read and changed, and ACTIVITY is B5's ledger of every run the workspace has
|
||||
* made and what it has cost. Stacking them would put the second half of a
|
||||
* scrolling rail permanently below the fold on a laptop; a tab keeps both one
|
||||
* press away and neither of them half-visible.
|
||||
*
|
||||
* Which one opens is decided by whether there is a conversation to describe. An
|
||||
* empty transcript has no evidence, so a rail that opened on it would greet
|
||||
* every new arrival with a blank column; once a question has been asked, the
|
||||
* chat's own evidence is the more specific answer and takes the tab. A press
|
||||
* fixes the choice — after that the reader has said what they want to see and
|
||||
* the transcript does not get to overrule them.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CheckCircle2, CircleAlert, CircleSlash, FileText, Loader2, TriangleAlert } from 'lucide-react';
|
||||
import type { PiggyProposedChange } from '@pig/core';
|
||||
import type { ApprovalStep, TranscriptMessage } from '@/lib/piggy-chat';
|
||||
import { compactNumber } from '@/lib/api';
|
||||
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
|
||||
import { PiggyActivityPanel, spendMoney, spendTitle } from '@/components/piggy/activity-panel';
|
||||
import { Badge, EmptyState, Section, Stat, cn } from '@/components/ui';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export function PiggyWorkspaceRail({
|
||||
messages,
|
||||
className,
|
||||
}: {
|
||||
messages: TranscriptMessage[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const started = messages.length > 0;
|
||||
const tab = chosen ?? (started ? 'chat' : 'activity');
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setChosen}
|
||||
// `min-w-0` on every level: this is a flex child, and a flex item's
|
||||
// default `min-width: auto` lets a long run label — a title that is a
|
||||
// whole UUID — push the rail wider than the column it lives in and spill
|
||||
// over the transcript's edge. Measured at 1600: 641px of content in a
|
||||
// 319px rail.
|
||||
className={cn('flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
>
|
||||
<div className="shrink-0 border-b border-border p-2">
|
||||
{/* No palette overrides and no height override: PIG's own treatment and
|
||||
the 44px floor are in the primitive now. The rail's triggers were
|
||||
36px, which is the one place in Piggy the touch rule was actually
|
||||
being broken. */}
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">This chat</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* `mt-0` undoes the primitive's default gap: the tab strip already has a
|
||||
border under it, and a second gap below that reads as a dropped panel. */}
|
||||
<TabsContent
|
||||
value="chat"
|
||||
className="mt-0 min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
>
|
||||
<ConversationEvidence messages={messages} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="activity"
|
||||
className="mt-0 min-h-0 w-full min-w-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
>
|
||||
<PiggyActivityPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ this chat
|
||||
|
||||
interface ConversationSummary {
|
||||
turns: number;
|
||||
tools: { name: string; runs: number; failures: number }[];
|
||||
approvals: ApprovalStep[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
/** Null when no turn reported usage — which is not the same as free. */
|
||||
costMicroCents: number | null;
|
||||
}
|
||||
|
||||
function summarise(messages: TranscriptMessage[]): ConversationSummary {
|
||||
const tools = new Map<string, { name: string; runs: number; failures: number }>();
|
||||
const approvals: ApprovalStep[] = [];
|
||||
let turns = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let costMicroCents: number | null = null;
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'assistant') continue;
|
||||
turns += 1;
|
||||
inputTokens += message.inputTokens ?? 0;
|
||||
outputTokens += message.outputTokens ?? 0;
|
||||
// Left null until a figure exists, so a conversation whose provider
|
||||
// reported no usage reads as unknown rather than as costing nothing.
|
||||
if (message.costMicroCents != null) costMicroCents = (costMicroCents ?? 0) + message.costMicroCents;
|
||||
for (const tool of message.tools ?? []) {
|
||||
const entry = tools.get(tool.name) ?? { name: tool.name, runs: 0, failures: 0 };
|
||||
entry.runs += 1;
|
||||
if (tool.state === 'failed') entry.failures += 1;
|
||||
tools.set(tool.name, entry);
|
||||
}
|
||||
approvals.push(...(message.approvals ?? []));
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
tools: [...tools.values()].sort((a, b) => b.runs - a.runs),
|
||||
approvals,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costMicroCents,
|
||||
};
|
||||
}
|
||||
|
||||
function ConversationEvidence({ messages }: { messages: TranscriptMessage[] }) {
|
||||
const summary = useMemo(() => summarise(messages), [messages]);
|
||||
|
||||
if (!summary.turns) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Nothing asked yet"
|
||||
description="Every record Piggy reads and every change it proposes is listed here, so an answer can be checked against the rows behind it."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Stat size="sm" surface="inset" label="Answers" value={String(summary.turns)} />
|
||||
<Stat
|
||||
size="sm"
|
||||
surface="inset"
|
||||
label="Tool calls"
|
||||
value={String(summary.tools.reduce((total, tool) => total + tool.runs, 0))}
|
||||
/>
|
||||
<Stat
|
||||
size="sm"
|
||||
surface="inset"
|
||||
label="Tokens"
|
||||
value={`${compactNumber(summary.inputTokens)} / ${compactNumber(summary.outputTokens)}`}
|
||||
hint="in / out"
|
||||
/>
|
||||
{/* The exact figure hangs off a wrapper because the tile itself takes no
|
||||
`title`: sub-cent spend is rounded for reading and must still be
|
||||
recoverable to the micro-cent, which is the number an operator
|
||||
reconciles against. */}
|
||||
<div className="min-w-0" title={spendTitle(summary.costMicroCents)}>
|
||||
<Stat size="sm" surface="inset" label="Spend" value={spendMoney(summary.costMicroCents)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.approvals.length ? (
|
||||
<Section title="Changes" tone="micro" level={3}>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{summary.approvals.map((approval) => (
|
||||
<li key={approval.change.id}>
|
||||
<ChangeRow approval={approval} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* Not "records read": the same list carries the write tools a turn
|
||||
proposed, and calling `pig_log_activity` a record read would be a small
|
||||
lie on the one panel whose job is the audit trail. */}
|
||||
{summary.tools.length ? (
|
||||
<Section title="Tools used" tone="micro" level={3}>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{summary.tools.map((tool) => (
|
||||
<li
|
||||
key={tool.name}
|
||||
className="flex items-center gap-2 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<FileText aria-hidden className="size-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg" title={tool.name}>
|
||||
{piggyToolLabel(tool.name)}
|
||||
</span>
|
||||
{tool.failures ? (
|
||||
<Badge tone="danger">{tool.failures} failed</Badge>
|
||||
) : null}
|
||||
<span className="nums shrink-0 text-muted">×{tool.runs}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs leading-5 text-muted">
|
||||
Open a step in the transcript to see what each of these returned.
|
||||
</p>
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A proposed change, at rail width.
|
||||
*
|
||||
* The card in the transcript is the place a change is read and answered; this
|
||||
* is the index to it, so it carries the summary, what became of it, and nothing
|
||||
* that would invite a decision from a column too narrow to show the diff.
|
||||
*/
|
||||
function ChangeRow({ approval }: { approval: ApprovalStep }) {
|
||||
const state = CHANGE_STATES[approval.state];
|
||||
const Icon = state.icon;
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border px-2.5 py-2">
|
||||
<Icon aria-hidden className={cn('mt-0.5 size-3.5 shrink-0', state.className)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs leading-5 text-fg">{approval.change.summary}</p>
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted">
|
||||
{state.label}
|
||||
{recordLabel(approval.change) ? ` · ${recordLabel(approval.change)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same table the approval card is drawn from, and for the same reason:
|
||||
* colour marks what needs a person, not what happened. This is an index of
|
||||
* changes, so a column in which every applied write is green leaves the one row
|
||||
* still waiting for an answer indistinguishable from the four above it.
|
||||
*/
|
||||
const CHANGE_STATES: Record<
|
||||
ApprovalStep['state'],
|
||||
{ label: string; icon: typeof CheckCircle2; className: string }
|
||||
> = {
|
||||
// A circle for "act on this", a triangle for "this went wrong" — the same two
|
||||
// shapes `components/status.tsx` spends, so the scan works without hue.
|
||||
pending: { label: 'Waiting for you', icon: CircleAlert, className: 'text-warning' },
|
||||
submitting: { label: 'Sending your decision', icon: Loader2, className: 'animate-spin text-muted' },
|
||||
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-muted' },
|
||||
rejected: { label: 'Rejected', icon: CircleSlash, className: 'text-muted' },
|
||||
failed: { label: 'Not applied', icon: TriangleAlert, className: 'text-danger' },
|
||||
};
|
||||
|
||||
function recordLabel(change: PiggyProposedChange): string | null {
|
||||
if (!change.record) return null;
|
||||
return change.record.label ?? change.record.type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* The blank transcript, on every Piggy surface.
|
||||
*
|
||||
* There used to be two of these. The workspace had this one; the dock, the
|
||||
* sheet and the phone drawer had a second, narrower one that offered three
|
||||
* read openers, showed a Sparkles glyph and never once mentioned that Piggy
|
||||
* can write — the product's headline capability, missing from the surface most
|
||||
* people keep open all day. One agent gets one front door, so this is now it,
|
||||
* and `narrow` is what the 22rem column asks for instead of a second file.
|
||||
*
|
||||
* It has one job that the old blank transcript did not have: Piggy can write
|
||||
* now, and nobody will discover that by typing into a box. So the openers are
|
||||
* in two columns — what it can find out, and what it can get done — and the
|
||||
* second column says plainly that a change is proposed and waits for a person.
|
||||
*
|
||||
* Every sentence here comes from `lib/piggy-copy`. It is written once because
|
||||
* it was written three times and had already drifted.
|
||||
*
|
||||
* The read openers come from `piggySuggestions`, which picks them by the one
|
||||
* read tool this context resolves to, so every line is one Piggy can ground.
|
||||
* The write openers are held here because there is no equivalent table for them
|
||||
* yet, and they are written against the same constraint: each one is answerable
|
||||
* with the tools a `/piggy` turn is actually given — the workspace summary, the
|
||||
* record lookups, the renewals list — and none of them names a record that only
|
||||
* exists in the demo book.
|
||||
*
|
||||
* Pressing a write opener while Piggy is in Read only moves it to Ask first.
|
||||
* That is a change to a permission, so it is never silent: the card says so
|
||||
* before it is pressed, and the mode control in the header changes with it. Ask
|
||||
* first cannot write unattended — it proposes, and the Apply button is the
|
||||
* person — so the escalation this performs is from "no tools" to "a proposal
|
||||
* you must approve", which is the thing the user just asked for by pressing it.
|
||||
*/
|
||||
import { ArrowRight, PenLine, Search } from 'lucide-react';
|
||||
import type { PiggyChatContext, PiggyMode } from '@pig/core';
|
||||
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
|
||||
import { piggySuggestions } from '@/lib/piggy-suggestions';
|
||||
import { PiggyMark } from '@/components/PiggyMark';
|
||||
import { Label, cn } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* Openers that end in a change to the book.
|
||||
*
|
||||
* Every write tool takes a record id, and none of the tools a `/piggy` turn is
|
||||
* given returns one from the page context alone — so each of these is a lookup
|
||||
* followed by a write, and none of them names a record. Naming one would make
|
||||
* them land beautifully on the seeded demo book and fail on the first real
|
||||
* deployment, which is the opposite of the trade this file should make.
|
||||
*
|
||||
* The consequence is stated to the user rather than hidden: where the sentence
|
||||
* does not identify the record, Piggy asks which one instead of choosing. That
|
||||
* is the behaviour a CRM should have, and it is measurably what the default
|
||||
* model does — see the note under the column.
|
||||
*/
|
||||
const WRITE_STARTERS = [
|
||||
'Find the block furthest from break-even and log a note on its account.',
|
||||
'Look up the contract renewing soonest and log a call about extending it.',
|
||||
'Add a task to chase the account we have not spoken to in a month.',
|
||||
];
|
||||
|
||||
const READ_STARTERS_SHOWN = 3;
|
||||
|
||||
export function PiggyWorkspaceStarters({
|
||||
context,
|
||||
mode,
|
||||
canWrite,
|
||||
onAsk,
|
||||
onAskWithChange,
|
||||
narrow = false,
|
||||
}: {
|
||||
context?: PiggyChatContext;
|
||||
/** Only to word the note. The escalation itself belongs to the thread. */
|
||||
mode: PiggyMode;
|
||||
canWrite: boolean;
|
||||
onAsk: (text: string) => void;
|
||||
/**
|
||||
* An opener that ends in a write. The thread raises the mode first and sends
|
||||
* once the conversation is holding the new one — `send` reads the mode out of
|
||||
* the conversation, so sending in the same tick would ask for a change with
|
||||
* the write tools still withheld.
|
||||
*/
|
||||
onAskWithChange: (text: string) => void;
|
||||
/**
|
||||
* The surface is under ~26rem — the dock, the phone drawer, the workspace's
|
||||
* middle column on a phone. Stacks the two groups, halves the openers and
|
||||
* takes the short form of every sentence.
|
||||
*/
|
||||
narrow?: boolean;
|
||||
}) {
|
||||
/*
|
||||
* Two openers each on a phone, three on a desktop.
|
||||
*
|
||||
* Not a taste decision: the transcript sticks to the bottom of its
|
||||
* scrollport, so anything taller than the viewport opens with its own
|
||||
* heading scrolled off the top. Measured at 393x852 the six-opener version
|
||||
* overran by about 180px, which put the pig, the headline and the first
|
||||
* column header above the fold on the screen that is supposed to introduce
|
||||
* the product.
|
||||
*/
|
||||
const perGroup = narrow ? 2 : READ_STARTERS_SHOWN;
|
||||
const reads = piggySuggestions(context).slice(0, perGroup);
|
||||
const writes = WRITE_STARTERS.slice(0, perGroup);
|
||||
|
||||
return (
|
||||
// `flex-1` rather than `h-full`: the conversation viewport's content element
|
||||
// is sized by its children, so a percentage height resolves to nothing.
|
||||
// Centred where there is room to spare, and airless where there is not: on
|
||||
// a narrow surface this front door has to land whole above the composer at
|
||||
// 393x852 and in the dock's 22rem column, and it is measured to.
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
|
||||
narrow ? 'gap-3' : 'gap-6 py-6',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* The agent's mark in the agent's colour. Accent is identity in this
|
||||
product and never meaning, and this is the one place on the front
|
||||
door where the identity is the subject. */}
|
||||
<PiggyMark className={cn('text-accent-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
|
||||
<h2
|
||||
className={cn(
|
||||
'font-semibold tracking-tight',
|
||||
narrow ? 'mt-2' : 'mt-3',
|
||||
// Sentence case at the section-heading step in the dock, at the
|
||||
// page-title step on the full workspace. Nothing between the two:
|
||||
// the 18px it used to sit at belongs to no step in the scale.
|
||||
narrow ? 'text-base' : 'text-xl',
|
||||
)}
|
||||
>
|
||||
{piggyLine(piggyCopy.headline, narrow)}
|
||||
</h2>
|
||||
{/* Narrow keeps the boundary and drops the mechanism: the headline has
|
||||
already said a change waits for approval, and the note under the
|
||||
write openers says it again where it is about to matter. Repeating
|
||||
it a third time in a 22rem column costs three lines the openers
|
||||
need to land above the composer. */}
|
||||
<p className={cn('max-w-xl text-sm text-muted', narrow ? 'mt-1 leading-5' : 'mt-1.5 leading-6')}>
|
||||
{narrow
|
||||
? piggyLine(piggyCopy.capability, true)
|
||||
: `${piggyCopy.capability.long} ${piggyCopy.safety.long}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn('grid', narrow ? 'grid-cols-1 gap-3' : 'gap-4 sm:grid-cols-2')}>
|
||||
<StarterGroup
|
||||
narrow={narrow}
|
||||
icon={<Search aria-hidden className="size-3.5" />}
|
||||
title={piggyLine(piggyCopy.readGroupTitle, narrow)}
|
||||
/* Dropped on a phone, where the two columns are stacked and every
|
||||
line costs: the hero above has just said the same thing, and the
|
||||
note that has to survive is the one about writing. */
|
||||
note={narrow ? null : piggyLine(piggyCopy.readGroupNote, narrow)}
|
||||
>
|
||||
{reads.map((suggestion) => (
|
||||
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
|
||||
{suggestion}
|
||||
</StarterButton>
|
||||
))}
|
||||
</StarterGroup>
|
||||
|
||||
<StarterGroup
|
||||
narrow={narrow}
|
||||
icon={<PenLine aria-hidden className="size-3.5" />}
|
||||
title={piggyLine(piggyCopy.writeGroupTitle, narrow)}
|
||||
note={piggyLine(
|
||||
canWrite
|
||||
? mode === 'read_only'
|
||||
? piggyCopy.writeGroupNote.readOnly
|
||||
: piggyCopy.writeGroupNote.askFirst
|
||||
: piggyCopy.writeGroupNote.noAccess,
|
||||
narrow,
|
||||
)}
|
||||
>
|
||||
{writes.map((suggestion) => (
|
||||
<StarterButton
|
||||
key={suggestion}
|
||||
disabled={!canWrite}
|
||||
onClick={() => onAskWithChange(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</StarterButton>
|
||||
))}
|
||||
</StarterGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarterGroup({
|
||||
icon,
|
||||
title,
|
||||
note,
|
||||
narrow,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
note: string | null;
|
||||
/** Measured, not guessed: see the note on the drawer's height below. */
|
||||
narrow: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
/*
|
||||
* The gaps close on a narrow surface rather than the content thinning.
|
||||
* Measured at 393x852 the phone drawer gives this front door 458px and it
|
||||
* wanted 498, and the 40px it was over came out of white space rather than
|
||||
* out of an opener — a column of two questions is the whole point of the
|
||||
* second column, and one of them is not a choice.
|
||||
*/
|
||||
<section className={cn('flex min-w-0 flex-col', narrow ? 'gap-1.5' : 'gap-2')}>
|
||||
<Label as="h3" className="flex items-center gap-1.5">
|
||||
{icon}
|
||||
{title}
|
||||
</Label>
|
||||
<div className={cn('flex flex-col', narrow ? 'gap-1' : 'gap-1.5')}>{children}</div>
|
||||
{note ? <p className="text-xs leading-4 text-muted">{note}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StarterButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex min-h-11 w-full items-center gap-2 rounded-lg border border-border bg-surface',
|
||||
'px-3 py-2 text-left text-sm leading-5 transition-colors',
|
||||
'hover:border-fg/20 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">{children}</span>
|
||||
<ArrowRight
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* A stored conversation, read back into the shape the transcript renders.
|
||||
*
|
||||
* The store keeps one row per THING that happened — a question, a tool call, a
|
||||
* proposed change, an answer — because that is what an append-only ledger has
|
||||
* to do to survive a turn that dies half-way through. The transcript renders one
|
||||
* block per TURN, with its tools and its approval cards inside it. Folding the
|
||||
* rows back into turns is therefore not a formality; it is the difference
|
||||
* between reopening a conversation and reopening a log file.
|
||||
*
|
||||
* The wire shapes below mirror `PiggyConversationDetail` in
|
||||
* apps/api/src/services/piggy-conversations.ts. They are restated rather than
|
||||
* imported because the browser cannot import from the API package, and every
|
||||
* field is optional-tolerant on read for the same reason: a row written by an
|
||||
* older build must reopen as a slightly plainer message, never as a blank pane.
|
||||
*/
|
||||
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
|
||||
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';
|
||||
|
||||
export interface StoredPiggyMessage {
|
||||
id: string;
|
||||
seq: number;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
reasoning: string | null;
|
||||
model: string | null;
|
||||
mode: PiggyMode | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
costMicroCents: number | null;
|
||||
finishReason: string | null;
|
||||
tool: {
|
||||
callId: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown> | null;
|
||||
result: Record<string, unknown> | null;
|
||||
ok: boolean | null;
|
||||
} | null;
|
||||
approval: {
|
||||
id: string;
|
||||
change: PiggyProposedChange;
|
||||
decision: 'apply' | 'reject' | null;
|
||||
decidedAt: string | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StoredPiggyConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string | null;
|
||||
mode: PiggyMode | null;
|
||||
context: PiggyChatContext | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: StoredPiggyMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A change that was proposed and never answered.
|
||||
*
|
||||
* It is not offered as pending on reopening, and that is deliberate rather than
|
||||
* cautious: the agent holds a proposal for the length of its own turn, so by the
|
||||
* time a transcript is read back from the database there is nothing left at the
|
||||
* other end for an Apply button to reach. Showing the buttons would collect an
|
||||
* error; showing the card settled says what happened.
|
||||
*/
|
||||
const UNANSWERED = 'This change was never answered, and the turn that proposed it has ended.';
|
||||
|
||||
export function toTranscript(messages: StoredPiggyMessage[]): TranscriptMessage[] {
|
||||
const transcript: TranscriptMessage[] = [];
|
||||
// The assistant turn currently being assembled. Tool rows and approval rows
|
||||
// belong to whichever answer they were streamed alongside, and they arrive
|
||||
// BEFORE its text — the answer is written last.
|
||||
let open: TranscriptMessage | null = null;
|
||||
|
||||
for (const row of [...messages].sort((a, b) => a.seq - b.seq)) {
|
||||
if (row.role === 'user') {
|
||||
if (open) transcript.push(open);
|
||||
open = null;
|
||||
transcript.push({ id: row.id, role: 'user', content: row.content });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.tool) {
|
||||
const turn: TranscriptMessage = open ?? newTurn(row.id);
|
||||
turn.tools = [...(turn.tools ?? []), toToolStep(row.tool)];
|
||||
open = turn;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.approval) {
|
||||
const turn: TranscriptMessage = open ?? newTurn(row.id);
|
||||
turn.approvals = [...(turn.approvals ?? []), toApprovalStep(row.approval)];
|
||||
open = turn;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A second answer inside one turn cannot happen on the wire, but a repaired
|
||||
// or re-run conversation could hold one; starting a fresh block is the only
|
||||
// reading that does not silently concatenate two answers into one.
|
||||
if (open && open.content) {
|
||||
transcript.push(open);
|
||||
open = null;
|
||||
}
|
||||
const turn: TranscriptMessage = open ?? newTurn(row.id);
|
||||
turn.id = row.id;
|
||||
turn.content = row.content;
|
||||
turn.reasoning = row.reasoning ?? undefined;
|
||||
turn.model = row.model ?? undefined;
|
||||
turn.mode = row.mode ?? undefined;
|
||||
turn.inputTokens = row.inputTokens;
|
||||
turn.outputTokens = row.outputTokens;
|
||||
turn.costMicroCents = row.costMicroCents;
|
||||
turn.finishReason = row.finishReason ?? undefined;
|
||||
turn.error = row.error ?? undefined;
|
||||
transcript.push(turn);
|
||||
open = null;
|
||||
}
|
||||
|
||||
if (open) transcript.push(open);
|
||||
return transcript;
|
||||
}
|
||||
|
||||
function newTurn(id: string): TranscriptMessage {
|
||||
return { id, role: 'assistant', content: '', tools: [], approvals: [], pending: false };
|
||||
}
|
||||
|
||||
function toToolStep(tool: NonNullable<StoredPiggyMessage['tool']>): ToolStep {
|
||||
return {
|
||||
id: tool.callId,
|
||||
name: tool.name,
|
||||
arguments: tool.arguments ?? {},
|
||||
// `ok: null` is a call the store never saw finish. It is drawn as succeeded
|
||||
// rather than running: a spinner in a transcript read back from disk would
|
||||
// never stop, and the payload beside it is the evidence either way.
|
||||
state: tool.ok === false ? 'failed' : 'succeeded',
|
||||
result: tool.result ?? undefined,
|
||||
/*
|
||||
* No clock. `startedAt` is `performance.now()` on the live path, which is
|
||||
* milliseconds since this document loaded and means nothing for a call made
|
||||
* last Tuesday. Zero with no `durationMs` renders as a step with no timing,
|
||||
* which is honest; a computed one would be fiction.
|
||||
*/
|
||||
startedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toApprovalStep(approval: NonNullable<StoredPiggyMessage['approval']>): ApprovalStep {
|
||||
if (approval.decision === 'apply') {
|
||||
return { change: approval.change, state: 'applied', decision: 'apply' };
|
||||
}
|
||||
if (approval.decision === 'reject') {
|
||||
return { change: approval.change, state: 'rejected', decision: 'reject' };
|
||||
}
|
||||
return { change: approval.change, state: 'failed', error: UNANSWERED };
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* The Piggy workspace: history, the conversation, and the evidence beside it.
|
||||
*
|
||||
* Three columns on a wide screen, and the interesting decisions are all about
|
||||
* what happens when there are not three columns' worth of room. In order of
|
||||
* what gives way first:
|
||||
*
|
||||
* ≥ 1536 history rail, conversation, activity rail. The activity rail is the
|
||||
* last thing added because it is the least urgent of the three: it
|
||||
* says what has already happened.
|
||||
* ≥ 1280 history and conversation. Activity moves into a sheet, on a button
|
||||
* in the header, because a third column here leaves the middle one at
|
||||
* about 420px — narrower than the phone layout, on the pane the whole
|
||||
* screen exists to show.
|
||||
* ≥ 1024 the history rail collapses to initials by default, which buys the
|
||||
* conversation 230px and keeps the threads reachable in one click.
|
||||
* < 1024 one column. Both rails become sheets on header buttons, the
|
||||
* composer keeps the floor of the box, and nothing is stacked above
|
||||
* the transcript except the header — two rows on a phone held
|
||||
* upright, one on any screen under 560px tall, because there the
|
||||
* second row is a fifth of everything the reader came for.
|
||||
*
|
||||
* The transcript, the composer and the approval cards are `PiggyChatPanel` —
|
||||
* the same component the dock and the phone drawer use — so this file is a
|
||||
* layout and a set of decisions about conversations, not a second chat client.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
History,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRight,
|
||||
PanelRightClose,
|
||||
SquarePen,
|
||||
} from 'lucide-react';
|
||||
import type { PiggyChatContext } from '@pig/core';
|
||||
import { get, post } from '@/lib/api';
|
||||
import { useHasVerticalRoom, useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';
|
||||
import { usePiggyContext } from '@/lib/piggy-context';
|
||||
import type { PiggyConversation, TranscriptMessage } from '@/lib/piggy-chat';
|
||||
import { PiggyChatPanel, PiggyUnavailable, usePiggyStatus } from '@/components/PiggyChat';
|
||||
import { PiggyConversationList } from '@/components/piggy/conversation-list';
|
||||
import { Button, EmptyState, Skeleton, cn } from '@/components/ui';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './controls';
|
||||
import { PiggyWorkspaceRail } from './evidence';
|
||||
import { PiggyWorkspaceStarters } from './starters';
|
||||
import { toTranscript, type StoredPiggyConversation } from './stored-transcript';
|
||||
|
||||
/**
|
||||
* The context every turn from this page carries.
|
||||
*
|
||||
* A constant, not an inline object: `usePiggyContext` re-publishes whenever the
|
||||
* value changes identity, and the page tool this resolves to
|
||||
* (`pig_get_workspace_summary`) is fixed for the whole workspace.
|
||||
*/
|
||||
const WORKSPACE_CONTEXT: PiggyChatContext = { type: 'page', route: '/piggy' };
|
||||
|
||||
/** Where the activity rail earns a column of its own rather than a sheet. */
|
||||
const ACTIVITY_COLUMN_BREAKPOINT = 1536;
|
||||
|
||||
/** Where the history rail is worth showing expanded by default. */
|
||||
const WIDE_HISTORY_BREAKPOINT = 1280;
|
||||
|
||||
/**
|
||||
* Tailwind `sm`. Above it the title row has room for the mode and model
|
||||
* controls beside the thread's name; below it, it has not.
|
||||
*/
|
||||
const SHARED_TITLE_ROW_BREAKPOINT = 640;
|
||||
|
||||
const HISTORY_STORAGE_KEY = 'pig.piggy.workspace.history';
|
||||
const ACTIVITY_STORAGE_KEY = 'pig.piggy.workspace.activity';
|
||||
|
||||
/** The conversation being read, as a URL — so a run in the ledger can link to it. */
|
||||
const CONVERSATION_PARAM = 'conversation';
|
||||
|
||||
export function PiggyWorkspace() {
|
||||
const status = usePiggyStatus();
|
||||
usePiggyContext(WORKSPACE_CONTEXT);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const hasActivityColumn = useMediaQuery(`(min-width: ${ACTIVITY_COLUMN_BREAKPOINT}px)`);
|
||||
const wideHistory = useMediaQuery(`(min-width: ${WIDE_HISTORY_BREAKPOINT}px)`);
|
||||
|
||||
const [historyExpanded, setHistoryExpanded] = useStoredFlag(HISTORY_STORAGE_KEY, wideHistory);
|
||||
const [activityOpen, setActivityOpen] = useStoredFlag(ACTIVITY_STORAGE_KEY, hasActivityColumn);
|
||||
const [historySheet, setHistorySheet] = useState(false);
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const activeId = params.get(CONVERSATION_PARAM);
|
||||
/**
|
||||
* Bumped by "New conversation" so the thread below is rebuilt even when the
|
||||
* URL does not change — pressing New twice must give you two fresh threads,
|
||||
* not one thread and a control that appears to be broken.
|
||||
*/
|
||||
const [newThread, setNewThread] = useState(0);
|
||||
const [pendingAsk, setPendingAsk] = useState<{ id: string; message: string } | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const select = useCallback(
|
||||
(id: string) => {
|
||||
// Replaced rather than pushed: reading four threads should not put four
|
||||
// entries in the history stack for Back to walk out through.
|
||||
setParams({ [CONVERSATION_PARAM]: id }, { replace: true });
|
||||
setHistorySheet(false);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const startNew = useCallback(() => {
|
||||
setParams({}, { replace: true });
|
||||
setNewThread((count) => count + 1);
|
||||
setHistorySheet(false);
|
||||
}, [setParams]);
|
||||
|
||||
/**
|
||||
* A conversation is created by asking the first question, not by pressing New.
|
||||
*
|
||||
* The row's title is derived server-side from that first message, so creating
|
||||
* eagerly would fill the sidebar with rows called "New conversation" every
|
||||
* time somebody opened the page and thought better of it.
|
||||
*/
|
||||
const created = useCallback(
|
||||
(conversation: StoredPiggyConversation, message: string) => {
|
||||
// Seeded so the detail query below answers from cache: without it, the
|
||||
// thread would remount into a loading skeleton for the length of a round
|
||||
// trip, immediately after the user pressed send.
|
||||
queryClient.setQueryData(conversationKey(conversation.id), conversation);
|
||||
void queryClient.invalidateQueries({ queryKey: ['piggy', 'conversations'] });
|
||||
setPendingAsk({ id: conversation.id, message });
|
||||
setParams({ [CONVERSATION_PARAM]: conversation.id }, { replace: true });
|
||||
},
|
||||
[queryClient, setParams],
|
||||
);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: conversationKey(activeId ?? ''),
|
||||
queryFn: () => get<StoredPiggyConversation>(`/api/piggy/conversations/${activeId}`),
|
||||
enabled: Boolean(activeId),
|
||||
/*
|
||||
* Always read fresh. The relay appends every turn to this conversation, so
|
||||
* a cached copy taken before the last three questions is a transcript with
|
||||
* three turns missing — which is what "come back to a thread you were just
|
||||
* in" looks like. Refetching under a live conversation is safe because the
|
||||
* hook stops adopting the seed the moment this session sends anything (see
|
||||
* `touched` in usePiggyConversation).
|
||||
*/
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Stable while the fetch's payload is, so the thread's seed effect is not
|
||||
// handed a new array on every parent render.
|
||||
const storedTranscript = useMemo(
|
||||
() => (detail.data ? toTranscript(detail.data.messages) : undefined),
|
||||
[detail.data],
|
||||
);
|
||||
|
||||
if (status.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
<Skeleton className="h-12 rounded-xl" />
|
||||
<Skeleton className="min-h-0 flex-1 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!status.data?.canUse) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center p-6">
|
||||
<PiggyUnavailable status={status.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (
|
||||
<PiggyConversationList
|
||||
activeId={activeId}
|
||||
onSelect={select}
|
||||
onNew={startNew}
|
||||
collapsed={!historyExpanded}
|
||||
runningId={running ? activeId : null}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
/*
|
||||
* The thread is FIRST in the DOM and second on screen.
|
||||
*
|
||||
* Measured from a cold load of /piggy: reaching the composer cost 72 Tab
|
||||
* presses, 38 of them the conversation rail — nineteen rows, each with its
|
||||
* own action button — and "Skip to content" landed immediately BEFORE the
|
||||
* rail rather than after it, so the one affordance built to fix this walked
|
||||
* straight into the thing that caused it. It grows with use: 123
|
||||
* conversations exist in this workspace already.
|
||||
*
|
||||
* Reordering rather than removing tab stops, because every one of those
|
||||
* stops is a control somebody needs: the rename and delete actions are
|
||||
* reachable by keyboard only through the per-row button, and taking it out
|
||||
* of tab order would trade a long walk for a dead end. `order` is the same
|
||||
* instrument the delete dialog already uses to put the destructive action
|
||||
* first in the DOM and second on screen — declared reading order and
|
||||
* declared visual order are allowed to differ, and this is what for.
|
||||
*
|
||||
* It also puts the transcript before the navigation for a screen reader,
|
||||
* which is the arrangement skip links exist to approximate.
|
||||
*/
|
||||
<div className="flex h-full min-h-0 w-full overflow-hidden">
|
||||
<PiggyWorkspaceThread
|
||||
key={activeId ?? `new-${newThread}`}
|
||||
conversationId={activeId}
|
||||
title={detail.data?.title ?? null}
|
||||
initialMessages={storedTranscript}
|
||||
loading={Boolean(activeId) && detail.isLoading}
|
||||
loadError={detail.isError ? detail.error : null}
|
||||
autoSend={pendingAsk?.id === activeId ? pendingAsk.message : undefined}
|
||||
onAutoSent={() => setPendingAsk(null)}
|
||||
onCreated={created}
|
||||
onRunningChange={setRunning}
|
||||
onNew={startNew}
|
||||
onOpenHistory={() => setHistorySheet(true)}
|
||||
historyExpanded={historyExpanded}
|
||||
onToggleHistory={() => setHistoryExpanded(!historyExpanded)}
|
||||
showHistoryToggle={!isMobile}
|
||||
activityOpen={hasActivityColumn && activityOpen}
|
||||
onToggleActivity={() => setActivityOpen(!activityOpen)}
|
||||
activityInColumn={hasActivityColumn}
|
||||
/>
|
||||
|
||||
{isMobile ? null : (
|
||||
<aside
|
||||
className={cn(
|
||||
// `-order-1` puts it back on the left. See the note above.
|
||||
'-order-1 flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
|
||||
// The rail sets its own 3.75rem when collapsed; only the expanded
|
||||
// width is the parent's to decide.
|
||||
historyExpanded && 'w-[17rem]',
|
||||
)}
|
||||
aria-label="Piggy conversations"
|
||||
>
|
||||
{list}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Both workspace sheets are 20rem — the same width as the activity
|
||||
column beside the transcript. Two overlays on one screen at 19 and
|
||||
21rem is a difference nobody can name and everybody can see. */}
|
||||
<Sheet open={historySheet} onOpenChange={setHistorySheet}>
|
||||
<SheetContent side="left" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
|
||||
{/* Visible, not `sr-only`. A sheet that slides in over the
|
||||
transcript with no name on it asks the reader to work out what
|
||||
they opened from the contents. `pr-14` keeps the sentence clear
|
||||
of the dismiss control the primitive pins to the corner. */}
|
||||
<SheetHeader band className="pr-14">
|
||||
<SheetTitle>Conversations</SheetTitle>
|
||||
<SheetDescription>Pick one up from where it stopped.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1">{isMobile ? list : null}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- thread
|
||||
|
||||
function PiggyWorkspaceThread({
|
||||
conversationId,
|
||||
title,
|
||||
initialMessages,
|
||||
loading,
|
||||
loadError,
|
||||
autoSend,
|
||||
onAutoSent,
|
||||
onCreated,
|
||||
onRunningChange,
|
||||
onNew,
|
||||
onOpenHistory,
|
||||
historyExpanded,
|
||||
onToggleHistory,
|
||||
showHistoryToggle,
|
||||
activityOpen,
|
||||
onToggleActivity,
|
||||
activityInColumn,
|
||||
}: {
|
||||
conversationId: string | null;
|
||||
title: string | null;
|
||||
initialMessages?: TranscriptMessage[];
|
||||
loading: boolean;
|
||||
loadError: unknown;
|
||||
autoSend?: string;
|
||||
onAutoSent: () => void;
|
||||
onCreated: (conversation: StoredPiggyConversation, message: string) => void;
|
||||
onRunningChange: (running: boolean) => void;
|
||||
onNew: () => void;
|
||||
onOpenHistory: () => void;
|
||||
historyExpanded: boolean;
|
||||
onToggleHistory: () => void;
|
||||
showHistoryToggle: boolean;
|
||||
activityOpen: boolean;
|
||||
onToggleActivity: () => void;
|
||||
activityInColumn: boolean;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
/*
|
||||
* Whether this viewport can afford a second header row.
|
||||
*
|
||||
* `useIsMobile` is width-only, so a phone in landscape and a phone with the
|
||||
* keyboard up both got the two-row header built for a 393x852 portrait
|
||||
* screen — and were left with a measured 40px of transcript on the pane the
|
||||
* whole page exists to show. Below 560px tall the controls fold back onto
|
||||
* the title row, where they still fit because a landscape phone is wide.
|
||||
*/
|
||||
const hasHeaderRoom = useHasVerticalRoom();
|
||||
/**
|
||||
* Whether the title row is wide enough to carry the controls as well.
|
||||
*
|
||||
* Folding them up is only an improvement where there is width to fold into:
|
||||
* a landscape phone has 852px and loses nothing, a portrait phone with the
|
||||
* keyboard up has 393px and would crush the thread's name to four
|
||||
* characters to save one row. That screen keeps the second row.
|
||||
*
|
||||
* Measured, and left alone deliberately: the controls `flex-wrap`, so forcing
|
||||
* them onto a 393px title row beside three 44px icon buttons does not fold
|
||||
* anything — it wraps them and rebuilds the same two rows with a crushed
|
||||
* title as well. The transcript budget on that screen is reclaimed from the
|
||||
* composer and this header's own padding instead; see below and `PiggyChat`.
|
||||
*/
|
||||
const canShareTitleRow = useMediaQuery(`(min-width: ${SHARED_TITLE_ROW_BREAKPOINT}px)`);
|
||||
const { conversation, controls } = usePiggyChatSession({
|
||||
context: WORKSPACE_CONTEXT,
|
||||
initialMessages,
|
||||
initialConversationId: conversationId ?? undefined,
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
/*
|
||||
* The rail as an overlay, below the width where it earns a column. It lives
|
||||
* here rather than beside the history sheet in the parent because its first
|
||||
* tab describes THIS conversation, and the parent has no transcript to
|
||||
* describe — a sheet opened from up there would tell a phone user in the
|
||||
* middle of a conversation that nothing had been asked yet.
|
||||
*/
|
||||
const [activitySheet, setActivitySheet] = useState(false);
|
||||
/** A write opener waiting for the mode it needs. See `askWithChange`. */
|
||||
const [escalating, setEscalating] = useState<string | null>(null);
|
||||
const autoSent = useRef(false);
|
||||
|
||||
const { running, send: sendTurn, messages } = conversation;
|
||||
|
||||
useEffect(() => {
|
||||
onRunningChange(running);
|
||||
// The rail's running dot belongs to whichever thread is on screen, so the
|
||||
// flag has to be lowered when this one is replaced as well as when its turn
|
||||
// ends — otherwise switching conversations mid-answer leaves a dot spinning
|
||||
// on a thread nothing is running in.
|
||||
return () => onRunningChange(false);
|
||||
}, [running, onRunningChange]);
|
||||
|
||||
/**
|
||||
* Send, creating the stored conversation first when this is the first thing
|
||||
* said in it.
|
||||
*
|
||||
* The order is forced by the server: the row's title comes from the opening
|
||||
* message, and the id has to exist before the turn is streamed so that the
|
||||
* relay continues the same conversation the sidebar is listing.
|
||||
*/
|
||||
const ask = useCallback(
|
||||
(text?: string, from?: TranscriptMessage[]) => {
|
||||
const message = (text ?? conversation.draft).trim();
|
||||
if (!message || running || creating) return;
|
||||
// A thread that already has messages but no stored id is one whose
|
||||
// creation failed. Creating now would strand everything above on a page
|
||||
// that is about to remount, so it stays unsaved for the rest of its life.
|
||||
if (conversationId || messages.length) {
|
||||
sendTurn(text, from);
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
post<StoredPiggyConversation>('/api/piggy/conversations', { firstMessage: message })
|
||||
.then((created) => onCreated(created, message))
|
||||
.catch(() => {
|
||||
// The question is worth more than the filing. Piggy answers, the
|
||||
// relay mints its own conversation id, and only the history entry is
|
||||
// lost — which is what the toast says rather than implying the turn
|
||||
// failed.
|
||||
toast.error('Piggy could not save this to your history. The answer below is not filed.');
|
||||
sendTurn(text, from);
|
||||
})
|
||||
.finally(() => setCreating(false));
|
||||
},
|
||||
[conversation.draft, conversationId, creating, messages.length, onCreated, running, sendTurn],
|
||||
);
|
||||
|
||||
/**
|
||||
* The opening question of a conversation created a moment ago.
|
||||
*
|
||||
* Scheduled rather than sent inline, and cancelled by this effect's own
|
||||
* cleanup. A send started from an effect body outlives the mount that started
|
||||
* it: React's StrictMode mounts, runs effects, tears them down and mounts
|
||||
* again, and `usePiggyConversation` aborts its stream on unmount — so the
|
||||
* first thing anyone saw after asking the very first question of a new
|
||||
* conversation was their own question with "Stopped" under it, and a real
|
||||
* turn spent to get there. Deferring by a tick means the throwaway pass
|
||||
* cancels a timer instead of a request.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!autoSend || autoSent.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
autoSent.current = true;
|
||||
sendTurn(autoSend);
|
||||
onAutoSent();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoSend, onAutoSent, sendTurn]);
|
||||
|
||||
/**
|
||||
* A write opener pressed while Piggy is in Read only.
|
||||
*
|
||||
* The mode has to be committed before the turn leaves, because `send` reads
|
||||
* it off the conversation — so the text is parked here and sent by the effect
|
||||
* below once the conversation is actually holding the new mode. Sending in
|
||||
* the same tick would ask Piggy to change something with the write tools
|
||||
* still withheld, and the answer would be a polite refusal.
|
||||
*/
|
||||
const askWithChange = useCallback(
|
||||
(text: string) => {
|
||||
if (!controls.canWrite) return;
|
||||
if (conversation.mode === 'read_only') {
|
||||
controls.setMode('confirm');
|
||||
setEscalating(text);
|
||||
return;
|
||||
}
|
||||
ask(text);
|
||||
},
|
||||
[ask, controls, conversation.mode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (escalating === null) return;
|
||||
if (conversation.mode === 'read_only') return;
|
||||
setEscalating(null);
|
||||
ask(escalating);
|
||||
}, [ask, escalating, conversation.mode]);
|
||||
|
||||
const busy = running || creating;
|
||||
|
||||
const controlsRow = (
|
||||
<PiggyControls controls={controls} compact disabled={busy} />
|
||||
);
|
||||
/** The controls take a row of their own only on a tall, narrow screen. */
|
||||
const controlsOnSecondRow = isMobile && (hasHeaderRoom || !canShareTitleRow);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* `lg:px-4` puts the first glyph about 30px from the pane edge, which is
|
||||
the CRM's own `lg:px-8` content inset once the icon button's optical
|
||||
padding is counted. The workspace is allowed a toolbar; it is not
|
||||
allowed to start 8px from an edge every other page starts 32px from. */}
|
||||
<header
|
||||
className={cn(
|
||||
'shrink-0 border-b border-border bg-surface px-2 sm:px-3 lg:px-4',
|
||||
// Tighter where the transcript is counted in tens of pixels. The
|
||||
// controls are still 44px; only the air around them gives. `py-0.5`
|
||||
// rather than `py-1` because a 393x390 keyboard-up screen has 390px
|
||||
// to divide between a 56px app bar, this header, the composer and the
|
||||
// thread — and four of those pixels are 1% of the transcript.
|
||||
hasHeaderRoom ? 'py-2' : 'py-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{showHistoryToggle ? (
|
||||
<IconButton
|
||||
label={historyExpanded ? 'Collapse the conversation list' : 'Expand the conversation list'}
|
||||
onClick={onToggleHistory}
|
||||
>
|
||||
{historyExpanded ? <PanelLeftClose aria-hidden /> : <PanelLeftOpen aria-hidden />}
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton label="Your Piggy conversations" onClick={onOpenHistory}>
|
||||
<History aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* An unsaved thread has no name yet, and calling it "New
|
||||
conversation" would put the sidebar's button's own words in the
|
||||
title bar. The workspace is called Piggy until the first
|
||||
question names the thread. */}
|
||||
<h1 className="truncate text-base font-semibold leading-tight tracking-tight">
|
||||
{title ?? 'Piggy'}
|
||||
</h1>
|
||||
<p className="hidden truncate text-xs leading-5 text-muted sm:block">
|
||||
{piggyLine(conversationId ? piggyCopy.threadSaved : piggyCopy.threadNew, isMobile)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{controlsOnSecondRow ? null : controlsRow}
|
||||
|
||||
{showHistoryToggle ? null : (
|
||||
<IconButton label="Start a new conversation" onClick={onNew}>
|
||||
<SquarePen aria-hidden />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={
|
||||
activityInColumn
|
||||
? activityOpen
|
||||
? 'Hide the activity panel'
|
||||
: 'Show the activity panel'
|
||||
: 'Show activity'
|
||||
}
|
||||
pressed={activityInColumn ? activityOpen : undefined}
|
||||
onClick={() => (activityInColumn ? onToggleActivity() : setActivitySheet(true))}
|
||||
>
|
||||
{activityOpen ? <PanelRightClose aria-hidden /> : <PanelRight aria-hidden />}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* On a phone the controls take the second row rather than shrinking:
|
||||
"Ask first" and a model name cannot share 393px with a title. On a
|
||||
short viewport they do share it, because 52px of chrome is a fifth
|
||||
of the transcript there. */}
|
||||
{controlsOnSecondRow ? (
|
||||
<div className={hasHeaderRoom ? 'mt-2' : 'mt-0.5'}>{controlsRow}</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* No background of its own. The transcript well IS the canvas: the
|
||||
shell's `.app-canvas` wash sits behind everything, and painting a
|
||||
flat `bg-bg` over it made the one full-bleed surface in the product
|
||||
the only place the canvas could not be seen — three planes
|
||||
(rail, canvas, card) collapsing into two. */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{loading ? (
|
||||
<ThreadSkeleton />
|
||||
) : loadError ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||
<EmptyState
|
||||
icon={<AlertTriangle />}
|
||||
title="That conversation could not be opened"
|
||||
description={
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: 'It may have been deleted, or it belongs to someone else.'
|
||||
}
|
||||
action={
|
||||
<Button type="button" variant="outline" onClick={onNew}>
|
||||
Start a new conversation
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PiggyChatPanel
|
||||
conversation={askingConversation(conversation, ask)}
|
||||
/* No `context`: the panel would draw a badge saying the
|
||||
conversation is working from the page you are looking at, which
|
||||
on this page is a badge reading "piggy". The turn still carries
|
||||
the context — the conversation was created with it. */
|
||||
className="min-h-0 flex-1"
|
||||
/* Focus follows the thread across the remount that filing it
|
||||
causes. The first question of a new conversation creates the
|
||||
stored row, which puts an id in the URL, which rebuilds this
|
||||
subtree under a new key — and the caret the user had just
|
||||
typed into landed on `<body>`, so the obvious next thing to do
|
||||
was to reach for the mouse. */
|
||||
autoFocusComposer={Boolean(autoSend)}
|
||||
emptyState={
|
||||
<PiggyWorkspaceStarters
|
||||
context={WORKSPACE_CONTEXT}
|
||||
mode={controls.mode}
|
||||
canWrite={controls.canWrite}
|
||||
onAsk={ask}
|
||||
onAskWithChange={askWithChange}
|
||||
/* The two columns of openers fit whenever the pane does:
|
||||
even with both rails out at 1280 the middle keeps ~700px,
|
||||
which is two 340px cards. Only the phone stacks them. */
|
||||
narrow={isMobile}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activityOpen ? (
|
||||
<aside
|
||||
className="hidden w-[20rem] shrink-0 overflow-hidden border-l border-border bg-surface 2xl:flex"
|
||||
aria-label="Piggy activity"
|
||||
>
|
||||
<PiggyWorkspaceRail messages={messages} className="min-h-0 w-full flex-1" />
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Sheet open={activitySheet} onOpenChange={setActivitySheet}>
|
||||
<SheetContent side="right" className="flex w-[20rem] flex-col p-0 sm:max-w-none">
|
||||
<SheetHeader band className="pr-14">
|
||||
<SheetTitle>Activity</SheetTitle>
|
||||
<SheetDescription>
|
||||
What this conversation has touched, and what the workspace has run.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{/* Mounted only while open: the ledger polls, and a hidden copy would
|
||||
poll alongside the one in the column. */}
|
||||
{activitySheet ? (
|
||||
<PiggyWorkspaceRail messages={messages} className="min-h-0 flex-1" />
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation as the panel should see it: identical, except that sending
|
||||
* goes through the workspace's own `ask`, which may have a conversation to
|
||||
* create first. The panel is deliberately unaware of that — it has three other
|
||||
* callers with nothing to file.
|
||||
*/
|
||||
function askingConversation(
|
||||
conversation: PiggyConversation,
|
||||
ask: (text?: string, from?: TranscriptMessage[]) => void,
|
||||
): PiggyConversation {
|
||||
return { ...conversation, send: ask };
|
||||
}
|
||||
|
||||
function ThreadSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>
|
||||
<Skeleton className="h-16 w-2/3 rounded-xl" />
|
||||
<Skeleton className="ml-auto h-12 w-1/2 rounded-xl" />
|
||||
<Skeleton className="h-24 w-3/4 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
pressed,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
pressed?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-11 shrink-0 text-muted', pressed && 'text-fg')}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- state
|
||||
|
||||
function conversationKey(id: string) {
|
||||
return ['piggy', 'conversation', id] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* A panel's open/closed state, remembered.
|
||||
*
|
||||
* Not keyed by user, unlike the mode: which rails somebody likes open is a
|
||||
* preference about a window, not a permission, and the worst a shared laptop
|
||||
* can do with it is show the second person a column they can close.
|
||||
*/
|
||||
function useStoredFlag(key: string, fallback: boolean): [boolean, (value: boolean) => void] {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : raw === 'true';
|
||||
} catch {
|
||||
// Private browsing throws on access; the layout default is fine.
|
||||
return fallback;
|
||||
}
|
||||
});
|
||||
|
||||
const update = useCallback(
|
||||
(next: boolean) => {
|
||||
setValue(next);
|
||||
try {
|
||||
localStorage.setItem(key, String(next));
|
||||
} catch {
|
||||
// Nothing to do: the panel still opens, it just forgets by tomorrow.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, update];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,15 @@ import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
forwardRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type LabelHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
@@ -42,8 +46,17 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
|
||||
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 ' +
|
||||
'[&_svg]:shrink-0 ' +
|
||||
/*
|
||||
* The ring is declared here as well as in the global `:focus-visible` rule.
|
||||
* A button that also carries a local `focus-visible:ring-*` class beat the
|
||||
* base rule on specificity and painted the near-invisible subtle accent;
|
||||
* declaring it in the cva puts it in the same cascade layer as those
|
||||
* overrides, so `cn()` merging resolves it rather than the stylesheet.
|
||||
*/
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand ' +
|
||||
'focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
|
||||
// touch-manipulation removes the 300ms tap delay that older mobile Safari
|
||||
// applies while waiting to see whether a tap is a double-tap zoom.
|
||||
'touch-manipulation select-none whitespace-nowrap',
|
||||
@@ -179,28 +192,191 @@ export function Badge({
|
||||
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- stat
|
||||
// -------------------------------------------------------------- micro label
|
||||
|
||||
/**
|
||||
* A single headline number.
|
||||
* The one small-caps label in the product.
|
||||
*
|
||||
* There were five of these — 10px, 11px and 12px, `tracking-wide`,
|
||||
* `tracking-wider` and `tracking-[0.12em]`, `font-medium` and `font-semibold`
|
||||
* — across 58 hand-written call sites, which is why no two panels' labels
|
||||
* lined up. 11px at 0.06em was the majority reading and the one that survives
|
||||
* the sidebar rail's width.
|
||||
*
|
||||
* `as` exists because the same label is a `dt` in a definition list, a `th` in
|
||||
* a table head and a `span` inside a flex row; rendering all three as a `div`
|
||||
* is how a table stops being a table for a screen reader.
|
||||
*/
|
||||
const MICRO_LABEL =
|
||||
'text-[11px] font-medium uppercase leading-tight tracking-[0.06em] text-muted';
|
||||
|
||||
export function Label({
|
||||
as: Component = 'div',
|
||||
className,
|
||||
...props
|
||||
}: LabelHTMLAttributes<HTMLElement> & {
|
||||
as?: 'div' | 'span' | 'p' | 'dt' | 'th' | 'legend' | 'label' | 'h2' | 'h3' | 'h4';
|
||||
}) {
|
||||
return (
|
||||
<Component className={cn(MICRO_LABEL, className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ section
|
||||
|
||||
/**
|
||||
* A titled group of content: the panel heading, its count, its action, and
|
||||
* optionally a chevron that folds it away.
|
||||
*
|
||||
* Six of these existed at 11px-uppercase through 16px-sentence-case, so a page
|
||||
* built from three of them read as three products. `tone` is the only choice
|
||||
* left: `panel` owns a card, `micro` names a group inside one.
|
||||
*/
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
count,
|
||||
action,
|
||||
level = 3,
|
||||
tone = 'panel',
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Rendered beside the title as a tabular figure. `0` still renders. */
|
||||
count?: number;
|
||||
action?: ReactNode;
|
||||
level?: 2 | 3 | 4;
|
||||
tone?: 'panel' | 'micro';
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const Heading = ({ 2: 'h2', 3: 'h3', 4: 'h4' } as const)[level];
|
||||
|
||||
const titleClass =
|
||||
tone === 'panel' ? 'text-base font-semibold leading-tight text-fg' : MICRO_LABEL;
|
||||
|
||||
const heading = (
|
||||
<>
|
||||
<span className={cn('min-w-0 break-words', titleClass)}>{title}</span>
|
||||
{count == null ? null : <span className="nums shrink-0 text-sm text-muted">{count}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={cn('min-w-0', className)}>
|
||||
<div className={cn('flex min-w-0 items-start gap-2', collapsible ? '' : 'py-0.5')}>
|
||||
<Heading className="min-w-0 flex-1">
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
aria-expanded={open}
|
||||
className="flex min-h-11 w-full items-center gap-2 rounded-lg text-left transition-colors duration-1 ease-enter hover:text-fg"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted transition-transform duration-1 ease-enter',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{heading}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-baseline gap-2">{heading}</span>
|
||||
)}
|
||||
</Heading>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className={cn('mt-1 min-w-0 text-sm text-muted', collapsible && 'pl-6')}>{description}</p>
|
||||
) : null}
|
||||
{children != null && (!collapsible || open) ? (
|
||||
<div className={cn('mt-2 min-w-0', tone === 'panel' && !collapsible && 'mt-3')}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- stat
|
||||
|
||||
const statValueSizes = {
|
||||
/** Inline tile inside a panel. */
|
||||
sm: 'text-sm',
|
||||
/** A figure a panel is about. */
|
||||
md: 'text-lg',
|
||||
/** The page's headline number. */
|
||||
lg: 'text-2xl sm:text-3xl',
|
||||
} as const;
|
||||
|
||||
const statSurfaces = {
|
||||
/** The default, and the only one that existed: a card of its own. */
|
||||
card: 'card p-4',
|
||||
/** A tile on `bg-surface-2` inside a card. No border, no shadow. */
|
||||
inset: 'rounded-md bg-surface-2 p-2.5',
|
||||
/** No surface at all — the caller owns the container. */
|
||||
bare: '',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A single labelled number.
|
||||
*
|
||||
* `nums` applies tabular figures so a value does not jitter horizontally as it
|
||||
* updates — which it does, on a dashboard that refreshes while someone is
|
||||
* looking at it.
|
||||
*
|
||||
* `min-w-0 break-words` is on the value, not left to the caller: this tile is
|
||||
* always a grid child, a grid child refuses to shrink below its content, and
|
||||
* the content is an unbreakable currency string. The measured failure was
|
||||
* `GROSS MARGIN $658,194.3` — a digit short — on any phone under 400px, and
|
||||
* the same figure colliding with the next card at 1440 with the Piggy dock
|
||||
* open. A truncated financial figure is worse than no figure.
|
||||
*
|
||||
* That stopped the clipping and traded it for a second failure nobody measured,
|
||||
* because `scrollWidth === clientWidth` is true of a number that has WRAPPED:
|
||||
* `$658,194.` on one line and `37` on the next, at every width under about
|
||||
* 220px — a phone, and 1440 with the dock open. So the tile is now a container
|
||||
* query context (`stat-tile`) and the `lg` figure carries `stat-figure-lg`,
|
||||
* which steps 30 → 24 → 22 → 20 → 18 → 16px as the tile narrows. See the
|
||||
* measured ladder in `index.css`. Every tile in a grid row is the same width,
|
||||
* so a row steps together; `break-words` stays as the last-resort net.
|
||||
*
|
||||
* An absent value renders a muted em dash rather than the tone colour: "—" in
|
||||
* danger red reads as a number that went wrong rather than one nobody has.
|
||||
*/
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
size = 'lg',
|
||||
surface = 'card',
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
tone?: 'positive' | 'warning' | 'danger' | 'default';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
surface?: 'card' | 'inset' | 'bare';
|
||||
/** Makes the whole tile a router link to the page that explains the figure. */
|
||||
href?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'positive'
|
||||
const absent = value == null || value === '' || value === '—' || value === '-';
|
||||
const toneClass = absent
|
||||
? 'text-muted'
|
||||
: tone === 'positive'
|
||||
? 'text-positive'
|
||||
: tone === 'warning'
|
||||
? 'text-warning'
|
||||
@@ -208,18 +384,52 @@ export function Stat({
|
||||
? 'text-danger'
|
||||
: 'text-fg';
|
||||
|
||||
return (
|
||||
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
|
||||
// is always a grid child whose figure is `tabular-nums` and whose hint does
|
||||
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
|
||||
// shrink and the page scrolls sideways (AGENTS.md §5).
|
||||
<div className="card min-w-0 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
|
||||
{value}
|
||||
const body = (
|
||||
<>
|
||||
<Label>{label}</Label>
|
||||
<div
|
||||
className={cn(
|
||||
'nums min-w-0 break-words font-semibold leading-tight',
|
||||
size === 'sm' ? 'mt-0.5' : 'mt-1',
|
||||
statValueSizes[size],
|
||||
// Only the page KPI step is container-scaled; `sm` and `md` are
|
||||
// already small enough that no figure in the product wraps them.
|
||||
size === 'lg' && 'stat-figure-lg',
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{absent ? '—' : value}
|
||||
</div>
|
||||
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
|
||||
</div>
|
||||
{hint ? <div className="mt-1 min-w-0 text-xs text-muted">{hint}</div> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const shell = cn(
|
||||
'block min-w-0',
|
||||
/*
|
||||
* The container context is declared ONLY on the page-KPI step, and that is
|
||||
* a measured constraint rather than tidiness. `container-type: inline-size`
|
||||
* carries `contain: layout style inline-size`, which suppresses a box's
|
||||
* content-based intrinsic contribution — so putting it on every `Stat`
|
||||
* changed how the `sm` and `md` tiles inside Growth's account cards
|
||||
* negotiated width with the flex rows around them, and /growth began
|
||||
* overflowing its viewport by 32px at 393 and 12px at 1440. Zero horizontal
|
||||
* overflow is the product's oldest measured guarantee; a nicer number is
|
||||
* not worth spending it. The `lg` tiles are grid children with declared
|
||||
* tracks, where the contribution is not what decides the width.
|
||||
*/
|
||||
size === 'lg' && 'stat-tile',
|
||||
statSurfaces[surface],
|
||||
href && 'transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||
className,
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<Link to={href} className={shell}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={shell}>{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,24 +441,59 @@ export function Skeleton({ className }: { className?: string }) {
|
||||
|
||||
// --------------------------------------------------------------- empty state
|
||||
|
||||
/**
|
||||
* Nothing here, said once.
|
||||
*
|
||||
* Seven bespoke empty states stood beside this one, differing only in how much
|
||||
* vertical room they took: a panel's worth of padding inside a 120px list row
|
||||
* pushes the thing below it off the screen. `size` is that decision and the
|
||||
* only one — `panel` is the historic rendering and stays the default.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
size = 'panel',
|
||||
className,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
/** `inline` inside a list or a card body, `panel` for a card, `page` for a route. */
|
||||
size?: 'inline' | 'panel' | 'page';
|
||||
className?: string;
|
||||
}) {
|
||||
const box =
|
||||
size === 'inline' ? 'gap-2 px-4 py-6' : size === 'page' ? 'gap-4 px-6 py-20' : 'gap-3 px-6 py-12';
|
||||
const titleClass =
|
||||
size === 'inline'
|
||||
? 'text-sm font-medium'
|
||||
: size === 'page'
|
||||
? 'text-base font-semibold'
|
||||
: 'font-medium';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center justify-center text-center',
|
||||
box,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted">{icon}</div> : null}
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('break-words', titleClass)}>{title}</p>
|
||||
{description ? (
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mx-auto mt-1 max-w-sm break-words text-muted',
|
||||
size === 'inline' ? 'text-xs' : 'text-sm',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The one route heading.
|
||||
*
|
||||
* Thirteen pages hand-rolled this block and drifted: the `<h1>` was 20px on
|
||||
* most, 30px on Growth and Learn, five pages carried two `<h1>`s, and the gap
|
||||
* to the first section below ran 16 / 20 / 24 / 40px depending on the file. A
|
||||
* document has one title, and a product has one title size.
|
||||
*
|
||||
* `ask` is a separate slot from `actions` deliberately. "Ask Piggy" is not a
|
||||
* page action — it is the same agent on every page, and it sits in the same
|
||||
* place on every page so a reader stops hunting for it among the buttons that
|
||||
* do differ.
|
||||
*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
ask,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Page-specific controls, right-aligned at `sm` and up. */
|
||||
actions?: ReactNode;
|
||||
/** The `PiggyAskButton` for this page, kept in one consistent slot. */
|
||||
ask?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-1 min-w-0 max-w-2xl text-sm text-muted">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions || ask ? (
|
||||
// `flex-wrap` rather than `whitespace-nowrap`: at 393px a two-button
|
||||
// row plus the ask button overflows, and a page header is the last
|
||||
// place that should be the thing which introduces horizontal scroll.
|
||||
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
{actions}
|
||||
{ask}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user