Files
RedditBot/apps/dashboard/src/features/attention/attention-page.tsx
T
Jamie BruntonandClaude Opus 5 89eb4583f8 feat: flag accounts Reddit considers unestablished and re-test them
New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.

Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.

An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:14:47 +01:00

89 lines
4.2 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
import { attentionApi } from './attention-api.js';
import './attention.css';
function relativeTime(timestamp: number, now: number) {
return (
<time dateTime={new Date(timestamp).toISOString()} title={formatExactTime(timestamp)}>
{formatShortRelativeTime(timestamp, now)}
</time>
);
}
function actionHint(kind: AttentionItem['kind']): string {
switch (kind) {
case 'account-login-required':
return 'No linked conversation — check Settings.';
case 'account-unestablished':
return 'No linked conversation — check Accounts.';
case 'runtime-failed':
case 'invitation-cooldown':
default:
return 'No linked conversation — check Operations.';
}
}
function itemAction(item: AttentionItem) {
if (!item.conversationId) return <span className="attention-item-hint">{actionHint(item.kind)}</span>;
return <Link className="button secondary" to={`/conversations/${encodeURIComponent(item.conversationId)}`}>Open conversation</Link>;
}
export function AttentionPage() {
const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 });
if (query.isPending) return <div className="page-state" role="status">Loading attention inbox…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Attention inbox unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
const now = Date.now();
const critical = query.data.items.filter((item) => item.severity === 'critical').length;
return (
<div className="page attention-page">
<header className="page-header">
<div>
<span className="eyebrow">Operator queue</span>
<h1>Needs attention</h1>
<p>Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.</p>
</div>
<div className="attention-refresh">
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="attention-refresh-note">Auto-refreshes every 30s</span>
</div>
</header>
<section aria-label="Attention totals" className="status-strip">
<div>
<span aria-hidden="true" className={`attention-status-dot ${query.data.items.length === 0 ? 'is-clear' : 'is-open'}`} />
<div><small>Open items</small><strong>{query.data.items.length}</strong></div>
</div>
<span className={critical > 0 ? 'pill danger' : 'pill success'}>{critical} critical</span>
<span className="status-note">Updated {relativeTime(query.data.generatedAt, now)}</span>
</section>
{query.data.items.length === 0 ? <div className="empty-state panel"><strong>Nothing needs attention.</strong><span>Automation and account health have no outstanding operator items.</span></div> : (
<ol aria-label="Attention items" className="attention-list">{query.data.items.map((item) => (
<li className={`attention-item is-${item.severity}`} key={item.id}>
<div className="attention-item-heading">
<div>
<span className={`pill ${item.severity === 'critical' ? 'danger' : 'warning'}`}>{item.kind.replaceAll('-', ' ')}</span>
<h2>{item.title}</h2>
</div>
{relativeTime(item.occurredAt, now)}
</div>
<p>{item.detail}</p>
{item.owner ? <AccountOwnerBadge owner={item.owner} /> : null}
{itemAction(item)}
</li>
))}</ol>
)}
</div>
);
}