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}