{!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.
#{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')}}
);
// 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) => (
{/* 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. */}
{ 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}
>)}
{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 (
{/* 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. */}
);
}
// ————————————————————————— 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 (
);
}
// ————————————————————————— 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 (
{/* 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 (
);
}
// ————————————————————————— 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) => (
{/* 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 = (
{/* 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. */}
{/* 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. */}
{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 (
{/* 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 (
);
}
// ————————————————————————— 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' && (
{/* `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,
});