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>
89 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|