Implement RPC contract validation baseline
This commit is contained in:
@@ -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 }]);
|
||||
|
||||
428
apps/web/src/ui/components/AgentConfigPanel.tsx
Normal file
428
apps/web/src/ui/components/AgentConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/web/src/ui/components/EmptyState.tsx
Normal file
186
apps/web/src/ui/components/EmptyState.tsx
Normal 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}</>;
|
||||
}
|
||||
247
apps/web/src/ui/components/ErrorState.tsx
Normal file
247
apps/web/src/ui/components/ErrorState.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
267
apps/web/src/ui/components/Skeleton.tsx
Normal file
267
apps/web/src/ui/components/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
apps/web/src/ui/components/ValidationBadge.tsx
Normal file
175
apps/web/src/ui/components/ValidationBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/ui/components/index.ts
Normal file
9
apps/web/src/ui/components/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user