forked from molecule-ai/molecule-core
Brings the canvas onto the warm-paper design system already shipped to landing, marketplace, and SaaS surfaces, and migrates the build from Tailwind v3 → v4 to match molecule-app. Plumbing: - swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss - delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS) - globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography" - two @theme blocks: warm-paper light defaults + always-dark surface tokens (bg-bg / ink-mute / line-strong) for terminal/console panels - [data-theme="dark"] cascade overrides the warm-paper tokens for dark - React Flow edge stroke + scrollbar + selection colour pull from semantic tokens so they flip with the theme Theme infra (ported from molecule-app, identical contracts): - lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client" so server components can read the constants) - lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with Domain=.moleculesai.app so the preference follows the user across canvas/app/market/landing subdomains AND tenant subdomains - lib/theme.ts: ColorToken union + cssVar() helper - components/ThemeToggle.tsx: 3-way System/Light/Dark picker - layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body Component migration (62 files): - Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white → semantic surface/ink/line tokens via perl negative-lookahead pass (preserves opacity modifiers like /80, /60) - bg-blue-500/600 → bg-accent / bg-accent-strong - text-red-* / amber-* / emerald-* → text-bad / warm / good - Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950 etc.) intentionally left literal — they remain readable on warm-paper in light mode without inventing new state-soft tokens - TerminalTab.tsx skipped — xterm renders to canvas, not DOM - 3 unit-test assertions updated to match new token strings (credits pillTone, AuthGate overlay class, A2AEdge accent) Verification: - pnpm test: 1214/1214 pass - pnpm tsc --noEmit: clean - next build: ✓ Compiled successfully (8 routes) - dev server inspection: html data-theme stamped, body uses bg-surface text-ink, boot script carries nonce, compiled CSS contains both @theme blocks + [data-theme="dark"] override Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
3.7 KiB
TypeScript
139 lines
3.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { api } from "@/lib/api";
|
||
|
||
export interface WorkspaceUsageProps {
|
||
workspaceId: string;
|
||
}
|
||
|
||
interface WorkspaceMetrics {
|
||
input_tokens?: number; // optional — provisioning-stuck workspaces return partial shapes
|
||
output_tokens?: number; // optional — same
|
||
total_calls?: number;
|
||
estimated_cost_usd?: string; // optional — same
|
||
period_start: string;
|
||
period_end: string;
|
||
}
|
||
|
||
export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||
const [metrics, setMetrics] = useState<WorkspaceMetrics | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let ignore = false;
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
api
|
||
.get<WorkspaceMetrics>(`/workspaces/${workspaceId}/metrics`)
|
||
.then((data) => {
|
||
if (!ignore) setMetrics(data);
|
||
})
|
||
.catch((e) => {
|
||
if (!ignore)
|
||
setError(e instanceof Error ? e.message : "Failed to load metrics");
|
||
})
|
||
.finally(() => {
|
||
if (!ignore) setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
ignore = true;
|
||
};
|
||
}, [workspaceId]);
|
||
|
||
return (
|
||
<div
|
||
className="rounded-md border border-line bg-surface-sunken p-3 space-y-2"
|
||
data-testid="workspace-usage"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-xs font-semibold text-ink-mid uppercase tracking-wider">
|
||
Usage
|
||
</h4>
|
||
{!loading && metrics && (
|
||
<span
|
||
className="text-[10px] text-ink-soft font-mono"
|
||
data-testid="usage-period"
|
||
>
|
||
{formatPeriod(metrics.period_start, metrics.period_end)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-1.5" data-testid="usage-stats">
|
||
{loading ? (
|
||
<>
|
||
<SkeletonRow />
|
||
<SkeletonRow />
|
||
<SkeletonRow />
|
||
</>
|
||
) : error ? (
|
||
<p className="text-xs text-bad" data-testid="usage-error">
|
||
{error}
|
||
</p>
|
||
) : metrics ? (
|
||
<>
|
||
<StatRow
|
||
label="Input tokens"
|
||
value={`${(metrics.input_tokens ?? 0).toLocaleString()} tokens`}
|
||
testId="usage-input-tokens"
|
||
/>
|
||
<StatRow
|
||
label="Output tokens"
|
||
value={`${(metrics.output_tokens ?? 0).toLocaleString()} tokens`}
|
||
testId="usage-output-tokens"
|
||
/>
|
||
<StatRow
|
||
label="Estimated cost"
|
||
value={`$${parseFloat(metrics.estimated_cost_usd ?? "0").toFixed(6)}`}
|
||
testId="usage-estimated-cost"
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatPeriod(start: string | undefined, end: string | undefined): string {
|
||
if (!start || !end) return "—";
|
||
const fmt = (s: string) =>
|
||
new Date(s).toLocaleDateString(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
return `${fmt(start)} – ${fmt(end)}`;
|
||
}
|
||
|
||
function SkeletonRow() {
|
||
return (
|
||
<div
|
||
className="flex justify-between items-center animate-pulse"
|
||
data-testid="usage-skeleton-row"
|
||
>
|
||
<div className="h-3 w-20 rounded bg-surface-card" />
|
||
<div className="h-3 w-16 rounded bg-surface-card" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatRow({
|
||
label,
|
||
value,
|
||
testId,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
testId?: string;
|
||
}) {
|
||
return (
|
||
<div className="flex justify-between items-center" data-testid={testId}>
|
||
<span className="text-xs text-ink-soft">{label}</span>
|
||
<span className="text-xs text-ink-mid font-mono">{value}</span>
|
||
</div>
|
||
);
|
||
}
|