// Cabinet tab — accounts (live + demo), switch account, transfer, deposit, withdraw, reset. // Matches the app's bottom-sheet language (slideUp, grab handle, success phase). const INITIAL_ACCOUNTS = [ { id: 'a1', login: '50128817', type: 'live', plan: 'Standard', currency: 'USD', leverage: '1:400', server: 'CFD-Live 04', balance: 10000.00, trading: true }, { id: 'a2', login: '50213394', type: 'live', plan: 'Raw Spread', currency: 'USD', leverage: '1:200', server: 'CFD-Live 07', balance: 2480.50 }, { id: 'a3', login: '88102245', type: 'demo', plan: 'Standard', currency: 'USD', leverage: '1:500', server: 'CFD-Demo 02', balance: 98740.00, resetTo: 100000 }, { id: 'a4', login: '88102310', type: 'demo', plan: 'Swing', currency: 'USD', leverage: '1:30', server: 'CFD-Demo 02', balance: 41260.00, resetTo: 50000 }, ]; const DEMO_COLOR = '#E0A21A'; function acctColor(acct, theme) { return acct.type === 'live' ? theme.buy : DEMO_COLOR; } function curSym(c) { return c === 'EUR' ? '€' : c === 'GBP' ? '£' : '$'; } function fmtBal(n, c = 'USD') { return curSym(c) + Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // labelKey/subKey, not text: this array is built when the script evaluates, // which is before initI18n() resolves, so a t() call here would bake in a miss. // The strings are resolved in MethodGrid's render instead. const PAY_METHODS = [ { id: 'card', labelKey: 'cabinet.method.card', subKey: 'cabinet.method.sub.instant', icon: (c) => }, { id: 'bank', labelKey: 'cabinet.method.bank', subKey: 'cabinet.method.sub.days1to3', icon: (c) => }, { id: 'crypto', labelKey: 'cabinet.method.crypto', subKey: 'cabinet.method.sub.min10', icon: (c) => }, { id: 'ewallet', labelKey: 'cabinet.method.ewallet', subKey: 'cabinet.method.sub.instant', icon: (c) => }, ]; // ————————————————————————— shared bits ————————————————————————— function AcctBadge({ acct, theme, size = 38 }) { const col = acctColor(acct, theme); return (
{acct.type === 'live' ? t('accounts.badge.live') : t('accounts.badge.demo')}
); } function TypePill({ acct, theme }) { const col = acctColor(acct, theme); return ( {acct.type === 'live' ? t('common.accountType.live') : t('common.accountType.demo')} ); } function AcctMini({ acct, theme }) { return (
#{acct.login}
{acct.plan} · {fmtBal(acct.balance, acct.currency)}
); } function SheetShell({ theme, onClose, children, maxH = '94%' }) { return (
e.stopPropagation()} style={{ width: '100%', background: theme.bgElevated, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: '12px 16px 28px', borderTop: `0.5px solid ${theme.border}`, maxHeight: maxH, overflowY: 'auto', animation: 'slideUp 280ms cubic-bezier(0.2,0.9,0.3,1.05)', }}>
{children}
); } function SheetHeader({ theme, title, onClose }) { return (
{title}
); } function SheetSuccess({ theme, title, sub }) { return (
{title}
{sub &&
{sub}
}
); } function AmountField({ value, setValue, currency, theme, color }) { const [editing, setEditing] = React.useState(false); const [text, setText] = React.useState(''); const sym = curSym(currency); const col = color || theme.text; if (editing) { return (
{sym} setText(e.target.value.replace(/[^0-9.]/g, ''))} onBlur={() => { let n = parseFloat(text); if (isNaN(n)) n = 0; setValue(Math.round(n * 100) / 100); setEditing(false); }} onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }} style={{ width: 200, textAlign: 'center', background: 'transparent', border: 'none', borderBottom: `2px solid ${col}`, outline: 'none', color: col, fontSize: 42, fontWeight: 700, letterSpacing: -1, fontVariantNumeric: 'tabular-nums', fontFamily: 'inherit', padding: '0 0 2px' }} />
); } return (
{ setText(value ? String(value) : ''); setEditing(true); }} style={{ cursor: 'text', textAlign: 'center', whiteSpace: 'nowrap' }}> {sym}{value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
); } function Chips({ chips, theme }) { return (
{chips.map(ch => (
{ch.label}
))}
); } function ConfirmButton({ theme, color, disabled, onClick, children }) { return (
!disabled && onClick()} style={{ padding: '16px', borderRadius: 14, textAlign: 'center', fontWeight: 700, fontSize: 16, letterSpacing: -0.2, background: disabled ? theme.bgSubtle : color, color: disabled ? theme.textTer : '#fff', cursor: disabled ? 'default' : 'pointer', boxShadow: disabled ? 'none' : `0 8px 20px ${color}55`, transition: 'background 140ms', }}>{children}
); } // Selectable account row with inline expanding picker function AcctSelect({ label, value, options, theme, onSelect }) { const [open, setOpen] = React.useState(false); const single = options.length <= 1; return (
!single && setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 13px', cursor: single ? 'default' : 'pointer' }}> {label}
{!single && ( )}
{open && (
{options.map(a => (
{ onSelect(a); setOpen(false); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 13px 10px 46px', cursor: 'pointer', borderTop: `0.5px solid ${theme.border}` }}>
{a.id === value.id && }
))}
)}
); } function MethodGrid({ theme, value, onChange }) { return (
{PAY_METHODS.map(m => { const sel = value === m.id; const col = sel ? theme.accent : theme.textSec; return (
onChange(m.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 12px', borderRadius: 12, cursor: 'pointer', background: theme.bgCard, border: `1.5px solid ${sel ? theme.accent : theme.border}`, }}> {m.icon(col)}
{t(m.labelKey)}
{t(m.subKey)}
); })}
); } // ————————————————————————— sheets ————————————————————————— function TransferSheet({ accounts, fromAcct, theme, onClose, onConfirm }) { const [from, setFrom] = React.useState(fromAcct); const eligible = (f) => accounts.filter(a => a.type === f.type && a.currency === f.currency && a.id !== f.id); const [to, setTo] = React.useState(() => eligible(fromAcct)[0] || null); const [amount, setAmount] = React.useState(0); const [phase, setPhase] = React.useState('form'); const targets = to ? eligible(from) : []; React.useEffect(() => { // `list`, not `t` — t is the global translator now. const list = eligible(from); if (!list.find(x => x.id === (to && to.id))) setTo(list[0] || null); }, [from]); const avail = from.balance; const valid = to && amount > 0 && amount <= avail; const swap = () => { if (to) { const f = from; setFrom(to); setTo(f); } }; const submit = () => { setPhase('confirming'); setTimeout(() => { onConfirm(from.id, to.id, amount); setPhase('done'); setTimeout(onClose, 1000); }, 550); }; return ( {phase === 'done' ? ( ) : ( <>
{!to ? ( // One key per account type rather than a
between two halves: // the pair is a single sentence to translate, so the break rides in // the string and the div renders it with whiteSpace: pre-line.
{from.type === 'live' ? t('cabinet.transfer.noTargetLive') : t('cabinet.transfer.noTargetDemo')}
) : ( <>
avail ? theme.sell : theme.textSec, marginTop: 8 }}> {amount > avail ? t('cabinet.amount.exceedsBalance') : t('cabinet.amount.available', { amount: fmtBal(avail, from.currency) })}
setAmount(v => Math.round((v + 100) * 100) / 100) }, { label: t('cabinet.chip.plus', { n: 500 }), onClick: () => setAmount(v => Math.round((v + 500) * 100) / 100) }, { label: t('cabinet.chip.plusThousands', { n: 1 }), onClick: () => setAmount(v => Math.round((v + 1000) * 100) / 100) }, { label: t('cabinet.chip.max'), onClick: () => setAmount(avail) }, { label: t('cabinet.chip.clear'), onClick: () => setAmount(0) }, ]} />
{phase === 'confirming' ? t('cabinet.transfer.busy') : valid ? t('cabinet.transfer.cta', { amount: fmtBal(amount, from.currency) }) : t('cabinet.amount.enter')} )} )} ); } function DepositSheet({ accounts, toAcct, theme, onClose, onConfirm }) { const virtual = toAcct.type === 'demo'; const [acct, setAcct] = React.useState(toAcct); const sameType = accounts.filter(a => a.type === toAcct.type); const [amount, setAmount] = React.useState(0); const [method, setMethod] = React.useState('card'); const [phase, setPhase] = React.useState('form'); const accent = virtual ? DEMO_COLOR : theme.buy; const valid = amount > 0 && (virtual || !!method); const submit = () => { setPhase('confirming'); setTimeout(() => { onConfirm(acct.id, amount); setPhase('done'); setTimeout(onClose, 1000); }, 550); }; return ( {phase === 'done' ? ( ) : ( <>
setAmount(v => Math.round((v + (virtual ? 10000 : 100)) * 100) / 100) }, { label: virtual ? t('cabinet.chip.plusThousands', { n: 25 }) : t('cabinet.chip.plus', { n: 500 }), onClick: () => setAmount(v => Math.round((v + (virtual ? 25000 : 500)) * 100) / 100) }, { label: virtual ? t('cabinet.chip.plusThousands', { n: 50 }) : t('cabinet.chip.plusThousands', { n: 1 }), onClick: () => setAmount(v => Math.round((v + (virtual ? 50000 : 1000)) * 100) / 100) }, { label: t('cabinet.chip.clear'), onClick: () => setAmount(0) }, ]} /> {!virtual && (
{t('cabinet.deposit.method')}
)}
{phase === 'confirming' ? t('cabinet.deposit.busy') : valid ? (virtual ? t('cabinet.deposit.ctaVirtual', { amount: fmtBal(amount, acct.currency) }) : t('cabinet.deposit.cta', { amount: fmtBal(amount, acct.currency) })) : t('cabinet.amount.enter')} )} ); } function WithdrawSheet({ accounts, fromAcct, theme, onClose, onConfirm }) { const [acct, setAcct] = React.useState(fromAcct); const live = accounts.filter(a => a.type === 'live'); const [amount, setAmount] = React.useState(0); const [method, setMethod] = React.useState('card'); const [phase, setPhase] = React.useState('form'); const avail = acct.balance; const valid = amount > 0 && amount <= avail && !!method; const submit = () => { setPhase('confirming'); setTimeout(() => { onConfirm(acct.id, amount); setPhase('done'); setTimeout(onClose, 1000); }, 550); }; return ( {phase === 'done' ? ( ) : ( <>
avail ? theme.sell : theme.textSec, marginTop: 8 }}> {amount > avail ? t('cabinet.amount.exceedsBalance') : t('cabinet.amount.available', { amount: fmtBal(avail, acct.currency) })}
setAmount(v => Math.min(avail, Math.round((v + 100) * 100) / 100)) }, { label: '+500', onClick: () => setAmount(v => Math.min(avail, Math.round((v + 500) * 100) / 100)) }, { label: t('cabinet.chip.max'), onClick: () => setAmount(avail) }, { label: t('cabinet.chip.clear'), onClick: () => setAmount(0) }, ]} />
{t('cabinet.withdraw.method')}
{phase === 'confirming' ? t('cabinet.withdraw.busy') : valid ? t('cabinet.withdraw.cta', { amount: fmtBal(amount, acct.currency) }) : t('cabinet.amount.enter')} )} ); } function ResetSheet({ acct, theme, onClose, onConfirm }) { const [phase, setPhase] = React.useState('form'); const target = acct.resetTo || 50000; const submit = () => { setPhase('confirming'); setTimeout(() => { onConfirm(acct.id, target); setPhase('done'); setTimeout(onClose, 1000); }, 500); }; return ( {phase === 'done' ? ( ) : ( <>
#{acct.login}
{t('cabinet.reset.current', { amount: fmtBal(acct.balance, acct.currency) })}
{t('cabinet.reset.newBalance')}
{fmtBal(target, acct.currency)}
{phase === 'confirming' ? t('cabinet.reset.busy') : t('cabinet.reset.cta', { amount: fmtBal(target, acct.currency) })} )}
); } // Tap-an-account action sheet → switch + contextual money actions function AccountActionSheet({ acct, active, equity, theme, onClose, onSwitch, openSheet }) { const isLive = acct.type === 'live'; const isActive = acct.id === active.id; const row = (label, val, col) => (
{label} {val}
); const actBtn = (label, kind, col) => (
openSheet(kind, acct)} style={{ flex: 1, padding: '13px 4px', borderRadius: 13, background: theme.bgSubtle, color: col || theme.text, textAlign: 'center', fontWeight: 700, fontSize: 13.5, cursor: 'pointer' }}>{label}
); return (
#{acct.login} {/* The middle dot stays literal JSX; only the word is translated, and the uppercasing moves to CSS so non-Latin scripts survive. */} {isActive && · {t('accounts.active')}}
{acct.plan} · {acct.server}
{row(t('common.balance'), fmtBal(acct.balance, acct.currency))} {row(t('common.equity'), fmtBal(equity, acct.currency))} {row(t('accounts.leverage'), acct.leverage)} {row(t('accounts.currency'), acct.currency)} {row(t('accounts.server'), acct.server)}
{!isActive && (
{ onSwitch(acct.id); onClose(); }}> {t('accounts.switchTo')}
)}
{isLive ? ( <> {actBtn(t('cabinet.action.deposit'), 'deposit', theme.buy)} {actBtn(t('cabinet.action.withdraw'), 'withdraw')} {actBtn(t('cabinet.action.transfer'), 'transfer')} ) : ( <> {actBtn(t('cabinet.action.addFunds'), 'deposit', DEMO_COLOR)} {actBtn(t('cabinet.action.reset'), 'reset')} {actBtn(t('cabinet.action.transfer'), 'transfer')} )}
); } // ————————————————————————— the tab ————————————————————————— function AccountRow({ acct, active, prices, theme, equity, onTap, last }) { const isActive = acct.id === active.id; const col = acctColor(acct, theme); return (
onTap(acct)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px', cursor: 'pointer', borderBottom: last ? 'none' : `0.5px solid ${theme.border}`, background: isActive ? `${theme.accent}0E` : 'transparent', }}>
#{acct.login} {isActive && }
{acct.plan} · {acct.leverage}
{fmtBal(equity, acct.currency)}
{acct.currency}
); } function CabinetTab({ accounts, activeId, totalPL, prices, theme, onTapAccount, onSwitch, openSheet }) { const active = accounts.find(a => a.id === activeId) || accounts[0]; const equityOf = (a) => a.balance + (a.trading ? totalPL : 0); const activeEquity = equityOf(active); const live = accounts.filter(a => a.type === 'live'); const demo = accounts.filter(a => a.type === 'demo'); const isLive = active.type === 'live'; const accent = isLive ? theme.buy : DEMO_COLOR; const heroBtn = (label, kind, primary, col) => (
openSheet(kind, active)} style={{ flex: 1, padding: '12px 4px', borderRadius: 13, textAlign: 'center', cursor: 'pointer', fontWeight: 700, fontSize: 13.5, letterSpacing: -0.1, background: primary ? col : theme.bgSubtle, color: primary ? '#fff' : theme.text, boxShadow: primary ? `0 6px 16px ${col}44` : 'none', }}>{label}
); // module-scope-safe helper: plain function returning JSX (not a component // type), so re-renders update DOM in place instead of remounting it. const renderGroup = (title, list) => (
{title} {list.length === 1 ? t('cabinet.group.countOne', { n: list.length }) : t('cabinet.group.countMany', { n: list.length })}
{list.map((a, i) => ( ))}
); return (
{t('cabinet.title')}
{/* Active account hero */}
{t('cabinet.activeAccount')} #{active.login}
{fmtBal(activeEquity, active.currency)}
{active.plan} · {active.currency} · {active.leverage} {active.trading && ( = 0 ? theme.buy : theme.sell, fontWeight: 600, marginInlineStart: 8, fontVariantNumeric: 'tabular-nums' }}> {t('cabinet.pl', { amount: fmtMoney(totalPL) })} )}
{isLive ? ( <> {heroBtn(t('cabinet.action.deposit'), 'deposit', true, theme.buy)} {heroBtn(t('cabinet.action.withdraw'), 'withdraw', false)} {heroBtn(t('cabinet.action.transfer'), 'transfer', false)} ) : ( <> {heroBtn(t('cabinet.action.addFunds'), 'deposit', true, DEMO_COLOR)} {heroBtn(t('cabinet.action.reset'), 'reset', false)} {heroBtn(t('cabinet.action.transfer'), 'transfer', false)} )}
{live.length > 0 && renderGroup(t('cabinet.group.live'), live)} {demo.length > 0 && renderGroup(t('cabinet.group.demo'), demo)}
); } // ————————————————————————— header account switcher (dropdown) ————————————————————————— const SWITCH_ICONS = { deposit: (c) => , withdraw: (c) => , reset: (c) => , transfer: (c) => , open: (c) => , // API access: a key — the token IS the key to the account. api: (c) => , }; function ActionTile({ kind, label, color, theme, onClick }) { return (
{SWITCH_ICONS[kind](color)}
{label}
); } function SwitcherRow({ acct, active, equity, theme, onTap, last }) { const isActive = acct.id === active.id; return (
onTap(acct)} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 12px', cursor: 'pointer', borderRadius: 12, background: isActive ? `${theme.accent}14` : 'transparent', }}>
#{acct.login}
{acct.plan} · {acct.leverage}
{fmtBal(equity, acct.currency)}
{acct.currency}
{isActive && }
); } function AccountSwitcher({ accounts, activeId, totalPL, theme, onSwitch, onClose, onAction, onLogout }) { const active = accounts.find(a => a.id === activeId) || accounts[0]; const equityOf = (a) => a.balance + (a.trading ? totalPL : 0); const live = accounts.filter(a => a.type === 'live'); const demo = accounts.filter(a => a.type === 'demo'); const isLive = active.type === 'live'; const tap = (a) => { onSwitch(a.id); onClose(); }; const act = (kind) => { onClose(); onAction(kind, active); }; // Parameter is `text`, not `t` — `t` is the global translator. const groupLabel = (text) => (
{text}
); const renderGroup = (list, i) => (
{groupLabel(list[0].type === 'live' ? t('accounts.typeName.live') : t('accounts.typeName.demo'))} {list.map((a, k) => )}
); return (
e.stopPropagation()} style={{ position: 'absolute', top: 100, insetInline: 10, background: theme.bgElevated, borderRadius: 22, border: `0.5px solid ${theme.border}`, boxShadow: '0 24px 60px rgba(0,0,0,0.55)', padding: 12, maxHeight: 720, overflowY: 'auto', animation: 'dropDown 240ms cubic-bezier(0.2,0.9,0.3,1.04)', }}>
{t('switcher.title')} {t('switcher.countTotal', { n: accounts.length })}
{[live, demo].filter(g => g.length).map(renderGroup)}
{t('switcher.manageFunds')}
{isLive ? ( <> act('deposit')} /> act('withdraw')} /> ) : ( <> act('deposit')} /> act('reset')} /> )} act('transfer')} /> act('openaccount')} />
{/* Automation lives next to funds, not buried: revoking a bot's access is the same class of action as moving money. */}
{t('switcher.automation')}
act('api')} />
{/* Language (F-i18n-2, ID4). The cabinet sheet is where this shell keeps everything belonging to the PERSON rather than to a single account — funds, automation, sign-out — and a locale is a user attribute, not an account one, so it belongs here. Each locale is named in its OWN language (the endonym, served by the catalogue alongside the bundle). Someone who has landed in a script they cannot read still has to find their way out, and "Español" is findable in a list where "Spanish" is not. */}
{t('common.language')}
{i18nCatalogue().map(lang => { const on = lang.tag === i18nLocale(); return (
{ if (!on) setLocale(lang.tag).then(() => apiSaveLocale(lang.tag)); }} style={{ padding: '9px 13px', borderRadius: 11, cursor: on ? 'default' : 'pointer', background: on ? theme.accent : theme.bgSubtle, color: on ? '#fff' : theme.text, fontSize: 13, fontWeight: on ? 700 : 600, border: `0.5px solid ${on ? 'transparent' : theme.border}`, }}> {/* The label is in the target script, so it declares its own direction rather than inheriting the sheet's — otherwise "العربية" in an LTR sheet (or "English" in an RTL one) is reordered against its chip. */} {lang.label}
); })}
{ onClose(); onLogout(); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '13px', borderRadius: 13, background: theme.bgSubtle, cursor: 'pointer', color: theme.sell, fontWeight: 700, fontSize: 13.5, }}> {t('switcher.logout')}
); } // New account creation // Plan names are Backoffice-sourced display copy: docs/i18n.md §5 puts them // (C9, with payment-method copy C8) explicitly OUT of v1 i18n scope, flagged // into the Backoffice webservice contract instead (IO4). They stay hardcoded // here on purpose — do not move them behind t(). const OA_PLANS = ['Standard', 'Raw Spread', 'Pro', 'Swing']; const OA_LEVS = ['1:30', '1:100', '1:200', '1:400', '1:500']; function OpenAccountSheet({ theme, onClose, onConfirm }) { const [type, setType] = React.useState('demo'); const [plan, setPlan] = React.useState('Standard'); const [leverage, setLeverage] = React.useState('1:400'); const [phase, setPhase] = React.useState('form'); const accent = type === 'live' ? theme.buy : DEMO_COLOR; const submit = () => { setPhase('confirming'); const login = (type === 'live' ? '50' : '88') + Math.floor(100000 + Math.random() * 899999); const acct = { id: 'a' + Date.now(), login, type, plan, currency: 'USD', leverage, server: type === 'live' ? 'CFD-Live 04' : 'CFD-Demo 02', balance: type === 'demo' ? 10000 : 0, resetTo: type === 'demo' ? 10000 : undefined, }; setTimeout(() => { onConfirm(acct); setPhase('done'); setTimeout(onClose, 1100); }, 550); }; // Plain JSX helpers (not component types) — inline component definitions // remount their DOM on every render, eating clicks and input focus. // An option is either a bare string (value doubles as label, e.g. plan and // leverage names, which are data) or a { v, label } pair so a localized label // can ride along without touching the value the comparison and onChange use. const renderSeg = (val, options, onChange) => (
{options.map(o => { const v = typeof o === 'string' ? o : o.v; const lbl = typeof o === 'string' ? o : o.label; return (
onChange(v)} style={{ flex: 1, textAlign: 'center', padding: '8px 4px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, cursor: 'pointer', letterSpacing: -0.1, background: val === v ? theme.bgCard : 'transparent', color: val === v ? theme.text : theme.textSec, border: val === v ? `0.5px solid ${theme.borderStrong}` : '0.5px solid transparent', }}>{lbl}
); })}
); const renderField = (label, children) => (
{label}
{children}
); return ( {phase === 'done' ? ( ) : ( <> {renderField(t('cabinet.open.field.type'), <> {renderSeg(type, [{ v: 'demo', label: t('accounts.type.demo') }, { v: 'live', label: t('accounts.type.live') }], v => setType(v))}
{type === 'demo' ? t('cabinet.open.hintDemo') : t('cabinet.open.hintLive')}
)} {renderField(t('cabinet.open.field.plan'), renderSeg(plan, OA_PLANS, setPlan))} {renderField(t('accounts.leverage'), renderSeg(leverage, OA_LEVS, setLeverage))}
{phase === 'confirming' ? t('cabinet.open.busy') : (type === 'live' ? t('cabinet.open.ctaLive') : t('cabinet.open.ctaDemo'))} )} ); } // ————————————————————————— API access (Track P P1) ————————————————————————— // // The human's view of every piece of automation that can touch their accounts: // list, mint, revoke. The scopes and the plaintext-once rule ARE the security // model, so the copy states them plainly instead of hiding them in a tooltip. // // P1 is list / mint / revoke. P3 adds live state (connected, caps usage), the // per-session message feed, and the higher kill levels (cancel orders, flatten). // Keys, not text: both maps are module-scope, so they are built before // initI18n() resolves. They are resolved through t() at render time. const SCOPE_LABEL_KEY = { read: 'apiaccess.scope.read', 'trade:demo': 'apiaccess.scope.tradeDemo', 'trade:live': 'apiaccess.scope.tradeLive', }; const SCOPE_BLURB_KEY = { read: 'apiaccess.scopeBlurb.read', 'trade:demo': 'apiaccess.scopeBlurb.tradeDemo', 'trade:live': 'apiaccess.scopeBlurb.tradeLive', }; function scopeColor(scope, theme) { if (scope === 'trade:live') return theme.sell; if (scope === 'trade:demo') return DEMO_COLOR; return theme.textSec; } function ScopeBadge({ scope, theme }) { const col = scopeColor(scope, theme); return ( {SCOPE_LABEL_KEY[scope] ? t(SCOPE_LABEL_KEY[scope]) : scope} ); } // fmtWhen renders a unix-millis stamp as a short relative age — nobody reading // this list is asking for the exact timestamp. function fmtWhen(ms) { if (!ms) return t('apiaccess.age.never'); const s = Math.max(0, (Date.now() - ms) / 1000); if (s < 60) return t('apiaccess.age.justNow'); if (s < 3600) return t('apiaccess.age.minutes', { n: Math.floor(s / 60) }); if (s < 86400) return t('apiaccess.age.hours', { n: Math.floor(s / 3600) }); if (s < 86400 * 30) return t('apiaccess.age.days', { n: Math.floor(s / 86400) }); return new Date(ms).toLocaleDateString(); } // The plaintext token, shown once. Loud on purpose: this is the only chance the // user gets, and the cost of dismissing it is re-minting. function TokenReveal({ token, theme, onDone }) { const [copied, setCopied] = React.useState(false); const copy = async () => { const ok = await copyText(token); setCopied(ok ? 'yes' : 'no'); setTimeout(() => setCopied(false), 2200); }; return (
{token}
{copied === 'yes' ? t('common.action.copied') : copied === 'no' ? t('apiaccess.reveal.copyFailed') : t('apiaccess.reveal.copy')}
{/* One sentence, one key. `Authorization: Bearer …` is a wire literal, not copy, so it stays untranslated and keeps its mono face: the string carries a {header} placeholder and the split puts the node back in. */}
{t('apiaccess.reveal.usage').split('{header}').map((part, i) => ( {i > 0 && Authorization: Bearer …} {part} ))}
{/* Secondary on purpose: copying is the action, dismissing is the exit. */}
{t('apiaccess.reveal.saved')}
); } function MintTokenForm({ accounts, theme, liveTradingEnabled, onCancel, onMinted }) { const [name, setName] = React.useState(''); const [acct, setAcct] = React.useState(accounts.find(a => a.type === 'demo' && a.trading) || accounts[0]); const [scope, setScope] = React.useState('read'); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); // A trade:demo token must be bound to a demo account (PD15). The server // enforces it; the form simply never offers the impossible combination. const demoBound = !!acct && acct.type === 'demo'; const scopeOff = (s) => (s === 'trade:live' && !liveTradingEnabled) || (s === 'trade:demo' && !demoBound); React.useEffect(() => { if (scopeOff(scope)) setScope('read'); }, [acct && acct.id]); const submit = async () => { if (busy || !name.trim() || !acct) return; setBusy(true); setErr(null); try { const res = await apiMintAgentSession({ name: name.trim(), platform: acct.platform, account: acct.id, scope, }); onMinted(res); } catch (e) { setErr(String((e && e.message) || e)); setBusy(false); } }; // Parameter is `text`, not `t` — `t` is the global translator. const label = (text) => (
{text}
); return (
{label(t('apiaccess.field.name'))} setName(e.target.value.slice(0, 64))} placeholder={t('apiaccess.name.placeholder')} style={{ width: '100%', boxSizing: 'border-box', padding: '13px', borderRadius: 13, background: theme.bgCard, border: `0.5px solid ${theme.border}`, color: theme.text, fontSize: 15, fontWeight: 600, outline: 'none', }} /> {label(t('apiaccess.field.account'))} {acct ? :
{t('apiaccess.noAccount')}
}
{t('apiaccess.accountHint')}
{label(t('apiaccess.field.scope'))}
{['read', 'trade:demo', 'trade:live'].map(s => { const off = scopeOff(s); const sel = scope === s; return (
!off && setScope(s)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '11px 12px', borderRadius: 12, background: theme.bgCard, border: `1.5px solid ${sel ? theme.accent : theme.border}`, cursor: off ? 'default' : 'pointer', opacity: off ? 0.42 : 1, }}>
{off && {s === 'trade:live' ? t('apiaccess.scopeOff.notEnabled') : t('apiaccess.scopeOff.needsDemo')} }
{t(SCOPE_BLURB_KEY[s])}
); })}
{err && (
{err}
)}
{t('common.action.cancel')}
{busy ? t('apiaccess.mint.busy') : t('apiaccess.mint.cta')}
); } // ————————————————————————— agent sessions (Track P P3) ————————————————————————— // // The phone's supervision screen (docs/public-api.md PD10). One sheet does the // lot, because a phone has no room for two: the session list with live caps // meters, the message feed, the escalating kill, and P1's mint/reveal behind // the same button they were always behind. // // The caps meters are the part that earns the screen. A human who cannot see a // ceiling has to take "it is rate limited" on trust; a meter that fills is the // difference between supervision and faith. // AgentMeter — one cap as a spent bar plus its figures. // // All three read the same way: the bar is how much of the allowance is GONE. // The request bucket arrives as headroom REMAINING (it refills continuously, so // a "used" figure would fall on its own) and is converted by the caller — two // bars that fill as you spend and one that empties is exactly the inconsistency // that gets misread under pressure. The tooltip carries the nuance. function AgentMeter({ label, used, limit, title, theme }) { const pct = limit > 0 ? Math.max(0, Math.min(100, (used / limit) * 100)) : 0; const hot = limit > 0 && used >= limit; const col = hot ? theme.sell : pct >= 80 ? DEMO_COLOR : theme.buy; return (
{label}
{/* The fill grows from the leading edge, so it mirrors with the row under RTL — a physical `left` would leave the bar running away from its own label in Arabic. */}
{/* ONE isolated run for the pair (docs/i18n.md §6.3). "14 / 60" is two numbers joined by a neutral: isolate the halves separately and the digits are fine while the ORDER reverses, which is a plausible wrong value rather than visible garbage. */} {used} / {limit}
); } // AgentSessionRow — one token as the panel shows it. // // A revoked token renders as history: dimmed, struck through, no actions. The // kill endpoint is owner-scoped and L3/L4 do still work on a revoked token, but // a live-looking button that answers "already revoked" teaches the human that // the panel lies, which is the last thing a panic button should do. function AgentSessionRow({ tok, theme, onFeed, onKill, first }) { const dead = !!tok.revoked; const usage = tok.usage || {}; const reqLimit = usage.reqLimit || 0; const reqUsed = Math.max(0, reqLimit - (usage.reqRemaining || 0)); const connected = tok.connected > 0; return (
{!dead && ( )} {tok.name} {dead && {t('apiaccess.revoked')}}
#{tok.account} {tok.demo ? t('accounts.type.demo') : t('accounts.type.live')} {dead ? t('agent.state.revokedAt', { when: fmtWhen(tok.revoked) }) : connected ? tn('agent.connections', tok.connected) : tok.lastRequest ? t('agent.state.lastRequest', { when: fmtWhen(tok.lastRequest) }) : tok.lastUsed ? t('agent.state.lastUsed', { when: fmtWhen(tok.lastUsed) }) : t('agent.state.neverUsed')}
{!dead && (
)}
onFeed(tok)} style={{ padding: '8px 14px', borderRadius: 10, background: theme.bgSubtle, color: theme.text, fontSize: 12.5, fontWeight: 700, cursor: 'pointer', }}>{t('agent.action.messages')}
{!dead && (
onKill(tok)} style={{ padding: '8px 14px', borderRadius: 10, border: `0.5px solid ${theme.sell}66`, color: theme.sell, fontSize: 12.5, fontWeight: 700, cursor: 'pointer', }}>{t('agent.kill.cta')}
)}
); } // ————————————————————————— feed ————————————————————————— // Level → tint. PD9's vocabulary comes off the wire, so the LABEL renders from // the code through tCode('agentLevel', …) (FR4) and this map is only colour. function agentLevelTint(level, theme) { if (level === 'success') return theme.buy; if (level === 'warning') return DEMO_COLOR; if (level === 'alert') return theme.sell; return theme.textSec; } // AgentFeedRow — one thing an agent said. `body` is agent-authored text and is // rendered as TEXT: nothing on this path touches dangerouslySetInnerHTML, and // `pre-wrap` is what lets a bot format with newlines without being handed // markup. function AgentFeedRow({ msg, theme, onFilter, last }) { const col = agentLevelTint(msg.level, theme); const ref = msg.ref || null; const stamp = new Date(msg.createdAt); const chip = (text) => ( {text} ); return (
{tCode('agentLevel', msg.level)} onFilter && onFilter(msg.tokenId)} style={{ fontSize: 11.5, color: theme.textSec, cursor: 'pointer', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }}>{msg.tokenName} {fmtMonthDayTime(stamp, fmtClock(stamp))}
{msg.title}
{msg.body && (
{msg.body}
)} {ref && (ref.symbol || ref.ticket || ref.positionId) && (
{/* An instrument code is canonical and never translated (docs/i18n.md §5), so it rides bare. Each id is composed with its # INSIDE one isolate, so the pair cannot come apart in an RTL row. */} {ref.symbol ? chip(posPair(ref.symbol)) : null} {ref.ticket ? chip(t('agent.feed.ref.ticket', { id: bidiIsolate('#' + ref.ticket) })) : null} {ref.positionId ? chip(t('agent.feed.ref.position', { id: bidiIsolate('#' + ref.positionId) })) : null}
)}
); } function AgentFeedView({ tokenId, sessions, theme, onFilter }) { const store = useAgentStore(); const feed = agentFeed(tokenId); const named = tokenId ? (sessions.find(x => x.id === tokenId) || null) : null; React.useEffect(() => { if (!feed.loaded && !feed.loading) apiLoadAgentMessages(tokenId, false); // The store version is in the deps so a feed created by a filter change is // fetched on the render that first shows it. }, [tokenId, store.version]); React.useEffect(() => { agentMarkRead(); }, [store.version]); return (
{named ? t('agent.feed.filter.only', { name: named.name }) : t('agent.feed.oneWay')} {tokenId ? ( onFilter(0)} style={{ flexShrink: 0, padding: '7px 12px', borderRadius: 10, background: theme.bgSubtle, color: theme.text, fontSize: 12, fontWeight: 700, cursor: 'pointer', }}>{t('agent.feed.filter.clear')} ) : null}
{feed.err && (
{t('agent.feed.error')}
)} {!feed.loaded && feed.loading && (
{t('agent.feed.loading')}
)} {feed.loaded && !feed.rows.length && (
{t('agent.feed.empty.title')}
{t('agent.feed.empty.body')}
)} {feed.rows.length > 0 && (
{feed.rows.map((msg, i) => ( ))}
)} {feed.loaded && feed.rows.length > 0 && (
{feed.nextBefore ? ( apiLoadAgentMessages(tokenId, true)} style={{ display: 'inline-block', padding: '10px 18px', borderRadius: 12, background: theme.bgSubtle, color: theme.text, fontSize: 13, fontWeight: 700, cursor: 'pointer', }}>{feed.loading ? t('agent.feed.loading') : t('agent.feed.loadOlder')} ) : ( {t('agent.feed.end')} )}
)}
); } // ————————————————————————— kill ————————————————————————— // The four levels, in escalation order. Keys, not text: the table is built at // script-eval time, before initI18n() resolves. const AGENT_KILL_STEPS = [ { level: 1, nameKey: 'agent.kill.level1.name', bodyKey: 'agent.kill.level1.body' }, { level: 2, nameKey: 'agent.kill.level2.name', bodyKey: 'agent.kill.level2.body' }, { level: 3, nameKey: 'agent.kill.level3.name', bodyKey: 'agent.kill.level3.body' }, { level: 4, nameKey: 'agent.kill.level4.name', bodyKey: 'agent.kill.level4.body' }, ]; // AgentKillOutcome renders the response HONESTLY. // // The rule this exists for: a partial failure must never look like a success. // The failures get their own block, in sell red, each with the backend's own // reason — and a flatten that ran out of PASSES rather than out of work // (`converged: false`) is called out first, because that is the difference // between "you are flat" and "you are probably not". function AgentKillOutcome({ res, theme, onDone }) { const failed = res.failed || []; const cancelled = res.cancelled || []; const closed = res.closed || []; const notConverged = res.converged === false; const bad = failed.length > 0 || notConverged; const itemLabel = (fail) => { if (fail.kind === 'order') return t('agent.kill.result.item.order', { id: bidiIsolate('#' + fail.id) }); if (fail.kind === 'position') return t('agent.kill.result.item.position', { id: bidiIsolate('#' + fail.id) }); return t('agent.kill.result.item.sweep'); }; const line = (text, dim) => (
· {text}
); return (
{bad ? t('agent.kill.result.partialTitle') : t('agent.kill.result.title')}
{notConverged && (
{tn('agent.kill.result.notConverged', res.passes || 0)}
{t('agent.kill.result.notConvergedNote')}
)}
{res.streamsClosed > 0 ? line(tn('agent.kill.result.streams', res.streamsClosed)) : line(t('agent.kill.result.noStreams'), true)} {res.level >= 2 && (res.revoked ? line(t('agent.kill.result.revoked')) : line(t('agent.kill.result.alreadyRevoked'), true))} {res.level === 1 && line(t('agent.kill.result.tokenValid'), true)} {res.level >= 3 && (cancelled.length ? line(tn('agent.kill.result.cancelled', cancelled.length)) : line(t('agent.kill.result.noneCancelled'), true))} {res.level >= 4 && (closed.length ? line(tn('agent.kill.result.closed', closed.length)) : line(t('agent.kill.result.noneClosed'), true))} {res.level >= 4 && res.converged === true && line(t('agent.kill.result.converged'), true)}
{failed.length > 0 && (
{tn('agent.kill.result.failed', failed.length)}
{failed.map((fail, i) => (
{itemLabel(fail)}
{/* The backend's own words. This body goes to the account holder, not to a third party, so there is nothing to redact — and a reason they cannot read is a failure they cannot act on. */}
{fail.reason}
))}
{t('agent.kill.result.failedNote')}
)} {t('agent.kill.result.done')}
); } // AgentKillSheet — the escalating control (PD10). // // The escalation must be LEGIBLE: all four levels are shown together, each // stating what it ADDS to the one above it, and L3/L4 carry a live count of // what they would act on — so the difference between "stop it trading" and // "close my positions" is a number, not a word. // // L4 confirms TWICE, and the two confirmations deliberately say different // things rather than asking the same question twice: the first is what // flattening MEANS (positions close at market, P&L is realised now), the second // is that there is no undo. Two identical dialogs train a reflex; two different // ones make the second one get read. // // ——— why one double-TAP must not walk through both ——— // // It did, and the phone was the worse of the two shells: both phases rendered // the same markup at the same position, so React kept the DOM node and only // swapped its label, and the CTA moved ZERO pixels between them — a // pixel-identical hit target, elementFromPoint at the first CTA's centre // returning the second. A double-tap, the commonest input error there is when // someone is panicking at a phone, closed every position the agent had opened // without ever showing the "there is no undo" dialog. // // Three layers, matching the desktop: `key={phase}` so the node remounts, // AGENT_KILL_ARM_MS so a freshly-arrived confirmation's CTA is inert for half a // second (BOTH of them — a tap that skips from the pick sheet to the last // confirmation is a human who never read the first), and the two buttons // STACKED with their order swapped between the confirmations. // // Stacked rather than side by side, on this shell only, because side by side // did not separate them: cancel is auto-width, so swapping the row moved the // CTA by 94px while the CTA itself is 276px wide, and elementFromPoint at the // first CTA's centre still landed on the second (measured). The sheet is // bottom-anchored, so stacking puts confirm1's CTA on the upper row and // confirm2's on the lower one — a full button height apart, with cancel // occupying whichever row the previous CTA used. const AGENT_KILL_ARM_MS = 500; // killTint maps killPreviewText's semantic tone onto this shell's palette. const killTint = (tone, theme) => (tone === 'danger' ? theme.sell : tone === 'warn' ? DEMO_COLOR : theme.textTer); function AgentKillSheet({ tok, theme, onCancel, onBusy, onDone }) { const [level, setLevel] = React.useState(1); const [phase, setPhase] = React.useState('pick'); // pick | confirm1 | confirm2 | busy | done const [res, setRes] = React.useState(null); const [err, setErr] = React.useState(null); // The server's own dry run of the sweep, once per sheet — the client could // not answer this honestly for a token on an account this phone is not // streaming, and previewed {0,0}. See useKillPreview. const preview = useKillPreview(tok.id); // Layer 2: a confirmation's CTA arms a beat after the sheet appears. const [armed, setArmed] = React.useState(true); React.useEffect(() => { if (phase !== 'confirm1' && phase !== 'confirm2') { setArmed(true); return undefined; } setArmed(false); const id = setTimeout(() => setArmed(true), AGENT_KILL_ARM_MS); return () => clearTimeout(id); }, [phase]); // N1: while the POST is in flight the sheet must not be dismissable — the // kill completes either way and the outcome report, including a partial // failure, would never be shown. The dismissal lives on the shell above, so // the state has to travel up. React.useEffect(() => { if (!onBusy) return undefined; onBusy(phase === 'busy'); return () => onBusy(false); }, [phase, onBusy]); const fire = async (lvl) => { setPhase('busy'); setErr(null); try { const out = await apiKillAgentSession(tok.id, lvl); setRes(out); setPhase('done'); onDone(); } catch (e) { setErr(String((e && e.message) || e)); setPhase('pick'); } }; if (phase === 'done' && res) { return ; } if (phase === 'confirm1' || phase === 'confirm2') { const second = phase === 'confirm2'; const shot = killPreviewText(preview, 'positions'); const cancelBtn = (
setPhase('pick')} style={{ padding: '16px 20px', borderRadius: 14, textAlign: 'center', fontWeight: 700, fontSize: 15, background: theme.bgSubtle, color: theme.text, cursor: 'pointer', }}>{t('common.action.cancel')}
); const confirmBtn = (
(second ? fire(4) : setPhase('confirm2'))}> {second ? t('agent.kill.confirm2.cta') : t('agent.kill.confirm1.cta')}
); return ( // Layer 1. Without the key React reuses the DOM node and one tap counts // twice.
{second ? t('agent.kill.confirm2.title') : t('agent.kill.confirm1.title')}
{second ? t('agent.kill.confirm2.body') : t('agent.kill.confirm1.body', { name: tok.name })}
{/* Always rendered, in every preview state: an absent box used to be how "I could not check" looked, which is exactly how "there is nothing to close" looked too. */}
0 ? `${theme.sell}18` : theme.bgSubtle, border: `0.5px solid ${preview.positions > 0 ? `${theme.sell}55` : theme.border}`, }}>
{shot.text}
{/* The note explains a figure. With no figure — the preview failed, or has not landed — "checked with the server just now" under "could not check" is a contradiction, so it waits. */} {!preview.loading && !preview.err && (
{t('agent.kill.previewNote')}
)}
{/* Layer 3. Stacked, and the order flips between the two: confirm1's CTA is the upper row, confirm2's the lower one, so the second tap of a double-tap cannot land on the next dialog's CTA. */}
{second ? [cancelBtn, confirmBtn] : [confirmBtn, cancelBtn]}
); } const busy = phase === 'busy'; return (
{t('agent.kill.intro')}
{AGENT_KILL_STEPS.map(step => { const on = level === step.level; // Everything at or below the chosen level is CONTAINED by it, and the // list shows that: choosing L4 lights the three steps it includes // rather than only itself. The containment is the whole model. const within = level >= step.level; const danger = step.level === 4; const edge = on ? (danger ? theme.sell : theme.accent) : within ? theme.borderStrong : theme.border; return (
setLevel(step.level)} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '11px 12px', borderRadius: 12, background: on ? (danger ? `${theme.sell}14` : theme.bgCard) : theme.bgCard, border: `1.5px solid ${edge}`, cursor: 'pointer', }}> {step.level}
{t(step.nameKey)}
{t(step.bodyKey)}
{/* The two levels that ACT carry a line in every preview state — a count, "checking", or "could not check". Never nothing: silence is what made an unpreviewable L4 look idle. */} {(step.level === 3 || step.level === 4) && (() => { const line = killPreviewText(preview, step.level === 3 ? 'orders' : 'positions'); return (
{line.text}
); })()}
); })}
{!preview.loading && !preview.err && (
{t('agent.kill.previewNote')}
)} {err && (
{t('agent.kill.failed')}
)}
{t('common.action.cancel')}
(level === 4 ? setPhase('confirm1') : fire(level))} >{busy ? t('agent.kill.busy') : t(AGENT_KILL_STEPS[level - 1].nameKey)}
); } // ————————————————————————— the sheet ————————————————————————— // ApiAccessSheet — one screen for everything automation on this phone. // // A React.memo barrier: the mobile App re-renders on every price tick, and the // panel must not come along for the ride. That holds only while every prop is // stable, which is why App memoises its theme object and useCallbacks its // close handler — a fresh object in either would defeat this entirely. const ApiAccessSheet = React.memo(function ApiAccessSheet({ accounts, theme, initialView, onClose }) { const [state, setState] = React.useState({ loading: true, rows: [], live: false, err: null }); // list | mint | reveal | feed | kill const [mode, setMode] = React.useState(initialView === 'feed' ? 'feed' : 'list'); const [revealed, setRevealed] = React.useState(null); const [filter, setFilter] = React.useState(0); const [killing, setKilling] = React.useState(null); // True only while a kill POST is in flight. See `dismiss`/`back` below. const [killBusy, setKillBusy] = React.useState(false); const load = React.useCallback(async () => { try { const res = await apiAgentSessions(); setState({ loading: false, rows: res.sessions || [], live: !!res.liveTradingEnabled, err: null }); } catch (e) { setState(s => ({ loading: false, rows: s.rows, live: s.live, err: String((e && e.message) || e) })); } }, []); // Poll while the sessions list is up: the caps meters are live state, and a // human watching a looping agent expects to see them move. Only in `list`, // because the other modes do not show a meter and a background refetch under // a confirmation dialog is work nobody asked for. React.useEffect(() => { load(); if (mode !== 'list') return undefined; const id = setInterval(load, 5000); return () => clearInterval(id); }, [load, mode]); const showFeed = React.useCallback((tok) => { setFilter(tok.id); setMode('feed'); }, []); const onFilter = React.useCallback((id) => { setFilter(id); setMode('feed'); }, []); const title = mode === 'mint' ? t('apiaccess.mint.title') : mode === 'reveal' ? t('apiaccess.reveal.sheetTitle') : mode === 'feed' ? t('agent.feed.title') : mode === 'kill' && killing ? t('agent.kill.forName', { name: killing.name }) : t('agent.title'); // Two things a backdrop tap must not throw away: the plaintext token, which // exists exactly once and has no way back, and a kill that is mid-flight — // N1. The POST completes either way, so dismissing it does not stop // anything; it only hides the outcome report, which is the one honest // account of what the sweep managed and what it did not. const dismiss = (mode === 'reveal' || killBusy) ? () => {} : onClose; const back = () => { if (killBusy) return; if (mode === 'reveal') { setRevealed(null); setMode('list'); load(); return; } if (mode === 'list') { onClose(); return; } setKilling(null); setMode('list'); }; return ( {mode === 'reveal' && revealed && ( { setRevealed(null); setMode('list'); load(); }} /> )} {mode === 'mint' && ( setMode('list')} onMinted={(res) => { setRevealed(res.token); setMode('reveal'); }} /> )} {mode === 'feed' && ( )} {mode === 'kill' && killing && ( { setKilling(null); setMode('list'); }} onBusy={setKillBusy} onDone={load} /> )} {mode === 'list' && (
{t('agent.blurb')}
{t('agent.caps.readOnly')}
{ setFilter(0); setMode('feed'); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 13px', marginBottom: 12, borderRadius: 13, background: theme.bgCard, border: `0.5px solid ${theme.border}`, cursor: 'pointer', }}> {t('agent.feed.title')}
{state.loading && !state.rows.length && (
{t('apiaccess.loading')}
)} {state.err && (
{t('agent.error.sessions')}
)} {!state.loading && !state.rows.length && !state.err && (
{t('agent.empty.title')}
{t('agent.empty.body')}
)} {!!state.rows.length && (
{/* `tok`, not `t` — `t` is the global translator. */} {state.rows.map((tok, i) => ( { setKilling(row); setMode('kill'); }} /> ))}
)} setMode('mint')}>{t('apiaccess.mint.cta')}
)}
); }); // AgentUnreadPill subscribes to the agent store only, so an unread count can // move without the sheet around it re-rendering. function AgentUnreadPill({ theme }) { const store = useAgentStore(); if (!store.unread) return null; return ( {store.unread > 99 ? '99+' : store.unread} ); } Object.assign(window, { INITIAL_ACCOUNTS, CabinetTab, AccountSwitcher, OpenAccountSheet, AccountActionSheet, TransferSheet, DepositSheet, WithdrawSheet, ResetSheet, ApiAccessSheet, ScopeBadge, acctColor, fmtBal, // Track P P3 — the agent-sessions screen's parts, exported so index.html can // mount the unread pill on the header bell without re-deriving the store. AgentSessionRow, AgentFeedView, AgentKillSheet, AgentUnreadPill, AgentMeter, });