Implement RPC contract validation baseline

This commit is contained in:
2026-05-14 15:41:51 -04:00
parent 379c07b50c
commit df367756d0
60 changed files with 10704 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Agent, Company, ExportRecord, Holding, MemoAnnotation, MemoSection, MemoSectionReview, ModelRow, RpcResponseMap, Screen } from "@mosaiciq/contracts/rpc";
import type { Agent, ClientSettings, Company, ExportRecord, Holding, MemoAnnotation, MemoSection, MemoSectionReview, ModelRow, RpcResponseMap, Screen } from "@mosaiciq/contracts/rpc";
import { rpc } from "../rpcClient";
import { cx } from "../lib/cn";
import { capitalize, formatPct, formatTime, toneText } from "../lib/format";
@@ -7,6 +7,7 @@ import { editableHtmlToMarkdown, getAnnotationSignature, getCaretTextOffset, get
import { agentCatalog, demoCatalysts, demoAlerts, demoRisks, demoEarnings, demoFilings, demoExports, extraHoldings, keyboardShortcuts, modelTabs, screens, workspaceGroups } from "../lib/constants";
import { ui } from "../lib/styles";
import type { Alert, Catalyst, EarningsSchedule, Filing, Risk } from "@mosaiciq/contracts/rpc";
import { useAllAgentEvents } from "../hooks/useServerEvents";
type AppData = {
holdings: Holding[];
@@ -74,6 +75,31 @@ export function App() {
}
}, [theme, density]);
// Load settings from backend on mount
useEffect(() => {
async function loadSettings() {
const result = await rpc.call("settings.get", { scope: "client" });
if (result.ok) {
const settings = result.data.settings as ClientSettings;
if (settings.theme) setTheme(settings.theme);
if (settings.density) setDensity(settings.density);
}
}
void loadSettings();
}, []);
// Persist theme changes
const handleThemeChange = useCallback(async (newTheme: Theme) => {
setTheme(newTheme);
await rpc.call("settings.update", { scope: "client", changes: { theme: newTheme } });
}, []);
// Persist density changes
const handleDensityChange = useCallback(async (newDensity: Density) => {
setDensity(newDensity);
await rpc.call("settings.update", { scope: "client", changes: { density: newDensity } });
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
@@ -116,6 +142,19 @@ export function App() {
const activeAgents = useMemo(() => data.agents.filter((a) => a.status === "running" || a.status === "paused"), [data.agents]);
// Subscribe to agent events for real-time updates
useAllAgentEvents(useCallback((agentId: string, update: { status?: string; progress?: number; action?: string }) => {
setData((prev) => ({
...prev,
agents: prev.agents.map((a) => {
if (a.id === agentId) {
return { ...a, ...update } as typeof a;
}
return a;
}),
}));
}, []));
useEffect(() => {
function handleKey(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
@@ -167,7 +206,7 @@ export function App() {
if (res.ok) setAgentChatMessages((m) => [...m, { role: "agent", text: res.data.response }]);
}} chatInput={agentChatInput} onChatInputChange={setAgentChatInput} />
<ToastContainer toasts={toasts} onDismiss={removeToast} />
<SettingsOverlay open={settingsOpen} onClose={() => setSettingsOpen(false)} panel={settingsPanel} onPanelChange={setSettingsPanel} theme={theme} onThemeChange={setTheme} density={density} onDensityChange={setDensity} />
<SettingsOverlay open={settingsOpen} onClose={() => setSettingsOpen(false)} panel={settingsPanel} onPanelChange={setSettingsPanel} theme={theme} onThemeChange={handleThemeChange} density={density} onDensityChange={handleDensityChange} />
<ProfileOverlay open={profileOpen} onClose={() => setProfileOpen(false)} />
<AgentFullscreenOverlay open={agentFullscreenOpen} onClose={() => setAgentFullscreenOpen(false)} agents={data.agents} activeTab={agentFullscreenTab} onTabChange={setAgentFullscreenTab} chatMessages={agentChatMessages} chatInput={agentChatInput} onChatInputChange={setAgentChatInput} onChatSend={async (msg) => {
setAgentChatMessages((m) => [...m, { role: "analyst", text: msg }]);

View File

@@ -0,0 +1,428 @@
/**
* Agent Configuration Panel
* Per design doc §6.3 - Slide-in 360px panel for agent customization
*/
import { useState, useEffect } from "react";
import { cx } from "../../lib/cn";
import { rpc } from "../../rpcClient";
import { ui } from "../../lib/styles";
export interface AgentConfig {
dataSources: {
secFilings: boolean;
transcripts: boolean;
marketData: boolean;
analystReports: boolean;
pressReleases: boolean;
};
model: {
llm: "pi" | "gpt-4" | "claude";
temperature: number;
maxTokens: number;
};
scheduling: "auto" | "daily" | "manual";
outputFormat: {
includeCitations: boolean;
includeConfidence: boolean;
includeAssumptions: boolean;
};
}
const defaultConfig: AgentConfig = {
dataSources: {
secFilings: true,
transcripts: true,
marketData: false,
analystReports: false,
pressReleases: true,
},
model: {
llm: "pi",
temperature: 0,
maxTokens: 4096,
},
scheduling: "manual",
outputFormat: {
includeCitations: true,
includeConfidence: true,
includeAssumptions: true,
},
};
export function AgentConfigPanel({
agentId,
open,
onClose,
}: {
agentId: string;
open: boolean;
onClose: () => void;
}) {
const [config, setConfig] = useState<AgentConfig>(defaultConfig);
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Load existing config when panel opens
useEffect(() => {
if (open && agentId) {
loadConfig();
}
}, [open, agentId]);
async function loadConfig() {
const result = await rpc.call("settings.get", { scope: "server" });
if (result.ok) {
const settings = result.data.settings as { agentConfigs?: Record<string, unknown> };
const agentConfig = settings.agentConfigs?.[agentId] as AgentConfig | undefined;
if (agentConfig) {
setConfig(agentConfig);
}
}
}
async function saveConfig() {
setIsSaving(true);
await rpc.call("agent.configure", { agentId, config: config as unknown as Record<string, unknown> });
await rpc.call("settings.update", { scope: "server", changes: { agentConfigs: { [agentId]: config } } });
setIsSaving(false);
setHasChanges(false);
}
function updateConfig<K extends keyof AgentConfig>(key: K, value: AgentConfig[K]) {
setConfig((prev) => ({ ...prev, [key]: value }));
setHasChanges(true);
}
function updateDataSource(key: keyof AgentConfig["dataSources"], value: boolean) {
setConfig((prev) => ({
...prev,
dataSources: { ...prev.dataSources, [key]: value },
}));
setHasChanges(true);
}
function updateOutputFormat(key: keyof AgentConfig["outputFormat"], value: boolean) {
setConfig((prev) => ({
...prev,
outputFormat: { ...prev.outputFormat, [key]: value },
}));
setHasChanges(true);
}
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative w-[360px] h-full bg-[var(--bg)] shadow-xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3">
<div>
<h2 className="text-sm font-semibold">Agent Configuration</h2>
<p className="text-[11px] text-[var(--muted)]">{agentId.toUpperCase()}</p>
</div>
<button
className={ui.iconBtn}
onClick={onClose}
aria-label="Close panel"
type="button"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Data Sources */}
<section>
<h3 className="mb-3 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Data Sources
</h3>
<div className="space-y-2">
<ToggleRow
label="SEC Filings"
checked={config.dataSources.secFilings}
onChange={(v) => updateDataSource("secFilings", v)}
/>
<ToggleRow
label="Earnings Transcripts"
checked={config.dataSources.transcripts}
onChange={(v) => updateDataSource("transcripts", v)}
/>
<ToggleRow
label="Market Data"
checked={config.dataSources.marketData}
onChange={(v) => updateDataSource("marketData", v)}
/>
<ToggleRow
label="Analyst Reports"
checked={config.dataSources.analystReports}
onChange={(v) => updateDataSource("analystReports", v)}
/>
<ToggleRow
label="Press Releases"
checked={config.dataSources.pressReleases}
onChange={(v) => updateDataSource("pressReleases", v)}
/>
</div>
</section>
{/* Model Settings */}
<section>
<h3 className="mb-3 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Model Settings
</h3>
<div className="space-y-4">
<SelectRow
label="LLM Model"
value={config.model.llm}
options={[
{ value: "pi", label: "Pi (Inflection AI)" },
{ value: "gpt-4", label: "GPT-4" },
{ value: "claude", label: "Claude" },
]}
onChange={(v) => updateConfig("model", { ...config.model, llm: v as any })}
/>
<RangeRow
label="Temperature"
value={config.model.temperature}
min={0}
max={1}
step={0.1}
formatValue={(v) => v.toFixed(1)}
onChange={(v) => updateConfig("model", { ...config.model, temperature: v })}
/>
<RangeRow
label="Max Tokens"
value={config.model.maxTokens}
min={1024}
max={8192}
step={1024}
formatValue={(v) => String(Math.round(v))}
onChange={(v) => updateConfig("model", { ...config.model, maxTokens: v })}
/>
</div>
</section>
{/* Scheduling */}
<section>
<h3 className="mb-3 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Scheduling
</h3>
<div className="space-y-2">
<RadioRow
label="Auto (on new filing)"
checked={config.scheduling === "auto"}
onChange={() => updateConfig("scheduling", "auto")}
/>
<RadioRow
label="Daily"
checked={config.scheduling === "daily"}
onChange={() => updateConfig("scheduling", "daily")}
/>
<RadioRow
label="Manual only"
checked={config.scheduling === "manual"}
onChange={() => updateConfig("scheduling", "manual")}
/>
</div>
</section>
{/* Output Format */}
<section>
<h3 className="mb-3 font-[var(--font-mono)] text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Output Format
</h3>
<div className="space-y-2">
<ToggleRow
label="Include citations"
checked={config.outputFormat.includeCitations}
onChange={(v) => updateOutputFormat("includeCitations", v)}
/>
<ToggleRow
label="Include confidence level"
checked={config.outputFormat.includeConfidence}
onChange={(v) => updateOutputFormat("includeConfidence", v)}
/>
<ToggleRow
label="Surface assumptions"
checked={config.outputFormat.includeAssumptions}
onChange={(v) => updateOutputFormat("includeAssumptions", v)}
/>
</div>
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--border)] bg-[var(--surface)] px-4 py-3">
<button
className={cx(ui.btn, ui.btnSm)}
onClick={() => {
setConfig(defaultConfig);
setHasChanges(true);
}}
disabled={isSaving}
type="button"
>
Reset
</button>
<div className="flex gap-2">
<button
className={cx(ui.btn, ui.btnSm)}
onClick={onClose}
disabled={isSaving}
type="button"
>
Cancel
</button>
<button
className={cx(ui.btn, ui.btnPrimary, ui.btnSm, !hasChanges && "opacity-50 cursor-not-allowed")}
onClick={saveConfig}
disabled={!hasChanges || isSaving}
type="button"
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
</div>
);
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className="flex items-center justify-between">
<span className="text-xs">{label}</span>
<button
className={cx(
"relative h-5 w-9 rounded-full transition-colors",
checked ? "bg-[var(--accent)]" : "bg-[var(--border)]"
)}
onClick={() => onChange(!checked)}
type="button"
role="switch"
aria-checked={checked}
>
<span
className={cx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
);
}
function RadioRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: () => void;
}) {
return (
<div className="flex items-center justify-between">
<span className="text-xs">{label}</span>
<button
className={cx(
"relative h-4 w-4 rounded-full border transition-colors",
checked ? "border-[var(--accent)]" : "border-[var(--border)]"
)}
onClick={onChange}
type="button"
role="radio"
aria-checked={checked}
>
{checked && (
<span className="absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-[var(--accent)]" />
)}
</button>
</div>
);
}
function SelectRow({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<div className="space-y-1">
<label className="text-xs text-[var(--muted)]">{label}</label>
<select
className="w-full border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]"
value={value}
onChange={(e) => onChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
function RangeRow({
label,
value,
min,
max,
step,
formatValue,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
formatValue: (v: number) => string;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--muted)]">{label}</label>
<span className="font-[var(--font-mono)] text-[10px] text-[var(--accent)]">
{formatValue(value)}
</span>
</div>
<input
type="range"
className="w-full"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}

View File

@@ -0,0 +1,186 @@
/**
* Empty State Components
* Displayed when there's no content to show
*/
import { cx } from "../../lib/cn";
import { ui } from "../../lib/styles";
export interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
size?: "sm" | "md" | "lg";
className?: string;
}
const sizeStyles = {
sm: {
icon: "text-2xl",
title: "text-sm",
description: "text-xs",
},
md: {
icon: "text-3xl",
title: "text-base",
description: "text-sm",
},
lg: {
icon: "text-4xl",
title: "text-lg",
description: "text-sm",
},
};
export function EmptyState({
icon = "◇",
title,
description,
action,
size = "md",
className,
}: EmptyStateProps) {
const styles = sizeStyles[size];
return (
<div className={cx("flex flex-col items-center justify-center py-12 text-center", className)}>
<span className={cx(styles.icon, "mb-3 text-[var(--muted)]")} aria-hidden="true">
{icon}
</span>
<p className={cx(styles.title, "font-medium text-[var(--fg)]")}>{title}</p>
{description && (
<p className={cx(styles.description, "mt-1 max-w-xs text-[var(--muted)]")}>
{description}
</p>
)}
{action && (
<button
className={cx(ui.btn, ui.btnPrimary, "mt-4")}
onClick={action.onClick}
type="button"
>
{action.label}
</button>
)}
</div>
);
}
/**
* Empty state for specific surfaces
*/
export function EmptyHoldings({ onAdd }: { onAdd?: () => void }) {
return (
<EmptyState
icon="○"
title="No holdings yet"
description="Add companies to your portfolio to begin tracking"
action={onAdd ? { label: "Add Holding", onClick: onAdd } : undefined}
size="lg"
/>
);
}
export function EmptyAgents({ onStart }: { onStart?: () => void }) {
return (
<EmptyState
icon="◇"
title="No agents running"
description="Start a research pipeline to begin analysis"
action={onStart ? { label: "Run Research", onClick: onStart } : undefined}
size="lg"
/>
);
}
export function EmptyMemo({ onWrite }: { onWrite?: () => void }) {
return (
<EmptyState
icon="◇"
title="Memo is empty"
description="Run agents to populate memo sections or write manually"
action={onWrite ? { label: "Start Writing", onClick: onWrite } : undefined}
size="md"
/>
);
}
export function EmptyModel({ onBuild }: { onBuild?: () => void }) {
return (
<EmptyState
icon="◇"
title="No model data"
description="Run the Financial Modeling agent to build projections"
action={onBuild ? { label: "Build Model", onClick: onBuild } : undefined}
size="md"
/>
);
}
export function EmptyCatalysts() {
return (
<EmptyState
icon="◇"
title="No catalysts tracked"
description="Catalysts will be added as they're identified"
size="sm"
/>
);
}
export function EmptyAlerts() {
return (
<EmptyState
icon="◇"
title="No alerts"
description="New thesis alerts will appear here"
size="sm"
/>
);
}
export function EmptyRisks({ onAdd }: { onAdd?: () => void }) {
return (
<EmptyState
icon="◇"
title="No risks documented"
description="Run the Risk agent to identify investment risks"
action={onAdd ? { label: "Identify Risks", onClick: onAdd } : undefined}
size="md"
/>
);
}
export function EmptyExports() {
return (
<EmptyState
icon="◇"
title="No exports yet"
description="Exported memos and models will appear here"
size="sm"
/>
);
}
/**
* Empty state wrapper - shows empty state when array is empty
*/
export function ShowWhenEmpty<T>({
items,
empty,
children,
}: {
items: T[] | undefined;
empty: React.ReactNode;
children: React.ReactNode;
}) {
if (!items || items.length === 0) {
return <>{empty}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,247 @@
/**
* Error State Components
* Displayed when an error occurs
*/
import { cx } from "../../lib/cn";
import { ui } from "../../lib/styles";
export type ErrorSeverity = "info" | "warning" | "error" | "critical";
export interface ErrorStateProps {
title: string;
message?: string;
severity?: ErrorSeverity;
action?: {
label: string;
onClick: () => void;
};
dismissible?: boolean;
onDismiss?: () => void;
className?: string;
}
const severityConfig = {
info: {
icon: "",
containerClass: "border-l-[var(--muted)] bg-[oklch(97%_0.008_80)]",
iconClass: "text-[var(--muted)]",
titleClass: "text-[var(--fg)]",
},
warning: {
icon: "⚠",
containerClass: "border-l-[var(--accent)] bg-[oklch(96%_0.03_80)]",
iconClass: "text-[var(--accent)]",
titleClass: "text-[var(--fg)]",
},
error: {
icon: "!",
containerClass: "border-l-[var(--red)] bg-[oklch(96%_0.04_25)]",
iconClass: "text-[var(--red)]",
titleClass: "text-[var(--fg)]",
},
critical: {
icon: "✕",
containerClass: "border-l-[var(--red)] bg-[oklch(94%_0.06_25)]",
iconClass: "text-[var(--red)]",
titleClass: "text-[var(--red)]",
},
};
const darkModeOverrides = {
info: {
containerClass: "border-l-[var(--muted)] bg-[oklch(22%_0.01_80)]",
},
warning: {
containerClass: "border-l-[var(--accent)] bg-[oklch(24%_0.02_80)]",
},
error: {
containerClass: "border-l-[var(--red)] bg-[oklch(24%_0.03_25)]",
},
critical: {
containerClass: "border-l-[var(--red)] bg-[oklch(22%_0.05_25)]",
},
};
export function ErrorState({
title,
message,
severity = "error",
action,
dismissible = false,
onDismiss,
className,
}: ErrorStateProps) {
const config = severityConfig[severity];
return (
<div
className={cx(
"l-4 border p-4 text-sm",
config.containerClass,
"[data-theme='dark']_" + severity + ":[background:" + darkModeOverrides[severity as keyof typeof darkModeOverrides].containerClass + "]",
className
)}
>
<div className="flex items-start gap-3">
<span className={cx("text-lg", config.iconClass)} aria-hidden="true">
{config.icon}
</span>
<div className="flex-1 min-w-0">
<p className={cx("font-medium", config.titleClass)}>{title}</p>
{message && (
<p className="mt-1 text-[var(--muted)]">{message}</p>
)}
{action && (
<button
className={cx(ui.btn, ui.btnSm, "mt-3")}
onClick={action.onClick}
type="button"
>
{action.label}
</button>
)}
</div>
{dismissible && onDismiss && (
<button
className="border-0 bg-transparent text-[var(--muted)] text-lg cursor-pointer p-0 leading-none"
onClick={onDismiss}
aria-label="Dismiss"
type="button"
>
×
</button>
)}
</div>
</div>
);
}
/**
* Inline error state - for smaller spaces
*/
export function InlineError({
message,
onRetry,
}: {
message: string;
onRetry?: () => void;
}) {
return (
<div className="flex items-center gap-2 text-xs text-[var(--red)]">
<span aria-hidden="true">!</span>
<span>{message}</span>
{onRetry && (
<button
className="underline cursor-pointer"
onClick={onRetry}
type="button"
>
Retry
</button>
)}
</div>
);
}
/**
* Full-page error state
*/
export function FullPageError({
title = "Something went wrong",
message,
onRetry,
onDismiss,
}: {
title?: string;
message?: string;
onRetry?: () => void;
onDismiss?: () => void;
}) {
return (
<div className="flex h-screen items-center justify-center p-9">
<div className="max-w-md text-center">
<span className="text-6xl text-[var(--red)]" aria-hidden="true">
</span>
<h1 className={cx(ui.h1, "mt-4")}>{title}</h1>
{message && <p className={cx(ui.muted, "mt-2")}>{message}</p>}
<div className="mt-6 flex justify-center gap-3">
{onRetry && (
<button className={cx(ui.btn, ui.btnPrimary)} onClick={onRetry} type="button">
Try Again
</button>
)}
{onDismiss && (
<button className={cx(ui.btn)} onClick={onDismiss} type="button">
Go Back
</button>
)}
</div>
</div>
</div>
);
}
/**
* Error panel - for sections within a page
*/
export function ErrorPanel({
title,
message,
severity = "error",
onRetry,
onDismiss,
}: {
title: string;
message?: string;
severity?: ErrorSeverity;
onRetry?: () => void;
onDismiss?: () => void;
}) {
return (
<ErrorState
title={title}
message={message}
severity={severity}
action={onRetry ? { label: "Retry", onClick: onRetry } : undefined}
dismissible={!!onDismiss}
onDismiss={onDismiss}
className="mb-4"
/>
);
}
/**
* API error display - for RPC errors
*/
export function ApiError({
error,
onRetry,
onDismiss,
}: {
error: { code: string; message: string; detail?: unknown };
onRetry?: () => void;
onDismiss?: () => void;
}) {
const severityMap: Record<string, ErrorSeverity> = {
NOT_FOUND: "warning",
VALIDATION_ERROR: "warning",
INTERNAL_ERROR: "error",
AGENT_FAILED: "error",
CONFLICT: "warning",
RATE_LIMITED: "info",
};
const severity = severityMap[error.code] || "error";
return (
<ErrorPanel
title={error.code.replace(/_/g, " ").toLowerCase()}
message={error.message}
severity={severity}
onRetry={onRetry}
onDismiss={onDismiss}
/>
);
}

View File

@@ -0,0 +1,267 @@
/**
* Skeleton Loading Components
* Display shimmer placeholders while content is loading
*/
import { cx } from "../../lib/cn";
import { ui } from "../../lib/styles";
/**
* Generic skeleton block
*/
export function Skeleton({
className,
variant = "default",
width,
height,
}: {
className?: string;
variant?: "default" | "circle" | "rounded";
width?: string;
height?: string;
}) {
return (
<div
className={cx(
"skeleton",
variant === "circle" && "rounded-full",
variant === "rounded" && "rounded-md",
className
)}
style={width || height ? { width: width || "100%", height } : undefined}
aria-hidden="true"
/>
);
}
/**
* Skeleton row - flexible width skeleton
*/
export function SkeletonRow({
width,
height = "12px",
className,
}: {
width?: string;
height?: string;
className?: string;
}) {
return (
<div
className={cx("skeleton", className)}
style={{ width: width || "100%", height }}
aria-hidden="true"
/>
);
}
/**
* Skeleton text - mimics a line of text
*/
export function SkeletonText({
lines = 3,
className,
}: {
lines?: number;
className?: string;
}) {
return (
<div className={cx("space-y-2", className)} aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<SkeletonRow
key={i}
width={i === lines - 1 ? "70%" : "100%"}
height="14px"
/>
))}
</div>
);
}
/**
* Skeleton metric card
*/
export function SkeletonMetric({ className }: { className?: string }) {
return (
<div className={cx("bg-[var(--surface)] p-3.5", className)} aria-hidden="true">
<Skeleton width="60px" height="10px" className="mb-2" />
<Skeleton width="80px" height="18px" />
</div>
);
}
/**
* Skeleton holding tile
*/
export function SkeletonHolding({ className }: { className?: string }) {
return (
<div
className={cx(
"grid grid-cols-[48px_56px_44px_28px] items-center gap-1.5 bg-[var(--surface)] p-2",
className
)}
aria-hidden="true"
>
<Skeleton width="48px" height="12px" />
<Skeleton width="56px" height="12px" />
<Skeleton width="44px" height="12px" />
<Skeleton width="28px" height="12px" />
</div>
);
}
/**
* Skeleton agent card
*/
export function SkeletonAgentCard({ className }: { className?: string }) {
return (
<div
className={cx("border border-[var(--border)] bg-[var(--bg)] p-3", className)}
aria-hidden="true"
>
<div className="mb-2 flex items-center justify-between">
<Skeleton width="100px" height="14px" />
<Skeleton width="40px" height="10px" variant="rounded" />
</div>
<Skeleton width="120px" height="12px" className="mb-3" />
<div className="h-1 overflow-hidden rounded-full bg-[var(--border)]">
<Skeleton width="60%" height="100%" className="h-full rounded-none" />
</div>
</div>
);
}
/**
* Skeleton spreadsheet/table
*/
export function SkeletonSpreadsheet({
rows = 5,
cols = 4,
className,
}: {
rows?: number;
cols?: number;
className?: string;
}) {
return (
<div className={cx("border border-[var(--border)]", className)} aria-hidden="true">
{/* Header */}
<div className="flex border-b border-[var(--border)] bg-[var(--surface)]">
{Array.from({ length: cols }).map((_, i) => (
<div key={`h-${i}`} className="flex-1 p-2">
<Skeleton width="80%" height="12px" />
</div>
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={`r-${rowIdx}`} className="flex border-b border-[var(--border)] last:border-b-0">
{Array.from({ length: cols }).map((_, colIdx) => (
<div key={`c-${colIdx}`} className="flex-1 p-2">
<Skeleton
width={colIdx === 0 ? "90%" : "60%"}
height="12px"
/>
</div>
))}
</div>
))}
</div>
);
}
/**
* Skeleton memo block
*/
export function SkeletonMemoBlock({ className }: { className?: string }) {
return (
<div className={cx("border-t border-[var(--border)] pt-5", className)} aria-hidden="true">
<Skeleton width="200px" height="24px" className="mb-3" />
<SkeletonText lines={4} />
</div>
);
}
/**
* Skeleton panel (generic)
*/
export function SkeletonPanel({
title,
className,
children,
}: {
title?: boolean;
className?: string;
children?: React.ReactNode;
}) {
return (
<div className={cx("border border-[var(--border)] bg-[var(--bg)] p-3", className)} aria-hidden="true">
{title && <Skeleton width="120px" height="14px" className="mb-3" />}
{children || <SkeletonText lines={3} />}
</div>
);
}
/**
* Loading state wrapper - shows skeleton while loading, content when done
*/
export function LoadingState({
loading,
skeleton,
children,
}: {
loading: boolean;
skeleton: React.ReactNode;
children: React.ReactNode;
}) {
if (loading) {
return <>{skeleton}</>;
}
return <>{children}</>;
}
/**
* Full-page loading shell
*/
export function LoadingShell() {
return (
<div className="flex h-screen flex-col">
{/* Topbar */}
<header className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3">
<Skeleton width="120px" height="20px" />
<Skeleton width="200px" height="20px" className="ml-4" />
<div className="flex-1" />
<Skeleton width="200px" height="32px" />
<Skeleton width="32px" height="32px" variant="circle" className="ml-3" />
</header>
{/* Main content */}
<main className="flex-1 overflow-auto p-9">
<div className="mb-4">
<Skeleton width="160px" height="24px" className="mb-2" />
<Skeleton width="320px" height="40px" />
</div>
{/* Metric grid */}
<div className="mb-6 grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-px border border-[var(--border)] bg-[var(--border)]">
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonMetric key={i} />
))}
</div>
{/* Panel grid */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<SkeletonPanel key={i} title />
))}
</div>
</main>
{/* Footer */}
<footer className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-3">
<Skeleton width="320px" height="24px" />
<Skeleton width="384px" height="32px" className="ml-4" />
</footer>
</div>
);
}

View File

@@ -0,0 +1,175 @@
/**
* Validation Badge Component
* Shows the validation status of content with appropriate styling
*/
import { cx } from "../../lib/cn";
import { ui } from "../../lib/styles";
export type ValidationState = "verified" | "flagged" | "unverified" | "failed";
export interface ValidationBadgeProps {
state: ValidationState;
label?: string;
size?: "sm" | "md";
showIcon?: boolean;
onClick?: () => void;
}
const validationConfig = {
verified: {
icon: "✓",
label: "Verified",
className: "text-[var(--green)] border-[var(--green)] bg-[var(--green)]/10",
dotColor: "bg-[var(--green)]",
},
flagged: {
icon: "⚑",
label: "Flagged",
className: "text-[var(--accent)] border-[var(--accent)] bg-[var(--accent)]/10",
dotColor: "bg-[var(--accent)]",
},
unverified: {
icon: "—",
label: "Unverified",
className: "text-[var(--muted)] border-[var(--border)] bg-[var(--surface)]",
dotColor: "bg-[var(--muted)]",
},
failed: {
icon: "✗",
label: "Failed",
className: "text-[var(--red)] border-[var(--red)] bg-[var(--red)]/10",
dotColor: "bg-[var(--red)]",
},
};
export function ValidationBadge({
state,
label,
size = "sm",
showIcon = true,
onClick,
}: ValidationBadgeProps) {
const config = validationConfig[state];
if (size === "sm") {
return (
<button
className={cx(
"inline-flex items-center gap-1 border px-1.5 font-[var(--font-mono)] text-[10px] uppercase tracking-wider transition-colors",
config.className,
onClick && "cursor-pointer hover:opacity-80"
)}
onClick={onClick}
type="button"
>
{showIcon && <span>{config.icon}</span>}
<span>{label ?? config.label}</span>
</button>
);
}
return (
<button
className={cx(
"inline-flex items-center gap-2 rounded border px-2 py-1 text-xs font-medium transition-colors",
config.className,
onClick && "cursor-pointer hover:opacity-80"
)}
onClick={onClick}
type="button"
>
{showIcon && <span>{config.icon}</span>}
<span>{label ?? config.label}</span>
</button>
);
}
/**
* Validation status indicator (small dot)
*/
export function ValidationDot({ state }: { state: ValidationState }) {
const config = validationConfig[state];
return <span className={cx("inline-block h-2 w-2 rounded-full", config.dotColor)} aria-hidden="true" />;
}
/**
* Validation status text
*/
export function ValidationStatus({ state }: { state: ValidationState }) {
const config = validationConfig[state];
return (
<span className={cx("text-xs font-medium", config.className)}>
{config.label}
</span>
);
}
/**
* Validation border wrapper - adds colored border based on state
*/
export function ValidationBorder({
state,
children,
className,
}: {
state: ValidationState;
children: React.ReactNode;
className?: string;
}) {
const borderColors = {
verified: "border-l-[3px] border-l-[var(--green)]",
flagged: "border-l-[3px] border-l-[var(--accent)]",
unverified: "border-l-[3px] border-l-[var(--muted)]",
failed: "border-l-[3px] border-l-[var(--red)]",
};
return (
<div className={cx(borderColors[state], className)}>
{children}
</div>
);
}
/**
* Validation section wrapper - for memo sections with validation
*/
export function ValidationSection({
state,
children,
onValidate,
title,
}: {
state: ValidationState;
children: React.ReactNode;
onValidate?: () => void;
title: string;
}) {
const borderColors = {
verified: "border-l-[var(--green)]",
flagged: "border-l-[var(--accent)]",
unverified: "border-l-transparent",
failed: "border-l-[var(--red)]",
};
return (
<div className={cx("border-l-4 pl-3 transition-colors", borderColors[state])}>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">{title}</h3>
<div className="flex items-center gap-2">
<ValidationBadge state={state} size="sm" />
{state !== "verified" && onValidate && (
<button
className={cx(ui.btn, ui.btnSm)}
onClick={onValidate}
type="button"
>
Validate
</button>
)}
</div>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* UI Components Index
*/
export * from "./ValidationBadge.js";
export * from "./Skeleton.js";
export * from "./EmptyState.js";
export * from "./ErrorState.js";
export * from "./AgentConfigPanel.js";