// TodayWorkspace — surfaces the daily OpenClaw cascade output that's
// otherwise invisible without curl: the morning brief, each persona's
// same-day memos (PhD Researcher's pre-market scan, Hedging Specialist's
// risk addendum), and the append-only status log.
//
// Replaces email as the primary morning surface (Gmail App Password is
// dead until rotated; daily cascade still runs and lands on disk —
// this tab makes it visible).
//
// Hits GET /api/today (powered by collect_today_summary in
// backend/agent/persona_runner.py). Auto-polls every 60s so a memo that
// the cron just filed shows up without manual refresh.
const { useState: tUseState, useEffect: tUseEffect, useCallback: tUseCallback, useMemo: tUseMemo } = React;
const TODAY_POLL_MS = 60_000;
function todayDateISO() {
// Local date is fine for the picker — backend interprets as UTC date,
// and the cascade is timezone-tagged in cron_tz anyway.
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function fmtRelTime(isoStr) {
if (!isoStr) return '';
try {
const t = new Date(isoStr);
const diffMin = Math.floor((Date.now() - t.getTime()) / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const h = Math.floor(diffMin / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
} catch { return ''; }
}
function outcomeColor(outcome) {
if (outcome === 'ok') return '#5fb37c'; // green
if (outcome === 'fail') return '#d96a6a'; // red
if (outcome === 'partial') return '#d9a85f'; // amber
if (outcome === 'skipped') return 'var(--text-dim)';
if (outcome === 'started') return '#d9a85f';
return 'var(--text-dim)';
}
// Render markdown safely-ish. We trust our own brief output (composed by
// our own LLM call into our own vault), so dangerouslySetInnerHTML on
// marked output is fine here — we control both ends. If you ever pipe
// untrusted content here, swap in DOMPurify.
function MarkdownBlock({ source }) {
const html = tUseMemo(() => {
if (!source) return '';
try { return window.marked.parse(source); }
catch { return `
${(source || '').replace(/[<>&]/g, c => ({ '<':'<','>':'>','&':'&'}[c]))}
`; }
}, [source]);
return ;
}
// Phase 6 R · #3 — Overnight gap strip. The 06:00 ET first-glance:
// "where are S&P futures, what's VIX doing, and did anything in my
// book gap?" Pulled from window.getQuote (Massive Market Data lib —
// pre-market quotes). Auto-refreshes on tab focus; doesn't poll
// aggressively (1 fetch on mount, 1 on focus = at most a few per
// hour while tab is visible).
//
// Macro symbols: SPY (S&P proxy for futures), QQQ (NQ), VIX,
// TLT (10y duration), GLD (gold), DXY (USD)
// Per-position symbols: pulled from /api/options/positions
function OvernightStrip() {
const [quotes, setQuotes] = tUseState({});
const [positions, setPositions] = tUseState([]);
const MACRO = ['SPY', 'QQQ', 'VIX', 'TLT', 'GLD'];
tUseEffect(() => {
let cancel = false;
const load = async () => {
try {
// 1. fetch open positions for the book
const r = await fetch('/api/options/positions');
if (r.ok) {
const j = await r.json();
if (!cancel) {
const tickers = [...new Set((j.positions || []).map(p => p.ticker).filter(Boolean))];
setPositions(tickers);
}
}
// 2. fetch quotes for macro + position tickers
if (typeof window.getQuote !== 'function') return;
const symbols = [...MACRO, ...(positions || [])];
const uniqSyms = [...new Set(symbols)];
const results = await Promise.all(uniqSyms.map(async sym => {
try { return [sym, await window.getQuote(sym)]; }
catch { return [sym, null]; }
}));
if (!cancel) {
const next = {};
for (const [sym, q] of results) if (q) next[sym] = q;
setQuotes(next);
}
} catch {}
};
load();
const onFocus = () => { if (document.visibilityState === 'visible') load(); };
document.addEventListener('visibilitychange', onFocus);
return () => { cancel = true; document.removeEventListener('visibilitychange', onFocus); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const macroQuotes = MACRO.map(s => ({ symbol: s, q: quotes[s] }));
// Gap movers in book: |change%| > 1.5
const gapMovers = positions
.filter(t => quotes[t] && Math.abs(quotes[t].changePct || 0) > 1.5)
.map(t => ({ symbol: t, q: quotes[t] }))
.sort((a, b) => Math.abs(b.q.changePct || 0) - Math.abs(a.q.changePct || 0));
if (Object.keys(quotes).length === 0) return null;
const Pill = ({ label, q, emphasize }) => {
if (!q) return null;
const chg = q.changePct;
const color = chg == null ? 'var(--text-dim)' : chg >= 0 ? '#5fb37c' : '#d96a6a';
const bg = emphasize && chg != null && Math.abs(chg) > 1.5
? (chg >= 0 ? 'rgba(95,179,124,0.12)' : 'rgba(217,106,106,0.12)')
: 'transparent';
return (
1.5 ? color : 'var(--line)'}40`,
}}>
{label}
{q.price != null && ${Number(q.price).toFixed(2)}}
{chg != null && (
{chg >= 0 ? '+' : ''}{chg.toFixed(2)}%
)}
);
};
return (
Macro
{macroQuotes.map(({ symbol, q }) => )}
{gapMovers.length > 0 && (
Gap movers · book
{gapMovers.map(({ symbol, q }) => )}
)}
);
}
// Phase 6 R · #1 — Catalyst calendar strip. Pre-trade context: what
// macro events are coming up in the next 30 days (FOMC, CPI, NFP,
// OPEX). Renders as a horizontal scroll of pills color-coded by impact.
// Data from /api/catalysts/upcoming. Per-ticker earnings coverage is
// future work (needs an external earnings API integration).
function CatalystStrip() {
const [items, setItems] = tUseState([]);
const [loaded, setLoaded] = tUseState(false);
tUseEffect(() => {
let cancel = false;
fetch('/api/catalysts/upcoming?days=30')
.then(r => r.ok ? r.json() : null)
.then(j => { if (!cancel && j) { setItems(j.items || []); setLoaded(true); } })
.catch(() => {});
return () => { cancel = true; };
}, []);
if (!loaded || items.length === 0) return null;
return (
{/* Phase 6 R · #2 — Memo action row. Turn the Today tab
from a passive viewer into a launch pad. Hedging
memos get "Stage hedge" (jumps to Trades), all memos
get accept/reject feedback that posts back to the
cron memory for future reference. */}
);
}
// AutonomyStrip — top-of-Today panel that surfaces the event-driven
// autonomy state: latest market snapshot (VIX, drawdown, BWD), every
// persona with a `trigger:` and its armed/latched state, and the count
// of pending trade-recs queued via propose_trade.
//
// Reads:
// GET /api/events/snapshot?refresh=true
// GET /api/events/personas
// GET /api/trade-recs?status=pending&limit=50
//
// Polls every 60s while visible. Mirrors OvernightStrip's pill style.
function AutonomyStrip() {
const [snapshot, setSnapshot] = tUseState(null);
const [personas, setPersonas] = tUseState([]);
const [pendingRecs, setPendingRecs] = tUseState([]);
const [twitterSignals, setTwitterSignals] = tUseState([]);
const [twitterPollerLive, setTwitterPollerLive] = tUseState(false);
const [error, setError] = tUseState(null);
const load = tUseCallback(async () => {
try {
const [snap, perso, recs, sigs, watch] = await Promise.all([
fetch('/api/events/snapshot?refresh=true').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/api/events/personas').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/api/trade-recs?status=pending&limit=50').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/api/twitter/signals?limit=10&routed_only=true').then(r => r.ok ? r.json() : null).catch(() => null),
fetch('/api/twitter/watch').then(r => r.ok ? r.json() : null).catch(() => null),
]);
setSnapshot(snap?.snapshot || null);
setPersonas(perso?.personas || []);
setPendingRecs(recs?.recs || []);
setTwitterSignals(sigs?.signals || []);
setTwitterPollerLive(Boolean(watch?.poller_live));
setError(null);
} catch (e) {
setError(String(e.message || e));
}
}, []);
tUseEffect(() => { load(); }, [load]);
tUseEffect(() => {
let t;
const onVis = () => {
if (document.visibilityState === 'visible') {
t = setInterval(load, 60_000);
} else if (t) {
clearInterval(t); t = null;
}
};
onVis();
document.addEventListener('visibilitychange', onVis);
return () => {
if (t) clearInterval(t);
document.removeEventListener('visibilitychange', onVis);
};
}, [load]);
// No data yet → render nothing (matches OvernightStrip's pattern of
// staying invisible until it has something to show).
if (!snapshot && personas.length === 0 && pendingRecs.length === 0 && !error) return null;
const fmtPct = (v) => v == null ? '—' : `${(v * 100).toFixed(1)}%`;
const fmtNum = (v, digits = 0) => v == null ? '—' : Number(v).toLocaleString('en-US', { maximumFractionDigits: digits });
// Snapshot pills — one per metric. Color the value based on whether
// any wired trigger uses it as LHS and is currently true (rough
// heuristic: "this metric matters and the threshold is hit").
const snapColor = (field, value) => {
if (value == null) return 'var(--text-dim)';
const matchingTrigger = personas.find(p =>
p.trigger && p.trigger.toLowerCase().startsWith(field) && p.evaluates_now === true);
if (matchingTrigger) return '#d96a6a';
return 'var(--text-2)';
};
return (
Autonomy
{/* Snapshot metric pills */}
{snapshot && (
<>
>
)}
{/* Pending recs counter — clickable would be great but the
Trades tab is a separate workspace; for now just show count. */}
{pendingRecs.length > 0 && (
{pendingRecs.length} queued· review in Trades
)}
{/* Trigger row — one pill per persona */}
{personas.length > 0 && (
Triggers
{personas.map(p => )}
)}
{/* Twitter signals row — recent matched tweets that fired a persona */}
{twitterSignals.length > 0 && (