From 42464d88b377edd896d299d9ff07cc3dba79a582 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Fri, 23 Oct 2026 12:57:01 +0700 Subject: [PATCH] Show the workspace credit balance in the topbar Read from GET /organizations/me, refreshed after every action and every 30 seconds, since agents and schedules spend credits in the background. The date is dropped from the topbar, and its items wrap on phones. Co-Authored-By: Claude Opus 5.5 --- src/api/engine.ts | 6 ++++++ src/components/AppLayout.tsx | 16 ++++++++++++++-- src/components/Icon.tsx | 1 + src/i18n.ts | 2 ++ src/main.tsx | 6 ++++-- src/styles/theme.css | 7 ++++--- 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/api/engine.ts b/src/api/engine.ts index c3c164e..ded8112 100644 --- a/src/api/engine.ts +++ b/src/api/engine.ts @@ -98,6 +98,12 @@ export async function waitReady(id: number, onProgress?: (d: EngineDocument) => } } +// ── Workspace wallet ── + +// The organization's credit balance: Express spends workspace credits, never personal ones. +export const workspaceCredits = async () => + (await request<{ organization: { credit_balance: number } | null }>('/organizations/me')).organization?.credit_balance ?? null + // ── Compose (text content) ── export interface ComposeSet { id: number; name: string } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 7c6215f..24cae55 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { useClerk, useOrganization, useUser } from '@clerk/react' +import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' +import { workspaceCredits } from '../api/engine' import { Brand, Icon, type IconName } from './Icon' import { Toaster } from './Toast' @@ -57,6 +59,17 @@ function HelpDialog({ onClose }: { onClose: () => void }) { ) } +// Polled because agents and schedules also spend credits outside this tab; main.tsx refreshes it after every mutation. +function CreditBalance() { + const credits = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits, refetchInterval: 30_000 }) + if (credits.data == null) return null + return ( + + {t('common.credits', { n: credits.data.toLocaleString(locale()) })} + + ) +} + export function AppLayout() { useLang() const [help, setHelp] = useState(false) @@ -66,7 +79,6 @@ export function AppLayout() { const { pathname } = useLocation() const current = NAV.find(n => pathname.startsWith(n.to)) const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' - const today = new Intl.DateTimeFormat(locale(), { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) return (
@@ -101,7 +113,7 @@ export function AppLayout() {
{t('layout.breadcrumb')} / {current ? t(current.label) : ''}
{t('layout.engine')} - {today.format(new Date())} +
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index fb08cd6..5c3448f 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -22,6 +22,7 @@ const paths: Record = { video: '', audio: '', scorm: '', + coin: '', brain: '', } diff --git a/src/i18n.ts b/src/i18n.ts index 93e9c63..8c73bd6 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -16,6 +16,7 @@ const en = { 'layout.breadcrumb': 'Workspace', 'layout.signOut': 'Sign out', 'layout.engine': 'learning-lab engine', + 'layout.creditsTitle': 'Workspace credit balance', 'layout.devStrip': 'Development phase: connected to the learning-lab engine. Review AI output before using it.', 'layout.footer': 'A workspace for knowledge that keeps growing.', 'layout.mainNav': 'Main navigation', @@ -397,6 +398,7 @@ const id: Record = { 'layout.breadcrumb': 'Ruang kerja', 'layout.signOut': 'Keluar', 'layout.engine': 'Engine learning-lab', + 'layout.creditsTitle': 'Saldo kredit workspace', 'layout.devStrip': 'Fase development: terhubung ke engine learning-lab. Hasil AI perlu ditinjau sebelum dipakai.', 'layout.footer': 'Ruang kerja untuk pengetahuan yang terus berkembang.', 'layout.mainNav': 'Navigasi utama', diff --git a/src/main.tsx b/src/main.tsx index 1f2f6c5..ea070fc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { ClerkProvider } from '@clerk/react' import { enUS, idID } from '@clerk/localizations' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' import { EngineError } from './api/client' import App from './App' @@ -16,7 +16,9 @@ if (!clerkPubKey) { throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY (set it, or keep ../learning-lab/backend/.env available)') } -const queryClient = new QueryClient({ +const queryClient: QueryClient = new QueryClient({ + // Any action may have spent workspace credits, so the topbar balance is read again after each one. + mutationCache: new MutationCache({ onSettled: () => queryClient.invalidateQueries({ queryKey: ['credits'] }) }), defaultOptions: { // Retrying a 4xx (auth, credits, validation) only repeats the same answer. queries: { retry: (n, e) => n < 2 && !(e instanceof EngineError && e.status >= 400 && e.status < 500) }, diff --git a/src/styles/theme.css b/src/styles/theme.css index 3ba7ccb..4f5ae80 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -79,7 +79,9 @@ input::placeholder,textarea::placeholder{color:#98a2b3} .breadcrumb span{color:#d0d5dd} .breadcrumb b{color:var(--ink);font-weight:600} .topbar-right{font-size:13px;color:var(--muted);gap:16px} -.demo-indicator{height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054} +.demo-indicator{white-space:nowrap;height:30px;padding:0 12px;border:1px solid var(--line);border-radius:999px;background:#fff;font-size:12.5px;font-weight:550;color:#344054} +.credit-pill{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;background:var(--light);color:var(--blue-dark);font-size:12.5px;font-weight:700;white-space:nowrap} +.credit-pill svg{width:15px;height:15px} .demo-indicator i{width:7px;height:7px;background:#059669;box-shadow:0 0 0 3px #d1fae5} .lang-switch{border:0;border-radius:999px;background:#f2f4f7;padding:3px} .lang-switch button{border-radius:999px;padding:5px 11px;font-size:12px;color:#475467} @@ -227,8 +229,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)} .sidebar nav :is(button,a).active::before{display:none} .workspace-main{margin:0;width:100%} .topbar{position:static;height:auto;min-height:56px;padding:10px 16px;flex-wrap:wrap} - .topbar-right{gap:10px} - .topbar-right>span:not(.demo-indicator){display:none} + .topbar-right{gap:8px 10px;flex-wrap:wrap} .demo-strip{margin:16px 16px 0;border-radius:16px;font-size:12px} .app main{padding:22px 16px} .app-footer{margin-left:16px;margin-right:16px}