Files
pig/apps/web/src/components/PiggyChat.tsx
T
karti 2763531ce4
CI / verify (push) Successful in 2m53s
Align shadcn's accent token with what shadcn means by it
shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command
row selection, ghost and outline button hover, the dialog close affordance. The
brand colour in shadcn is `primary`.

PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That
inverted the meaning, so every shadcn hover and selection state painted a
full-strength brand block. With the monochrome "pig" palette in dark mode the
brand is near-white, so a selected command row rendered as a white slab against
a near-black sheet. Measured before the change: selected row rgb(250,250,250)
on a rgb(9,9,11) body.

`accent` now aliases `--accent-subtle` and `accent-foreground` aliases
`--accent-fg`, which is what those tokens were created for. The eleven places
where PIG's own components wanted a solid brand fill — filled chips, selected
card borders, progress bars — move to `primary`, which still resolves to
`--accent`. A `brand` alias is added for clarity.

After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a
subtle tint above the body; the pipeline's active stage chip stays a solid
rgb(250,250,250) fill, unchanged.

Found by opening overlays, which earlier screenshot sweeps never did — every
route had been checked, but a dropdown or a command palette only misbehaves
once it is open. Worth remembering: page-level sweeps do not exercise portals.

Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px
horizontal overflow across 12 routes at 393px and 1440px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:17:49 -07:00

333 lines
13 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Bot,
Brain,
CheckCircle2,
CircleStop,
Database,
Loader2,
MessageCircleMore,
Send,
Sparkles,
XCircle,
} from 'lucide-react';
import { get } from '@/lib/api';
import {
streamPiggyChat,
type PiggyChatContext,
type PiggyChatEvent,
type PiggyChatTurn,
type PiggyStatus,
} from '@/lib/piggy-chat';
import { Badge, Button, EmptyState, cn } from './ui';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from './ui/drawer';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from './ui/sheet';
import { Textarea } from './ui/textarea';
interface ToolStep {
id: string;
name: string;
arguments: unknown;
state: 'running' | 'succeeded' | 'failed';
error?: string;
}
interface TranscriptMessage {
id: string;
role: 'user' | 'assistant';
content: string;
reasoning?: string;
tools?: ToolStep[];
error?: string;
pending?: boolean;
}
export function PiggyAskButton({
context,
prompt,
label = 'Ask Piggy',
variant = 'outline',
}: {
context?: PiggyChatContext;
prompt?: string;
label?: string;
variant?: React.ComponentProps<typeof Button>['variant'];
}) {
const [open, setOpen] = useState(false);
const status = usePiggyStatus();
const unavailable = status.data && !status.data.canUse;
return (
<>
<Button
type="button"
variant={variant}
disabled={Boolean(unavailable)}
title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
onClick={() => setOpen(true)}
>
<MessageCircleMore aria-hidden />
{label}
</Button>
<ResponsivePiggyChat
open={open}
onOpenChange={setOpen}
context={context}
initialPrompt={prompt}
/>
</>
);
}
export function PiggyChatWorkspace() {
const status = usePiggyStatus();
if (status.isLoading) return <div className="h-96 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 Piggy and connect the internal service.'
}
/>
);
}
return <PiggyChatPanel className="h-[calc(100dvh-12rem)] min-h-[32rem] rounded-xl border border-border bg-surface" />;
}
function ResponsivePiggyChat({
open,
onOpenChange,
context,
initialPrompt,
}: {
open: boolean;
onOpenChange(open: boolean): void;
context?: PiggyChatContext;
initialPrompt?: string;
}) {
const desktop = useDesktop();
if (desktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
<SheetHeader className="border-b border-border px-5 py-4">
<SheetTitle>Ask Piggy</SheetTitle>
<SheetDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</SheetDescription>
</SheetHeader>
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
</SheetContent>
</Sheet>
);
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[92dvh]">
<DrawerHeader className="border-b border-border px-4 pb-3 pt-2 text-left">
<DrawerTitle>Ask Piggy</DrawerTitle>
<DrawerDescription>{context?.label ? `Working from ${context.label}` : 'Working from your PIG workspace'}</DrawerDescription>
</DrawerHeader>
<PiggyChatPanel context={context} initialPrompt={initialPrompt} className="min-h-0 flex-1" />
</DrawerContent>
</Drawer>
);
}
function PiggyChatPanel({
context,
initialPrompt = '',
className,
}: {
context?: PiggyChatContext;
initialPrompt?: string;
className?: string;
}) {
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [draft, setDraft] = useState(initialPrompt);
const [running, setRunning] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: running ? 'auto' : 'smooth' }), [messages, running]);
useEffect(() => () => abortRef.current?.abort(), []);
const send = async () => {
const message = draft.trim();
if (!message || running) return;
const user: TranscriptMessage = { id: crypto.randomUUID(), role: 'user', content: message };
const assistantId = crypto.randomUUID();
const history: PiggyChatTurn[] = messages
.filter((entry) => entry.content.trim())
.slice(-20)
.map((entry) => ({ role: entry.role, content: entry.content }));
setMessages((current) => [
...current,
user,
{ id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
]);
setDraft('');
setRunning(true);
const abort = new AbortController();
abortRef.current = abort;
try {
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
setMessages((current) =>
current.map((entry) =>
entry.id === assistantId ? applyEvent(entry, event) : entry,
),
);
}
} catch (error) {
if (!abort.signal.aborted) {
setMessages((current) =>
current.map((entry) =>
entry.id === assistantId
? { ...entry, pending: false, error: error instanceof Error ? error.message : 'Piggy chat failed.' }
: entry,
),
);
}
} finally {
abortRef.current = null;
setRunning(false);
}
};
return (
<div className={cn('flex min-h-0 flex-col', className)}>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">
{messages.length === 0 ? (
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-accent-subtle text-accent-fg"><Sparkles aria-hidden /></div>
<h2 className="mt-4 font-semibold">What should we inspect?</h2>
<p className="mt-1 text-sm text-muted">Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access.</p>
<div className="mt-4 grid w-full gap-2">
{(context
? ['Summarise this record', 'What needs attention?', 'Which terms or dates matter most?']
: ['What needs attention across the book?', 'Summarise active commitments', 'Which renewals are approaching?']
).map((suggestion) => (
<button key={suggestion} type="button" className="min-h-11 rounded-lg border border-border px-3 text-left text-sm hover:bg-surface-2" onClick={() => setDraft(suggestion)}>{suggestion}</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
<div ref={bottomRef} />
</div>
)}
</div>
<form className="border-t border-border bg-surface p-3 sm:p-4" onSubmit={(event) => { event.preventDefault(); void send(); }}>
{context ? <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {context.label ?? context.type.replaceAll('_', ' ')}</Badge> : null}
<div className="flex items-end gap-2">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
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={() => abortRef.current?.abort()}><CircleStop aria-hidden /></Button>
) : (
<Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
)}
</div>
<p className="mt-2 text-center text-[11px] text-muted">Check source records before acting on material terms.</p>
</form>
</div>
);
}
function ChatMessage({ message }: { message: TranscriptMessage }) {
if (message.role === 'user') {
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
}
return (
<div className="flex gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-subtle text-accent-fg"><Bot aria-hidden /></div>
<div className="min-w-0 flex-1">
{message.reasoning ? (
<details className="mb-2 rounded-lg bg-surface-2 text-xs text-muted">
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2 font-medium"><Brain aria-hidden /> Reasoning</summary>
<p className="whitespace-pre-wrap px-3 pb-3">{message.reasoning}</p>
</details>
) : null}
{message.tools?.length ? <ToolTimeline tools={message.tools} /> : null}
{message.content ? <p className="whitespace-pre-wrap text-sm leading-6">{message.content}</p> : null}
{message.pending && !message.content ? <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG</div> : null}
{message.error ? <div className="mt-2 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
</div>
</div>
);
}
function ToolTimeline({ tools }: { tools: ToolStep[] }) {
return (
<div className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity">
{tools.map((tool) => (
<details key={tool.id} className="rounded-lg border border-border text-xs">
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-3 py-2">
{tool.state === 'running' ? <Loader2 className="animate-spin text-muted" aria-hidden /> : tool.state === 'succeeded' ? <CheckCircle2 className="text-positive" aria-hidden /> : <XCircle className="text-danger" aria-hidden />}
<span className="font-medium">{toolLabel(tool.name)}</span>
<span className="ml-auto text-muted">{tool.state === 'running' ? 'Running' : tool.state === 'succeeded' ? 'Complete' : 'Failed'}</span>
</summary>
<pre className="overflow-x-auto border-t border-border p-3 text-[11px] text-muted">{tool.error ?? JSON.stringify(tool.arguments, null, 2)}</pre>
</details>
))}
</div>
);
}
function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
if (event.type === 'tool_call') return { ...message, tools: [...(message.tools ?? []), { id: event.id, name: event.name, arguments: event.arguments, state: 'running' }] };
if (event.type === 'tool_result') return { ...message, tools: (message.tools ?? []).map((tool) => tool.id === event.id ? { ...tool, state: event.ok ? 'succeeded' : 'failed', error: event.error } : tool) };
if (event.type === 'done') return { ...message, pending: false };
if (event.type === 'error') return { ...message, pending: false, error: event.message };
return message;
}
function usePiggyStatus() {
return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
}
function useDesktop(): boolean {
const [desktop, setDesktop] = useState(() => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches);
useEffect(() => {
const media = window.matchMedia('(min-width: 768px)');
const update = () => setDesktop(media.matches);
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
return desktop;
}
function toolLabel(name: string): string {
return name.replace(/^pig_/, '').replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}