Put Piggy on Prime Agent, and let it write to the book
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped

Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session —
Prime Intellect's own harness, embedded as a Node library — answering from
PIG's tools and, for the first time, able to put information into the CRM
rather than only read it out.

The harness is a coding agent, so the first job was taking the coding agent
away from it. `noTools: 'all'` plus an explicit allowlist leaves the model
with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That
holds under attack: a hostile extension, a skill and a settings file planted
in the agent's own directory, then `setActiveToolsByName` called with every
built-in, still leaves ten tools, all ours. Both lines are load-bearing —
`noTools` alone registers nothing, and the allowlist is what admits our own.

Writing is gated rather than assumed. A change is proposed, not made: the
tool returns a description, the transcript renders a diff card, and nothing
reaches the database until someone presses Apply. Contracts, commitments,
allocations and compliance always stop for a human whatever the mode. Every
write runs through `executeMutation` as the calling user, so their
capabilities and the audit trail apply exactly as they would to a human's.

Four things about the SDK are wrong in its own documentation and cost a
debugging cycle each: models.json does not resolve an env var name for
`apiKey`, it sends the literal string; there is no built-in prime-inference
provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you;
and the stock system prompt is a coding-assistant prompt that must be
replaced — but replacing it also silently removes the tool list, because the
harness only renders that section when it owns the prompt. AGENTS.md records
all four.

The expensive one was thinking level. The harness defaults to `medium`, and
nemotron spent an entire 4,096-token budget reasoning and returned an empty
answer. `low` was worse; `off` omits the parameter so the endpoint's default
wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn
from 6,195 output tokens to 149.

And a turn is now bounded. The harness loop is `while (true)` with no
iteration cap; a runaway on a frontier model would have eaten the credit it
is supposed to report on. Ceilings on model calls and tokens, enforced both
through the harness hook and independently from the event stream, plus a
per-user daily spend limit — and the ledger now records spend on turns that
fail, which it previously discarded.

Signing in lands on /piggy, which is a workspace: conversations down one
side, the agent in the middle, what it did and what it cost beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 05:26:28 -07:00
parent 99d165b5e5
commit f0173440e4
77 changed files with 28108 additions and 1672 deletions
+23 -11
View File
@@ -7,10 +7,11 @@
* 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 {
@@ -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>
+175 -104
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, 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 type { PiggyApprovalDecision } from '@pig/core';
import { get } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
@@ -17,12 +18,14 @@ import {
type TranscriptMessage,
} from '@/lib/piggy-chat';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps, piggySuggestions } 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 { Button, Badge, EmptyState, cn } from './ui';
import {
Drawer,
DrawerContent,
@@ -88,41 +91,23 @@ export function PiggyAskButton({
}
/**
* 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={<Bot />}
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({
@@ -143,8 +128,9 @@ 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 });
if (desktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -153,7 +139,7 @@ export function ResponsivePiggyChat({
<SheetTitle>Ask Piggy</SheetTitle>
<SheetDescription>{context ? `Working from ${contextLabel(context)}` : 'Working from your PIG workspace'}</SheetDescription>
</SheetHeader>
<PiggyChatPanel conversation={conversation} context={context} autoFocusComposer className="min-h-0 flex-1" />
<PiggyChatPanel conversation={conversation} controls={controls} context={context} autoFocusComposer className="min-h-0 flex-1" />
</SheetContent>
</Sheet>
);
@@ -167,7 +153,7 @@ export function ResponsivePiggyChat({
</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" />
<PiggyChatPanel conversation={conversation} controls={controls} context={context} className="min-h-0 flex-1" />
</DrawerContent>
</Drawer>
);
@@ -188,6 +174,8 @@ export function PiggyChatPanel({
className,
compact = false,
conversation,
controls,
emptyState,
autoFocusComposer = false,
}: {
context?: PiggyChatContext;
@@ -200,12 +188,26 @@ export function PiggyChatPanel({
* 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 has a bigger front door. */
emptyState?: ReactNode;
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.
@@ -237,10 +239,26 @@ export function PiggyChatPanel({
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 py-5',
compact ? 'px-3' : 'px-4 sm:px-5',
)}
>
{emptyState ?? <PiggyStarters compact={compact} context={context} onAsk={send} />}
</div>
) : (
<PiggyConversation busy={running} 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 +272,102 @@ 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 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.
<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}
{/* 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}
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">
{/* No longer "Read-only session": Piggy writes now, and what it may
do this turn is stated by the mode control above rather than by a
line of copy that would have to be kept in step with it. What is
left is the part that is true in every mode. */}
<p className="flex-1 text-center">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
{/* 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>
) : null}
</div>
</div>
</form>
</div>
@@ -351,7 +399,10 @@ function PiggyStarters({
<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>
{/* The old line ended "and this chat cannot write CRM records", which
stopped being true the moment the mode control appeared under it. What
is still true is the boundary: PIG's own tools, and nothing else. */}
<p className={cn('mt-1 text-muted', compact ? 'text-xs leading-5' : 'text-sm')}>Piggy reads only through scoped PIG tools no shell, no filesystem, no browser. Set to Ask first, it also proposes changes for you to approve.</p>
<div className="mt-4 grid w-full gap-2">
{(compact ? suggestions.slice(0, 3) : suggestions).map((suggestion) => (
// Sends rather than fills the composer. Filling it looked like
@@ -380,10 +431,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') {
@@ -421,6 +475,23 @@ function ChatMessage({
</div>
) : 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. */}
+46 -4
View File
@@ -17,21 +17,37 @@
* first is a permanent third of the window that fails on first use.
*/
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PanelRightClose, Sparkles } from 'lucide-react';
import { useHasDockRoom } from '@/hooks/use-media-query';
import { useLayout } from '@/lib/layout';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import type { PiggyChatContext } from '@/lib/piggy-chat';
import { usePiggyChatSession } from './piggy/workspace/controls';
import { PiggyChatPanel, ResponsivePiggyChat, usePiggyStatus } from './PiggyChat';
import { 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 onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
if (!hasRoom || !dockOpen) return null;
// 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
@@ -87,17 +103,36 @@ 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"
/>
)}
</aside>
);
}
/**
* 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 }: { context: PiggyChatContext }) {
const { conversation, controls } = usePiggyChatSession({ context });
return (
<PiggyChatPanel
conversation={conversation}
controls={controls}
context={context}
compact
className="min-h-0 flex-1"
/>
);
}
/**
* The header control for Piggy.
*
@@ -112,6 +147,13 @@ export function PiggyDockToggle({ className }: { className?: string }) {
const context = usePiggyCurrentContext();
const [overlayOpen, setOverlayOpen] = useState(false);
const unavailable = status.data && !status.data.canUse;
const onWorkspace = useLocation().pathname === PIGGY_WORKSPACE_PATH;
// 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;
return (
<>
@@ -0,0 +1,635 @@
/**
* 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, ChevronDown } from 'lucide-react';
import { compactNumber, get, relativeTime } from '@/lib/api';
import { Badge, Button, Skeleton, cn } from '@/components/ui';
// -------------------------------------------------------------- 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 widens as the number shrinks, and never past six places,
* where the underlying figure stops being meaningful anyway.
*/
function decimalsFor(dollars: number): number {
const size = Math.abs(dollars);
if (size === 0 || size >= 1) return 2;
return size >= 0.01 ? 4 : 6;
}
/*
* 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));
}
/**
* A provider's error, as a sentence rather than as its wire format.
*
* `agent_runs.error` is deliberately the raw upstream reason — the chat stream
* sanitises what the browser is told and the ledger keeps the truth, which is
* the right division. But this panel then rendered that truth verbatim, so a
* rate limit arrived in the product as
* `429: {"message":"Rate limit reached. Please retry shortly.","type":…,"code":…}`,
* a JSON document from a third party sitting in PIG's own interface. The
* message is pulled out where the body is JSON and the status is kept, because
* "429" is the part an operator acts on; the whole of it stays one hover away.
*/
function readableError(error: string): string {
const match = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(error.trim());
if (!match) return error;
const [, status, payload] = match;
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;
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());
}
type Tone = 'neutral' | 'accent' | 'positive' | 'warning' | 'danger' | 'info';
/**
* A run's status is free text from the ledger, so an unrecognised value is
* shown as it is in a neutral badge rather than being forced into one of the
* four we know. A status this panel has never heard of is information.
*/
const RUN_TONES: Record<string, Tone> = {
running: 'info',
// Deliberately not `positive`. Almost every row succeeds, and a column of
// green makes the one aborted turn no easier to find than the twenty that
// were fine — which is the only reason anybody scans this list.
succeeded: 'neutral',
aborted: 'warning',
failed: 'danger',
};
const TASK_TONES: Record<PiggyTaskSummary['state'], Tone> = {
running: 'info',
queued: 'accent',
scheduled: 'neutral',
// Same reasoning as RUN_TONES: colour is for what needs a person.
succeeded: 'neutral',
failed: 'danger',
skipped: 'neutral',
cancelled: 'neutral',
};
// ------------------------------------------------------------- primitives
function Section({
title,
count,
children,
}: {
title: string;
count?: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="card min-w-0">
<h3>
<button
type="button"
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className={cn(
'flex min-h-[44px] w-full items-center gap-2 rounded-lg px-4 py-2 text-left',
'text-xs font-semibold uppercase tracking-wide text-muted',
'transition-colors hover:bg-surface-2',
)}
>
<ChevronDown
className={cn('h-4 w-4 transition-transform', open ? '' : '-rotate-90')}
aria-hidden
/>
<span className="flex-1">{title}</span>
{count == null ? null : <span className="nums text-muted">{count}</span>}
</button>
</h3>
{open ? <div className="px-4 pb-4">{children}</div> : null}
</section>
);
}
function Empty({ children }: { children: ReactNode }) {
return (
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-xs leading-relaxed text-muted">
{children}
</p>
);
}
/**
* 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-[11px] text-muted">
{kept.map((part, index) => (
<span key={index}>{part}</span>
))}
</div>
);
}
// ------------------------------------------------------------------- rows
function RunRow({ run }: { run: PiggyRunSummary }) {
const tone = RUN_TONES[run.status] ?? 'neutral';
const duration = formatDuration(run.durationMs);
const tokens =
run.inputTokens == null && run.outputTokens == null
? null
: `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;
return (
<li className="border-t border-border py-3 first:border-t-0 first:pt-1">
<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>
<Badge tone={tone} className="shrink-0 capitalize">
{run.status}
</Badge>
</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 ? (
<p
className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger"
// The whole of it, for an operator who needs the provider's own words.
title={run.error}
>
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{readableError(run.error)}</span>
</p>
) : null}
<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,
/*
* Only the caller's own conversations resolve to a link — the server
* refuses to name anybody else's — so an admin reading the workspace
* ledger sees the run without a doorway into a private transcript.
*/
run.conversation ? (
<Link
key="conversation"
to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
// `inline-block` is load-bearing: `max-width` and `overflow` do
// nothing on a non-replaced inline box, so the truncation here was
// inert and a run whose title is a question with a UUID in it
// rendered 624px wide inside a 320px rail — clipped mid-word by
// the column rather than ellipsised.
className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 hover:underline"
title={run.conversation.title}
>
{run.conversation.title}
</Link>
) : null,
]}
/>
</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 first:pt-1">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
{humanise(task.kind)}
</p>
<Badge tone={TASK_TONES[task.state]} className="shrink-0 capitalize">
{task.state}
</Badge>
</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 ? (
<p className="mt-1.5 flex items-start gap-1.5 break-words rounded-md bg-danger/10 px-2 py-1.5 text-[11px] leading-relaxed text-danger">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
<span className="min-w-0">{task.error}</span>
</p>
) : null}
<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;
return (
<aside
aria-label="Piggy activity"
className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}
>
{/* First, not last: a ledger that could not be read must say so before it
shows anything that looks like a figure. */}
{activity.isError ? (
<div className="card min-w-0 p-4">
<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}
<div className="card min-w-0 p-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Spend</h2>
<span className="text-[11px] text-muted">US dollars</span>
</div>
{/*
Nothing here falls back to zero. A figure the panel could not read is
an em dash, never `$0.00`: on a spend surface those two are opposite
claims, and only one of them is true.
*/}
{spend ? (
<div className="mt-2 grid grid-cols-2 gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">Today</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.todayMicroCents)}
>
{spendMoney(spend.todayMicroCents, spendDigits)}
</div>
</div>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">This month</div>
<div
className="nums truncate text-lg font-semibold leading-tight"
title={spendTitle(spend.monthMicroCents)}
>
{spendMoney(spend.monthMicroCents, spendDigits)}
</div>
</div>
</div>
) : activity.isError ? (
<div className="mt-2 grid grid-cols-2 gap-3">
{['Today', 'This month'].map((label) => (
<div key={label} className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted">{label}</div>
<div className="text-lg font-semibold leading-tight text-muted"></div>
</div>
))}
</div>
) : (
<div className="mt-3 grid grid-cols-2 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)}
{spend ? (
<p className="mt-2 text-[11px] leading-relaxed text-muted">
{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}
</div>
<Section title="Recent runs" count={activity.data ? runs.length : undefined}>
{/*
`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 ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
) : (
<div className="flex flex-col gap-3 pt-1">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : runs.length === 0 ? (
<Empty>
Nothing has run yet. Ask Piggy a question and the turn appears here with its model,
its tokens and what it cost.
</Empty>
) : (
<>
<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" count={activity.data ? tasks.length : undefined}>
{!activity.data ? (
activity.isError ? (
<Empty>Unavailable while the ledger cannot be read.</Empty>
) : (
<div className="flex flex-col gap-3 pt-1">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
)
) : tasks.length === 0 ? (
<Empty>
No background work is queued. Enrichment, renewal watches and supplier research are
written here as tasks before Piggy runs them, and stay with their result afterwards.
</Empty>
) : (
<ul className="flex flex-col">
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</ul>
)}
</Section>
</aside>
);
}
@@ -0,0 +1,508 @@
/**
* 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.
*/
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ChevronRight,
Loader2,
ShieldAlert,
TriangleAlert,
XCircle,
} from 'lucide-react';
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
import { Badge, Button, Card, cn } from '@/components/ui';
export type PiggyApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
// ------------------------------------------------------------------ routing
/**
* Where a record of each kind can be opened.
*
* `/accounts/:id` is the only per-record route PIG has, so an account link opens
* the record and everything else lands on the list that contains it — which at
* least puts the reader in front of the row they just changed. When the other
* detail routes land, each of these becomes a one-line edit; `recordHref` is the
* only place a record id becomes a URL.
*/
const RECORD_ROUTES: Record<string, string> = {
account: '/accounts',
contact: '/accounts',
demand_deal: '/demand',
supply_deal: '/supply',
contract: '/contracts',
commitment: '/capacity',
allocation: '/capacity',
task: '/calendar',
};
function recordHref(record: NonNullable<PiggyProposedChange['record']>): string | null {
const base = RECORD_ROUTES[record.type];
if (!base) return null;
return record.type === 'account' ? `${base}/${record.id}` : base;
}
/** Whether the link opens the record itself or merely the list holding it. */
function opensRecord(type: string): boolean {
return type === 'account';
}
// ------------------------------------------------------------------- naming
/**
* Named for the reader, not for the model.
*
* The generic fallback turns `pig_update_record_fields` into "Update record
* fields", which is close enough that only the tools whose identifiers read
* badly need an entry. The caption exists so two cards proposing different
* writes on the same record are told apart at a glance.
*/
const TOOL_LABELS: Record<string, string> = {
pig_log_activity: 'Log activity',
pig_create_contact: 'Create contact',
pig_update_deal_stage: 'Update deal stage',
pig_update_record_fields: 'Update record fields',
pig_create_task: 'Create task',
};
function toolLabel(tool: string): string {
return (
TOOL_LABELS[tool] ??
tool
.replace(/^pig_/, '')
.replaceAll('_', ' ')
.replace(/^\w/, (letter) => letter.toUpperCase())
);
}
// --------------------------------------------------------------------- card
export function PiggyApprovalCard({
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;
});
const answerable = state === 'pending' || state === 'failed';
const decide = (decision: PiggyApprovalDecision) => {
if (!answerable || dispatched.current) return;
dispatched.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 href = change.record ? recordHref(change.record) : null;
const recordLabel = change.record?.label ?? change.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';
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',
state === 'pending' || state === 'submitting'
? 'border-warning/50'
: state === 'applied'
? 'border-positive/40'
: state === 'failed'
? 'border-danger/50'
: 'border-border bg-surface-2/40',
)}
>
<div className="flex items-start gap-2.5 p-3 sm:p-4">
<StateIcon state={state} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted">
{toolLabel(change.tool)}
</p>
{/* The summary is the headline: everything below it is evidence for
this one sentence, so it is the only thing set at full weight. */}
<h4 id={headingId} className="mt-0.5 break-words text-sm font-semibold leading-snug">
{change.summary}
</h4>
</div>
<StateBadge state={state} choice={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.
*/
<details className="group border-t border-border">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-1 px-3 text-xs text-muted hover:text-fg sm:px-4 [&::-webkit-details-marker]:hidden">
<ChevronRight
className="size-3.5 transition-transform group-open:rotate-90"
aria-hidden
/>
What was proposed
</summary>
<FieldList fields={change.fields} settled />
</details>
) : (
<div className="border-t border-border">
<FieldList fields={change.fields} settled={false} />
</div>
)}
<div className="flex flex-col border-t border-border p-3 sm:p-4">
{/*
One live region, mounted for the life of the card. A status element
that appears at the same moment as its text is announced unreliably,
and this is exactly the transition — pending to applied — that a
screen-reader user must not miss. Empty while the card is waiting,
which is why the spacing hangs off the child rather than off a `gap`:
an empty region must not leave a hole above the buttons.
*/}
<div role="status" aria-live="polite" className="[&>*]:mb-3">
<StatusLine state={state} choice={choice} record={change.record} />
</div>
{error ? (
<p className="mb-3 flex items-start gap-2 rounded-lg bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-words">{error}</span>
</p>
) : null}
{/*
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.
*/}
<div className="flex flex-wrap items-center justify-end gap-2">
{href ? (
<Link
to={href}
// `mr-auto` rather than `justify-between` on the row: when the pair
// of buttons wraps to its own line in a narrow dock, the row must
// still hold them at the right edge, and `between` would strand a
// lone wrapped item at the left.
className="mr-auto inline-flex min-h-11 w-fit max-w-full items-center gap-1 rounded-md text-xs text-muted underline-offset-4 hover:text-fg hover:underline focus-visible:text-fg focus-visible:ring-brand"
title={
opensRecord(change.record?.type ?? '')
? `Open ${recordLabel}`
: `Open the list containing ${recordLabel}`
}
>
<span className="truncate">
{state === 'applied' ? 'Open ' : 'Check '}
{recordLabel || 'the record'}
</span>
<ArrowUpRight className="size-3.5 shrink-0" aria-hidden />
</Link>
) : null}
{state === 'pending' || state === 'submitting' || state === 'failed' ? (
/*
The decision sits last, after the evidence, and never under the
reader's eye while they are still reading the diff. Reject comes
first so the hand travelling rightwards ends on the deliberate
action rather than passing over it, and Apply carries the only
filled treatment on the card. Nothing is autofocused: the card
arrives mid-stream, and a button that grabs focus while someone is
typing turns their next Enter into a write.
*/
/*
Full width below `sm` so the two buttons split a phone row evenly,
content width above it so they sit as a pair at the right of the
footer — and so that in a 22rem dock the pair wraps to its own line
intact rather than stacking one button above the other.
*/
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button
type="button"
variant="outline"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('reject')}
/*
The app's global focus ring is `ring-accent`, which is the
*subtle* accent — on a white card it is very nearly invisible.
Everywhere else that is a cosmetic loss; here it would leave a
keyboard user unable to see which of Apply and Reject they are
about to press, so both buttons ask for the full-strength
accent instead.
*/
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'reject' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Reject
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={!answerable}
onKeyDown={swallowRepeat}
onClick={() => decide('apply')}
className="min-w-[6rem] flex-1 focus-visible:ring-brand sm:flex-none"
>
{state === 'submitting' && choice === 'apply' ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{state === 'failed' ? 'Try again' : 'Apply'}
</Button>
</div>
) : null}
</div>
</div>
</Card>
);
}
// ------------------------------------------------------------------- pieces
function StateIcon({ state }: { state: PiggyApprovalState }) {
const className = 'mt-0.5 size-4 shrink-0';
if (state === 'applied') {
return <CheckCircle2 className={cn(className, 'text-positive')} aria-hidden />;
}
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 />;
}
function StateBadge({
state,
choice,
}: {
state: PiggyApprovalState;
choice: PiggyApprovalDecision | null;
}) {
if (state === 'applied') return <Badge tone="positive">Applied</Badge>;
if (state === 'rejected') return <Badge tone="neutral">Rejected</Badge>;
if (state === 'failed') return <Badge tone="danger">Not applied</Badge>;
if (state === 'submitting') {
return <Badge tone="neutral">{choice === 'reject' ? 'Rejecting' : 'Applying'}</Badge>;
}
return (
<Badge tone="warning" className="shrink-0">
Needs you
</Badge>
);
}
/**
* Why a card appeared in a mode that promised not to ask.
*
* 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 (
<p className="mx-3 flex items-start gap-2 rounded-lg bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-4">
<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'];
}) {
if (state === 'pending') return null;
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') {
return (
<p className="text-xs text-positive">
Applied to PIG{record?.label ? ` on ${record.label}` : ''}.
</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. The reason under this line tells them apart;
// what both have in common is the only thing worth stating up front.
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.5 px-3 pb-3 pt-3 sm:px-4">
{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">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{field.label}</dt>
<dd className="mt-0.5 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,879 @@
/**
* Piggy's history rail: every conversation this person has had, newest first.
*
* Three decisions here are worth stating, because each replaces something more
* obvious that would have been wrong.
*
* **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. The Dialog primitive does both.
*/
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, Skeleton, cn } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/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);
}
/** 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);
const conversations = query.data ?? NO_CONVERSATIONS;
/**
* 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 />
)
) : (
<h3 className="sticky top-0 z-10 bg-surface px-2.5 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted">
{group.label}
</h3>
)}
<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={() => 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
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
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>
<Dialog
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open) setPendingDelete(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete this conversation?</DialogTitle>
<DialogDescription className="break-words">
{pendingDelete?.title} and everything said in it will be removed. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPendingDelete(null)}>
Keep it
</Button>
<Button
type="button"
variant="danger"
disabled={remove.isPending}
onClick={() => void confirmDelete()}
>
<Trash2 className="size-4" aria-hidden />
Delete conversation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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: () => void;
}) {
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',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
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-[11px] 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
type="button"
aria-label={`Actions for ${conversation.title}`}
className={cn(
'absolute right-0.5 top-0.5 flex size-11 items-center justify-center rounded-lg',
'text-muted transition hover:bg-border hover:text-fg',
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'data-[state=open]:opacity-100',
// 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()}
>
<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-[11px] 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',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
// 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>
}
/>
);
}
@@ -0,0 +1,341 @@
/**
* 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. Three 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 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 { 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>());
/**
* 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');
const step = useCallback(
(direction: 1 | -1) => {
const index = choices.findIndex((option) => option.value === selected);
const next = choices[(index + direction + choices.length) % choices.length];
if (!next) return;
onChange(next.value);
buttons.current.get(next.value)?.focus();
},
[choices, onChange, selected],
);
const active = optionFor(selected);
return (
<div className={cn('flex min-w-0 flex-col', compact ? 'gap-1.5' : 'gap-2')}>
{compact ? null : (
<span className="text-xs font-medium uppercase tracking-wide text-muted">
What Piggy may do
</span>
)}
<div
role="radiogroup"
aria-label="What Piggy may do"
aria-describedby={describedBy}
className="grid grid-cols-3 gap-1 rounded-xl border border-border bg-surface-2 p-1"
onKeyDown={(event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
step(1);
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
step(-1);
}
}}
>
{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={isSelected ? 0 : -1}
disabled={disabled}
title={disabled ? NO_WRITE_REASON : option.sentence}
onClick={() => onChange(option.value)}
className={cn(
'flex min-h-[44px] min-w-0 items-center justify-center rounded-lg',
'font-medium transition-colors touch-manipulation select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent',
// Tight enough that "Read only" survives whole in a dock
// narrower than the 22rem one; the label is what makes this
// control legible, so it is the last thing allowed to truncate.
compact ? 'gap-1 px-1 text-[11px]' : 'gap-1.5 px-2 text-xs sm:text-sm',
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.
*/}
<div id={describedBy} aria-live="polite" className="min-w-0">
{active.consequential ? (
<p
className={cn(
'flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10',
compact ? 'px-2 py-1.5 text-[11px]' : 'px-2.5 py-2 text-xs',
'leading-snug text-fg',
)}
>
<TriangleAlert aria-hidden className="mt-px h-3.5 w-3.5 shrink-0 text-warning" />
<span>
{active.sentence} <span className="font-medium">{GUARDED_SENTENCE}</span>
</span>
</p>
) : (
<p className={cn('leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
{active.sentence}
</p>
)}
{canWrite ? null : (
<p className={cn('mt-1 leading-snug text-muted', compact ? 'text-[11px]' : 'text-xs')}>
{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,508 @@
/**
* 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';
import { ChevronsUpDown, Sparkles } 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, 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;
}
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.'}
>
<Sparkles 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',
)}
>
<Sparkles className="size-4 shrink-0 text-muted" aria-hidden />
<span className="truncate text-fg">
{compact ? shortLabel(inForce.label) : inForce.label}
</span>
{!compact && isDeploymentDefault ? (
<span className="shrink-0 text-[11px] text-muted">Default</span>
) : 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="flex items-baseline justify-between gap-2 px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
Model
<span className="font-normal normal-case tracking-normal">
{models.length} available
</span>
</DropdownMenuLabel>
<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 text-[10px]">
Default
</Badge>
) : null}
{model.id === cheapestId ? (
<Badge tone="positive" className="px-1.5 py-0 text-[10px]">
Cheapest
</Badge>
) : null}
{topTierIds.has(model.id) ? (
<Badge tone="accent" className="px-1.5 py-0 text-[10px]">
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-[11px] leading-snug text-muted">
{formatRates(model)} · {formatContext(model.contextWindow)} context
</p>
</div>
<div className="flex shrink-0 flex-col items-end pl-1 text-right">
<span className="nums text-sm font-medium text-fg">{formatQuote(model)}</span>
<span className="text-[10px] leading-tight text-muted">per 100 questions</span>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<p className="whitespace-normal px-2 pb-1 pt-1.5 text-[11px] 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>
);
}
@@ -0,0 +1,215 @@
/**
* 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',
compact ? 'h-11 px-2 text-[11px]' : '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,298 @@
/**
* 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, CircleSlash, FileText, TriangleAlert } from 'lucide-react';
import type { PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, TranscriptMessage } from '@/lib/piggy-chat';
import { compactNumber } from '@/lib/api';
import { PiggyActivityPanel, spendMoney, spendTitle } from '@/components/piggy/activity-panel';
import { Badge, 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">
{/* The primitive's own palette is shadcn's, where `bg-muted` is a
surface. In PIG `muted` is the muted TEXT colour, so an unstyled
TabsList paints a mid-grey slab with unreadable labels on it. Every
other Tabs in the app carries the same three overrides; they are the
house pattern rather than a local fix. */}
<TabsList className="grid w-full grid-cols-2 border border-border bg-surface p-1">
<TabsTrigger
value="chat"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
This chat
</TabsTrigger>
<TabsTrigger
value="activity"
className="min-h-9 text-muted data-[state=active]:bg-surface-2 data-[state=active]:text-fg"
>
Activity
</TabsTrigger>
</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 (
<div className="p-4 text-sm leading-6 text-muted">
<p className="font-medium text-fg">Nothing asked yet.</p>
<p className="mt-1">
Every record Piggy reads and every change it proposes will be listed here as the
conversation goes on, so an answer can be checked against the rows behind it.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 p-3">
<dl className="grid grid-cols-2 gap-2">
<Figure label="Answers" value={String(summary.turns)} />
<Figure
label="Tool calls"
value={String(summary.tools.reduce((total, tool) => total + tool.runs, 0))}
/>
<Figure
label="Tokens"
value={`${compactNumber(summary.inputTokens)} / ${compactNumber(summary.outputTokens)}`}
hint="in / out"
/>
<Figure
label="Spend"
value={spendMoney(summary.costMicroCents)}
title={spendTitle(summary.costMicroCents)}
/>
</dl>
{summary.approvals.length ? (
<Section title="Changes">
<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">
<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}>
{toolLabel(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-[11px] leading-4 text-muted">
Open a step in the transcript to see what each of these returned.
</p>
</Section>
) : null}
</div>
);
}
function Figure({
label,
value,
hint,
title,
}: {
label: string;
value: string;
hint?: string;
title?: string;
}) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{label}</dt>
<dd className="nums mt-0.5 truncate text-sm font-semibold text-fg" title={title}>
{value}
{hint ? <span className="ml-1 text-[11px] font-normal text-muted">{hint}</span> : null}
</dd>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="min-w-0">
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted">
{title}
</h3>
{children}
</section>
);
}
/**
* A proposed change, at rail width.
*
* 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-[11px] leading-4 text-muted">
{state.label}
{recordLabel(approval.change) ? ` · ${recordLabel(approval.change)}` : ''}
</p>
</div>
</div>
);
}
const CHANGE_STATES: Record<
ApprovalStep['state'],
{ label: string; icon: typeof CheckCircle2; className: string }
> = {
pending: { label: 'Waiting for you', icon: TriangleAlert, className: 'text-warning' },
submitting: { label: 'Sending your decision', icon: TriangleAlert, className: 'text-warning' },
applied: { label: 'Applied', icon: CheckCircle2, className: 'text-positive' },
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('_', ' ');
}
/**
* `pig_get_workspace_summary` → "Workspace summary".
*
* Deliberately mechanical rather than a second copy of the label table in
* `piggy/tool.tsx`: that one exists to name a step in the transcript, where the
* exact wording matters and a missing entry is visible. Here the name is a
* grouping key in a list of counts, and a table kept in two files is a table
* that disagrees with itself the first time a tool is renamed.
*/
function toolLabel(name: string): string {
const words = name.replace(/^pig_/, '').replaceAll('_', ' ').trim();
return words ? `${words.charAt(0).toUpperCase()}${words.slice(1)}` : name;
}
@@ -0,0 +1,211 @@
/**
* The first thing anyone sees after signing in.
*
* 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.
*
* 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 { piggySuggestions } from '@/lib/piggy-suggestions';
import { PiggyMark } from '@/components/PiggyMark';
import { 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 middle column is under ~40rem: stack the two groups. */
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 tight where there is not: at
// 393x852 the six-line version needs every one of these 40 pixels to land
// whole above the composer.
<div
className={cn(
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
narrow ? 'gap-4 py-1' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
<PiggyMark className={cn('text-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
<h2
className={cn(
'font-semibold tracking-tight',
narrow ? 'mt-2' : 'mt-3',
narrow ? 'text-base' : 'text-lg sm:text-xl',
)}
>
Ask across the book and now, act on the answer.
</h2>
<p className={cn('mt-1.5 max-w-xl text-muted', narrow ? 'text-xs leading-5' : 'text-sm leading-6')}>
{narrow
? 'Piggy reads your PIG records through scoped tools. Switched to Ask first, it drafts changes for you to approve.'
: 'Piggy reads your PIG records through scoped tools, with no shell, filesystem or browser. Switched to Ask first, it also drafts changes: each one arrives as a card you read and approve, and nothing reaches the book until you do.'}
</p>
</div>
<div className={cn('grid gap-4', narrow ? 'grid-cols-1' : 'sm:grid-cols-2')}>
<StarterGroup
icon={<Search aria-hidden className="size-3.5" />}
title="Look something up"
/* Dropped on a phone, where the two columns are stacked and every
line costs: the hero above has just said the same thing, and the
note that has to survive is the one about writing. */
note={narrow ? null : 'Answered from your records, with the rows it read attached.'}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
{suggestion}
</StarterButton>
))}
</StarterGroup>
<StarterGroup
icon={<PenLine aria-hidden className="size-3.5" />}
title="Get something done"
note={
canWrite
? mode === 'read_only'
? 'These switch Piggy to Ask first: it proposes the change, you press Apply. It asks which record if your line does not say.'
: 'Piggy shows you exactly what it would write, and asks which record if your line does not say.'
: 'Your access does not allow changing records, so Piggy can only read.'
}
>
{writes.map((suggestion) => (
<StarterButton
key={suggestion}
disabled={!canWrite}
onClick={() => onAskWithChange(suggestion)}
>
{suggestion}
</StarterButton>
))}
</StarterGroup>
</div>
</div>
);
}
function StarterGroup({
icon,
title,
note,
children,
}: {
icon: React.ReactNode;
title: string;
note: string | null;
children: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
{icon}
{title}
</h3>
<div className="flex flex-col gap-1.5">{children}</div>
{note ? <p className="text-[11px] leading-4 text-muted">{note}</p> : null}
</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,163 @@
/**
* 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.
*
* Standing caveat, stated where it will be found: NOTHING WRITES THESE ROWS YET.
* `PiggyConversationService.appendMessage` exists and is tested, and the chat
* relay does not call it — see the report. So today every stored conversation
* reopens empty, and this module is the half of the loop that is ready.
*/
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';
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,632 @@
/**
* 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 a header that stays two rows tall.
*
* The transcript, the composer and the approval cards are `PiggyChatPanel` —
* the same component the dock and the phone drawer use — so this file is a
* layout and a set of decisions about conversations, not a second chat client.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
AlertTriangle,
History,
Info,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
PanelRightClose,
SquarePen,
} from 'lucide-react';
import type { PiggyChatContext } from '@pig/core';
import { get, post } from '@/lib/api';
import { useIsMobile, useMediaQuery } from '@/hooks/use-media-query';
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;
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),
// The transcript is immutable history plus whatever this tab has since
// added, so refetching it under a live conversation would replace what is
// on screen with what the server had before this turn started.
staleTime: Infinity,
retry: false,
});
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 (
<div className="flex h-full min-h-0 w-full overflow-hidden">
{isMobile ? null : (
<aside
className={cn(
'flex h-full min-h-0 shrink-0 border-r border-border bg-surface',
// The rail sets its own 3.75rem when collapsed; only the expanded
// width is the parent's to decide.
historyExpanded && 'w-[17rem]',
)}
aria-label="Piggy conversations"
>
{list}
</aside>
)}
<PiggyWorkspaceThread
key={activeId ?? `new-${newThread}`}
conversationId={activeId}
title={detail.data?.title ?? null}
initialMessages={detail.data ? toTranscript(detail.data.messages) : undefined}
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}
/>
<Sheet open={historySheet} onOpenChange={setHistorySheet}>
<SheetContent side="left" className="flex w-[19rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="sr-only">
<SheetTitle>Conversations</SheetTitle>
<SheetDescription>Your Piggy history. Pick one to carry on.</SheetDescription>
</SheetHeader>
{isMobile ? list : null}
</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();
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} />
);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="shrink-0 border-b border-border bg-surface px-2 py-2 sm:px-3">
<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-sm font-semibold tracking-tight">{title ?? 'Piggy'}</h1>
<p className="hidden truncate text-[11px] leading-4 text-muted sm:block">
{conversationId
? 'Running on Prime Agent, with your PIG records and nothing else.'
: 'New conversation. It is filed under your history as soon as you ask.'}
</p>
</div>
{isMobile ? 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. */}
{isMobile ? <div className="mt-2">{controlsRow}</div> : null}
</header>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-bg">
{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"
emptyState={
<div className="flex min-h-0 flex-1 flex-col gap-3">
{conversationId ? <ResumedNotice /> : null}
<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>
}
/>
)}
</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-[21rem] flex-col p-0 sm:max-w-none">
<SheetHeader className="border-b border-border px-4 py-3 pr-14 text-left">
<SheetTitle className="text-sm">Activity</SheetTitle>
<SheetDescription className="text-xs">
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 };
}
/**
* A stored conversation that opens with nothing in it.
*
* Which is every stored conversation today: the transcript tables exist and
* `appendMessage` is tested, and the chat relay does not call it yet — so a
* thread reopened tomorrow is a title and no words. Saying so is the only
* honest option; showing the front door's openers with no explanation would
* read as history that had been lost.
*/
function ResumedNotice() {
return (
<p className="mx-auto flex w-full max-w-3xl items-start gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs leading-5 text-muted">
<Info aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span>
Nothing is stored in this thread yet Piggy does not write transcripts to your history in
this build. Ask below and it carries on from here.
</span>
</p>
);
}
function ThreadSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-hidden>
<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];
}