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 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 12:57:01 +07:00
parent 7c17191b8b
commit 42464d88b3
6 changed files with 31 additions and 7 deletions

View File

@@ -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 }

View File

@@ -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 (
<span className="credit-pill" title={t('layout.creditsTitle')}>
<Icon name="coin" />{t('common.credits', { n: credits.data.toLocaleString(locale()) })}
</span>
)
}
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 (
<div className="app">
@@ -101,7 +113,7 @@ export function AppLayout() {
<div className="breadcrumb">{t('layout.breadcrumb')} <span>/</span> <b>{current ? t(current.label) : ''}</b></div>
<div className="topbar-right">
<span className="demo-indicator"><i></i> {t('layout.engine')}</span>
<span>{today.format(new Date())}</span>
<CreditBalance />
<LanguageSwitch />
</div>
</header>

View File

@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
video: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="m10 9 5 3-5 3z"/>',
audio: '<path d="M4 13v-2a8 8 0 0 1 16 0v2"/><rect x="3" y="11" width="4" height="8" rx="2"/><rect x="17" y="11" width="4" height="8" rx="2"/>',
scorm: '<path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/>',
coin: '<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.5-1-1.6-1.5-3-1.5-1.7 0-3 .8-3 2s1.3 1.7 3 2 3 .8 3 2-1.3 2-3 2c-1.4 0-2.5-.5-3-1.5M12 6v2M12 16v2"/>',
brain: '<path d="M12 5c-3-4-8-1-7 3-4 2-3 7 0 8-1 4 5 6 7 2M12 5c3-4 8-1 7 3 4 2 3 7 0 8 1 4-5 6-7 2M12 5v15M7 9l3 2M17 9l-3 2M6 15l3-1M18 15l-3-1"/>',
}

View File

@@ -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<Key, string> = {
'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',

View File

@@ -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) },

View File

@@ -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}