2763531ce4
CI / verify (push) Successful in 2m53s
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>
333 lines
13 KiB
TypeScript
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());
|
|
}
|