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,8 +1,11 @@
import type { RpcClient } from "../../../packages/contracts/src/rpc";
import type { RpcClient, ServerEvent } from "../../../packages/contracts/src/rpc";
declare global {
interface Window {
mosaic?: RpcClient;
mosaic?: RpcClient & {
on(eventType: string, callback: (data: ServerEvent) => void): () => void;
removeAllListeners(eventType?: string): void;
};
}
}

View File

@@ -0,0 +1,173 @@
/**
* React hook for subscribing to server events
*/
import { useEffect, useCallback, useRef } from "react";
import type { ServerEvent } from "@mosaiciq/contracts/rpc";
type EventListener = (event: ServerEvent) => void;
type EventType = ServerEvent["type"];
export function useServerEvents(
eventType: EventType,
callback: (data: ServerEvent) => void,
deps: React.DependencyList = []
) {
const callbackRef = useRef(callback);
// Keep callback ref updated
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
// Check if mosaic API is available
if (!window.mosaic) {
console.warn("[useServerEvents] mosaic API not available");
return;
}
// Check if on method exists
if (typeof window.mosaic.on !== "function") {
console.warn("[useServerEvents] mosaic.on method not available");
return;
}
// Subscribe to events
const unsubscribe = window.mosaic.on(eventType, (data: unknown) => {
const event = data as ServerEvent;
callbackRef.current(event);
});
// Cleanup on unmount
return () => {
unsubscribe();
};
}, [eventType, ...deps]);
}
export function useMultiServerEvents(
eventTypes: EventType[],
callback: (event: ServerEvent) => void,
deps: React.DependencyList = []
) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (!window.mosaic || typeof window.mosaic.on !== "function") {
return;
}
const unsubscribes: Array<() => void> = [];
for (const eventType of eventTypes) {
const unsubscribe = window.mosaic.on(eventType, (data: unknown) => {
const event = data as ServerEvent;
callbackRef.current(event);
});
unsubscribes.push(unsubscribe);
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
}, [eventTypes, ...deps]);
}
/**
* Hook for agent-specific events
*/
export function useAgentEvents(
agentId: string | null,
onProgress?: (progress: number, action: string) => void,
onCompleted?: (output: unknown) => void,
onFailed?: (error: string) => void,
onStreaming?: (chunk: string) => void
) {
const handleEvent = useCallback((event: ServerEvent) => {
if (!agentId) return;
if (event.type === "agent.progress" && event.data.agentId === agentId) {
onProgress?.(event.data.progress, event.data.action);
} else if (event.type === "agent.completed" && event.data.agentId === agentId) {
onCompleted?.(event.data.output);
} else if (event.type === "agent.failed" && event.data.agentId === agentId) {
onFailed?.(event.data.error);
} else if (event.type === "agent.streaming" && event.data.agentId === agentId) {
onStreaming?.(event.data.chunk);
}
}, [agentId, onProgress, onCompleted, onFailed, onStreaming]);
useMultiServerEvents(
["agent.progress", "agent.completed", "agent.failed", "agent.streaming"],
handleEvent,
[agentId]
);
}
/**
* Hook for all agent events (for agent list updates)
*/
export function useAllAgentEvents(
onAgentUpdate?: (agentId: string, update: { status?: string; progress?: number; action?: string }) => void
) {
const handleEvent = useCallback((event: ServerEvent) => {
if (event.type === "agent.progress") {
onAgentUpdate?.(event.data.agentId, {
progress: event.data.progress,
action: event.data.action,
status: "running"
});
} else if (event.type === "agent.completed") {
onAgentUpdate?.(event.data.agentId, {
status: "completed",
progress: 100
});
} else if (event.type === "agent.failed") {
onAgentUpdate?.(event.data.agentId, {
status: "failed"
});
} else if (event.type === "agent.started") {
onAgentUpdate?.(event.data.agentId, {
status: "running",
progress: 0
});
}
}, [onAgentUpdate]);
useMultiServerEvents(
["agent.progress", "agent.completed", "agent.failed", "agent.started"],
handleEvent,
[]
);
}
/**
* Hook for validation events
*/
export function useValidationEvents(
companyId: string | null,
onValidationUpdated?: (data: {
sectionId?: string;
validationState: "verified" | "flagged" | "unverified" | "failed";
agentId: string;
notes?: string;
}) => void
) {
useServerEvents("validation.updated", (event) => {
if (companyId && event.data.companyId === companyId) {
onValidationUpdated?.(event.data as {
sectionId?: string;
validationState: "verified" | "flagged" | "unverified" | "failed";
agentId: string;
notes?: string;
});
}
}, [companyId, onValidationUpdated]);
}

View File

@@ -1,12 +1,18 @@
import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc";
import { parseRpcResponse } from "../../../packages/contracts/src/rpcSchemas";
import { handleMockRpc } from "../../../packages/shared/src/mockRpc";
export const rpc: RpcClient = {
call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]) {
if (!window.mosaic) {
return handleMockRpc(method, payload);
async call<T extends RpcMethod>(method: T, payload: RpcRequestMap[T]) {
const useMockRpc = !window.mosaic;
if (useMockRpc) {
const result = await handleMockRpc(method, payload);
if (process.env.NODE_ENV !== "production" && result.ok) {
parseRpcResponse(method, result.data);
}
return result;
}
return window.mosaic.call(method, payload);
return window.mosaic!.call(method, payload);
}
};

View File

@@ -19,6 +19,16 @@
--density-cell-pad: 8px 12px;
--density-metric-min: 140px;
--density-nav-w: 240px;
/* Component-specific tokens */
--tag-bg: oklch(94% 0.01 80);
--tag-fg: oklch(48% 0.015 60);
--input-bg: oklch(99% 0.005 80);
--input-border: oklch(89% 0.012 80);
--hover-bg: oklch(94% 0.01 80);
--active-bg: oklch(92% 0.015 80);
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--focus-ring: oklch(58% 0.16 35);
}
[data-theme="dark"] {
@@ -30,6 +40,16 @@
--accent: oklch(65% 0.16 35);
--green: oklch(60% 0.12 145);
--red: oklch(60% 0.14 25);
/* Component-specific overrides */
--tag-bg: oklch(24% 0.01 80);
--tag-fg: oklch(70% 0.01 80);
--input-bg: oklch(18% 0.008 80);
--input-border: oklch(28% 0.01 80);
--hover-bg: oklch(22% 0.015 80);
--active-bg: oklch(25% 0.02 80);
--shadow: 0 1px 3px rgba(0,0,0,0.3);
--focus-ring: oklch(65% 0.16 35);
}
[data-density="compact"] {
@@ -189,6 +209,21 @@ button, input, select, textarea { font: inherit; }
background: oklch(28% 0.03 80);
}
/* ─── Dark Mode Component Overrides ─── */
[data-theme="dark"] .memo-editor-body {
color: var(--fg);
}
[data-theme="dark"] .memo-editor-body a {
color: oklch(70% 0.16 35);
}
[data-theme="dark"] .memo-editor-body code {
background: oklch(22% 0.01 80);
}
[data-theme="dark"] .memo-editor-body blockquote {
color: var(--muted);
border-left-color: oklch(65% 0.16 35);
}
/* ─── Responsive ─── */
@media (max-width: 1023px) {
body > *:not(.floor-message) { display: none !important; }

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";