Files
RedditBot/apps/dashboard/src/features/overview/overview-page.tsx
T
Jamie BruntonandClaude Opus 5 14d92e14ee feat: add scan mode so conversations stay in sync without sending
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.

Replace the boolean with an explicit three-way mode:

  running — discover, screen, send, automate replies
  scan    — refresh chats, detect replies, check responses; send nothing
             and discover nothing; actionable replies surface as backlog
  stopped — everything halts, including background media sync

scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.

The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:24 +01:00

318 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetModeMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotMode, BotStats, Timers } from '../../api/contracts.js';
import type { AccountStatus } from '../../../../../packages/api-contracts/src/index.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js';
import { RecentMessagesPanel } from './recent-messages.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
const MODE_ORDER: readonly BotMode[] = ['running', 'scan', 'stopped'];
const modeCopy: Record<BotMode, { label: string; action: string; dot: string; buttonTone: string }> = {
running: { label: 'Running', action: 'Start bot', dot: 'running', buttonTone: 'button primary' },
scan: { label: 'Scanning', action: 'Scan mode', dot: 'scanning', buttonTone: 'button secondary' },
stopped: { label: 'Stopped', action: 'Stop bot', dot: 'stopped', buttonTone: 'button danger' },
};
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function formatTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(timestamp);
}
function formatMetricValue(value: number): string {
return value.toLocaleString();
}
function formatCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1_000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
/** Ticks a client clock so time-derived UI flips without a refetch. */
function useNow(intervalMs = 1_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = globalThis.setInterval(() => setNow(Date.now()), intervalMs);
return () => globalThis.clearInterval(timer);
}, [intervalMs]);
return now;
}
function MetricCard({ label, value, detail, metric }: { label: string; value: string | number; detail: string; metric: string }) {
return (
<article className="metric-card">
<span>{label}</span>
<strong data-metric={metric}>{value}</strong>
<small>{detail}</small>
</article>
);
}
function TimersPanel({ timers }: { timers: Timers }) {
const now = useNow();
const isReady = timers.nextMessageAvailableAt === null || timers.nextMessageAvailableAt <= now;
return (
<section aria-labelledby="timers-heading" className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Rate limits</span>
<h2 id="timers-heading">Sending window</h2>
</div>
<span className={isReady ? 'pill success' : 'pill warning'}>{isReady ? 'Ready' : 'Cooling down'}</span>
</div>
<dl className="definition-list">
<div>
<dt>Next message</dt>
<dd>{isReady
? 'Available now'
: `Ready in ${formatCountdown((timers.nextMessageAvailableAt ?? now) - now)} (${formatTime(timers.nextMessageAvailableAt ?? now)})`}</dd>
</div>
<div>
<dt>Hourly allowance</dt>
<dd>{timers.messagesRemaining} messages remaining</dd>
</div>
<div>
<dt>Allowance resets</dt>
<dd>{formatTime(timers.hourlyResetAt)}</dd>
</div>
</dl>
</section>
);
}
const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; tone: 'success' | 'warning' | 'neutral'; detail: string }> = {
ready: { label: 'Ready', tone: 'success', detail: 'Available for the next outbound candidate.' },
'bot-stopped': { label: 'Bot stopped', tone: 'neutral', detail: 'Global sending is stopped.' },
'scan-only': { label: 'Scan only', tone: 'neutral', detail: 'Conversations stay in sync, but nothing is sent.' },
'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' },
'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' },
'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' },
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
unestablished: { label: 'Not established', tone: 'warning', detail: 'Reddit will not accept chat invitations from this account yet.' },
};
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
return (
<section aria-labelledby="account-status-heading" className="panel account-status-panel">
<div className="panel-heading">
<div><span className="eyebrow">Multi-account delivery</span><h2 id="account-status-heading">Account status</h2></div>
<span className="pill neutral">{accounts.length} configured</span>
</div>
{accounts.length === 0 ? <div className="empty-state"><strong>No accounts configured.</strong><span>Add an account under Settings → Access.</span></div> : (
<div className="account-status-grid">
{accounts.map((account) => {
const state = sendingStateCopy[account.sendingState];
return (
<article aria-label={`Status for u/${account.redditUsername}`} className="account-status-card" key={account.accountId}>
<div className="account-status-heading">
<AccountOwnerBadge owner={account} />
<span className={`pill ${state.tone}`}>{state.label}</span>
</div>
<p>{state.detail}</p>
<dl className="account-status-details">
<div><dt>Runtime</dt><dd>{account.runtimeState}</dd></div>
<div><dt>Authentication</dt><dd>{account.authenticated ? 'Authenticated' : 'Unavailable'}</dd></div>
<div><dt>Hourly usage</dt><dd>{account.messagesThisHour} of {account.maxMessagesPerHour}</dd></div>
<div><dt>Remaining</dt><dd>{account.messagesRemaining}</dd></div>
<div><dt>Allowance resets</dt><dd>{formatTime(account.hourlyResetAt)}</dd></div>
<div><dt>Pending replies</dt><dd>{account.actionableReplies}</dd></div>
</dl>
{account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown
? <p className="account-status-note">Next send: {formatTime(account.nextMessageAvailableAt)}</p>
: null}
{account.invitationCooldown ? (
<div className="account-status-warning">
<strong>Cooldown until {formatTime(account.invitationCooldown.until)}</strong>
<span>{account.invitationCooldown.reason}</span>
{account.invitationCooldown.redditCode ? <code>{account.invitationCooldown.redditCode}</code> : null}
</div>
) : null}
{account.failure ? <div className="account-status-warning is-error" role="alert"><strong>Runtime failure</strong><span>{account.failure}</span></div> : null}
</article>
);
})}
</div>
)}
</section>
);
}
function ActivityPanel({ stats }: { stats: BotStats }) {
return (
<section aria-labelledby="activity-heading" className="panel activity-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">Latest signals</span>
<h2 id="activity-heading">Recent activity</h2>
</div>
</div>
{stats.recentActivity.length === 0 ? (
<div className="empty-state">
<strong>No recent activity yet.</strong>
<span>New bot events will appear here.</span>
</div>
) : (
<ol className="activity-list">
{stats.recentActivity.map((item, index) => (
<li key={`${item.timestamp}-${index}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
)}
</section>
);
}
export function OverviewPage() {
const queryClient = useQueryClient();
const statsQuery = useStatsQuery();
const timersQuery = useTimersQuery();
const controlMutation = useSetModeMutation();
const [confirmationAction, setConfirmationAction] = useState<BotMode | null>(null);
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
async function refreshOverview() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.stats }),
queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.timers }),
]);
}
function confirmControl() {
if (!confirmationAction) return;
controlMutation.mutate(confirmationAction, {
onSuccess: () => setConfirmationAction(null),
});
}
if (statsQuery.isPending) {
if (statsQuery.isPaused) {
return (
<div className="page-state error-state" role="alert">
<strong>You appear to be offline</strong>
<span>The overview will load once the connection returns.</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
return <div aria-label="Loading overview" className="page-state" role="status"><span className="spinner" />Loading overview…</div>;
}
if (statsQuery.isError) {
return (
<div className="page-state error-state" role="alert">
<strong>Overview unavailable</strong>
<span>{errorMessage(statsQuery.error)}</span>
<button className="button secondary" onClick={() => void statsQuery.refetch()} type="button">Retry overview</button>
</div>
);
}
const stats = statsQuery.data;
// Offer the two modes the bot is not currently in, in a stable order.
const offeredModes = MODE_ORDER.filter((mode) => mode !== stats.mode);
return (
<div className="page">
<header className="page-header">
<div>
<span className="eyebrow">Control room</span>
<h1>Overview</h1>
<p>System health, sending limits, and the signals that need attention.</p>
</div>
<div className="header-actions">
<button
aria-busy={isRefreshing ? 'true' : undefined}
aria-label={isRefreshing ? 'Refreshing overview' : 'Refresh overview'}
className="button secondary"
disabled={isRefreshing}
onClick={() => void refreshOverview()}
type="button"
>
{isRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
{offeredModes.map((mode) => (
<button
className={modeCopy[mode].buttonTone}
key={mode}
onClick={() => setConfirmationAction(mode)}
type="button"
>
{modeCopy[mode].action}
</button>
))}
</div>
</header>
<section aria-label="Bot state" className="status-strip">
<div>
<span className={`status-dot ${modeCopy[stats.mode].dot}`} aria-hidden="true" />
<div><small>Bot status</small><strong data-bot-status>{modeCopy[stats.mode].label}</strong></div>
</div>
{stats.dryRunMode ? <span className="pill neutral">Dry run</span> : null}
<span className="status-note">{stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour</span>
</section>
{stats.invitationCooldown ? (
<div className="cooldown-banner" role="status">
<strong>{stats.invitationCooldown.redditUsername
? `u/${stats.invitationCooldown.redditUsername} invitation cooldown`
: 'Reddit invitation cooldown'}</strong>
<span>{stats.invitationCooldown.reason}</span>
</div>
) : null}
<section aria-label="Key metrics" className="metrics-grid">
<MetricCard detail="All recorded sends" label="Messages sent" metric="messages-sent" value={formatMetricValue(stats.totalMessagesSent)} />
<MetricCard detail={`${stats.responseRate}% response rate`} label="Responses" metric="responses" value={formatMetricValue(stats.totalResponses)} />
<MetricCard detail="Posts evaluated" label="Processed" metric="processed" value={formatMetricValue(stats.totalProcessed)} />
<MetricCard detail={`Limit ${formatMetricValue(stats.maxMessagesPerHour)}`} label="This hour" metric="messages-hour" value={formatMetricValue(stats.messagesThisHour)} />
</section>
<AccountStatusPanel accounts={stats.accounts} />
<div className="overview-grid">
{timersQuery.isPending ? (
<section aria-label="Loading timers" className="panel panel-loading" role="status"><span className="spinner" />Loading sending window…</section>
) : timersQuery.isError ? (
<section className="panel error-panel" role="alert">
<strong>Sending window unavailable</strong>
<span>{errorMessage(timersQuery.error)}</span>
<button className="button secondary" onClick={() => void timersQuery.refetch()} type="button">Retry timers</button>
</section>
) : (
<TimersPanel timers={timersQuery.data} />
)}
<ActivityPanel stats={stats} />
</div>
<RecentMessagesPanel />
{confirmationAction ? (
<ConfirmationDialog
action={confirmationAction}
errorMessage={controlMutation.isError ? errorMessage(controlMutation.error) : null}
isPending={controlMutation.isPending}
onCancel={() => { setConfirmationAction(null); controlMutation.reset(); }}
onConfirm={confirmControl}
/>
) : null}
</div>
);
}