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>
This commit is contained in:
1 parent
3a40651930
commit
14d92e14ee
38 files changed
+724
-140
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
botStatsSchema,
|
||||
controlResponseSchema,
|
||||
timersSchema,
|
||||
type BotMode,
|
||||
type BotStats,
|
||||
type ControlResponse,
|
||||
type Timers,
|
||||
@@ -36,7 +37,7 @@ async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise
|
||||
export interface DashboardApi {
|
||||
getStats(): Promise<BotStats>;
|
||||
getTimers(): Promise<Timers>;
|
||||
setRunning(isRunning: boolean): Promise<ControlResponse>;
|
||||
setMode(mode: BotMode): Promise<ControlResponse>;
|
||||
}
|
||||
|
||||
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
||||
@@ -49,9 +50,15 @@ export function createDashboardApi(fetchImplementation?: typeof fetch): Dashboar
|
||||
async getTimers() {
|
||||
return parseResponse(await request('/api/timers'), timersSchema);
|
||||
},
|
||||
async setRunning(isRunning) {
|
||||
const endpoint = isRunning ? '/api/start' : '/api/stop';
|
||||
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
|
||||
async setMode(mode) {
|
||||
return parseResponse(
|
||||
await request('/api/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
controlResponseSchema,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
} from '../../../../packages/api-contracts/src/index.js';
|
||||
|
||||
export type {
|
||||
BotMode,
|
||||
ControlResponse,
|
||||
StatsResponse as BotStats,
|
||||
TimersResponse as Timers,
|
||||
|
||||
@@ -19,13 +19,13 @@ export function useTimersQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 });
|
||||
}
|
||||
|
||||
export function useSetRunningMutation() {
|
||||
export function useSetModeMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: dashboardApi.setRunning,
|
||||
mutationFn: dashboardApi.setMode,
|
||||
onSuccess(response) {
|
||||
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
|
||||
current ? { ...current, isRunning: response.running } : current,
|
||||
current ? { ...current, isRunning: response.running, mode: response.mode } : current,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useStatsQuery, useSetRunningMutation } from '../api/queries.js';
|
||||
import { useStatsQuery, useSetModeMutation } from '../api/queries.js';
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
import { ConfirmationDialog } from '../components/confirmation-dialog.js';
|
||||
import { useSyncStatusQuery } from '../features/operations/operations-queries.js';
|
||||
import { useLoginStatusQuery } from '../features/settings/settings-queries.js';
|
||||
@@ -12,15 +13,15 @@ export function ShellStatusPanel() {
|
||||
const reddit = useLoginStatusQuery();
|
||||
const sync = useSyncStatusQuery();
|
||||
const realtime = useDashboardRealtime();
|
||||
const control = useSetRunningMutation();
|
||||
const [confirming, setConfirming] = useState<'start' | 'stop' | null>(null);
|
||||
const control = useSetModeMutation();
|
||||
const [confirming, setConfirming] = useState<BotMode | null>(null);
|
||||
const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection });
|
||||
return <>
|
||||
<section aria-label="Platform status" className="shell-status">
|
||||
<strong>{status.bot}</strong><span>{status.reddit}</span><span>{status.realtime}</span><span>{status.sync}</span>
|
||||
{status.cooldown ? <span className="shell-warning">Cooldown: {status.cooldown}</span> : null}
|
||||
<button aria-label={`${status.controlAction === 'start' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'start' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'start' ? 'Start bot' : 'Stop bot'}</button>
|
||||
<button aria-label={`${status.controlAction === 'running' ? 'Start' : 'Stop'} bot from global status`} className={status.controlAction === 'running' ? 'button primary' : 'button danger'} disabled={status.controlDisabled || control.isPending} onClick={() => setConfirming(status.controlAction)} type="button">{status.controlAction === 'running' ? 'Start bot' : 'Stop bot'}</button>
|
||||
</section>
|
||||
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming === 'start', { onSuccess: () => setConfirming(null) })} /> : null}
|
||||
{confirming ? <ConfirmationDialog action={confirming} isPending={control.isPending} onCancel={() => setConfirming(null)} onConfirm={() => control.mutate(confirming, { onSuccess: () => setConfirming(null) })} /> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js';
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
|
||||
const MODE_LABEL: Record<BotMode, string> = {
|
||||
running: 'Running',
|
||||
scan: 'Scanning',
|
||||
stopped: 'Stopped',
|
||||
};
|
||||
|
||||
interface ShellStatusInput {
|
||||
stats?: { isRunning: boolean; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
|
||||
stats?: { mode: BotMode; dryRunMode: boolean; invitationCooldown: { reason: string } | null };
|
||||
reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string };
|
||||
realtime: RealtimeConnectionSnapshot;
|
||||
sync?: { syncing: boolean; syncingKind?: string | null };
|
||||
@@ -13,7 +20,7 @@ export interface ShellStatus {
|
||||
realtime: string;
|
||||
cooldown: string | null;
|
||||
sync: string;
|
||||
controlAction: 'start' | 'stop';
|
||||
controlAction: BotMode;
|
||||
controlDisabled: boolean;
|
||||
}
|
||||
|
||||
@@ -28,12 +35,14 @@ export function deriveShellStatus(input: ShellStatusInput): ShellStatus {
|
||||
? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required'
|
||||
: 'Reddit session unavailable';
|
||||
return {
|
||||
bot: stats ? `${stats.isRunning ? 'Running' : 'Stopped'}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
|
||||
bot: stats ? `${MODE_LABEL[stats.mode]}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable',
|
||||
reddit,
|
||||
realtime,
|
||||
cooldown: stats?.invitationCooldown?.reason ?? null,
|
||||
sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync',
|
||||
controlAction: stats?.isRunning ? 'stop' : 'start',
|
||||
// The header's single control is the opposite of wherever we are now;
|
||||
// scan mode's natural next step is a full start.
|
||||
controlAction: stats?.mode === 'running' ? 'stopped' : 'running',
|
||||
controlDisabled: stats === undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,50 @@
|
||||
import type { BotMode } from '../api/contracts.js';
|
||||
import { Modal } from './modal.js';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
action: 'start' | 'stop';
|
||||
action: BotMode;
|
||||
errorMessage?: string | null;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
const copy: Record<BotMode, { title: string; body: string; confirm: string; tone: string }> = {
|
||||
running: {
|
||||
title: 'Start bot?',
|
||||
body: 'The bot will resume automated processing.',
|
||||
confirm: 'Confirm start',
|
||||
tone: 'button primary',
|
||||
},
|
||||
scan: {
|
||||
title: 'Switch to scan mode?',
|
||||
body: 'Conversations stay in sync and replies are flagged for you, but nothing is sent and no new candidates are discovered.',
|
||||
confirm: 'Confirm scan mode',
|
||||
tone: 'button secondary',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Stop bot?',
|
||||
body: 'The bot will pause after its current operation. Conversations stop syncing too — use scan mode to keep them up to date.',
|
||||
confirm: 'Confirm stop',
|
||||
tone: 'button danger',
|
||||
},
|
||||
};
|
||||
|
||||
export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) {
|
||||
const isStarting = action === 'start';
|
||||
const { title, body, confirm, tone } = copy[action];
|
||||
|
||||
return (
|
||||
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="confirmation-title" onClose={onCancel}>
|
||||
<span className="eyebrow">Confirm action</span>
|
||||
<h2 id="confirmation-title">{isStarting ? 'Start bot?' : 'Stop bot?'}</h2>
|
||||
<p>
|
||||
{isStarting
|
||||
? 'The bot will resume automated processing.'
|
||||
: 'The bot will pause after its current operation.'}
|
||||
</p>
|
||||
<h2 id="confirmation-title">{title}</h2>
|
||||
<p>{body}</p>
|
||||
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button className={isStarting ? 'button primary' : 'button danger'} disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Applying…' : `Confirm ${action}`}
|
||||
<button className={tone} disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Applying…' : confirm}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
|
||||
import type { BotStats, Timers } from '../../api/contracts.js';
|
||||
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';
|
||||
@@ -9,6 +9,14 @@ 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.';
|
||||
}
|
||||
@@ -83,6 +91,7 @@ function TimersPanel({ timers }: { timers: Timers }) {
|
||||
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.' },
|
||||
@@ -174,8 +183,8 @@ export function OverviewPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const statsQuery = useStatsQuery();
|
||||
const timersQuery = useTimersQuery();
|
||||
const controlMutation = useSetRunningMutation();
|
||||
const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null);
|
||||
const controlMutation = useSetModeMutation();
|
||||
const [confirmationAction, setConfirmationAction] = useState<BotMode | null>(null);
|
||||
const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending;
|
||||
|
||||
async function refreshOverview() {
|
||||
@@ -187,7 +196,7 @@ export function OverviewPage() {
|
||||
|
||||
function confirmControl() {
|
||||
if (!confirmationAction) return;
|
||||
controlMutation.mutate(confirmationAction === 'start', {
|
||||
controlMutation.mutate(confirmationAction, {
|
||||
onSuccess: () => setConfirmationAction(null),
|
||||
});
|
||||
}
|
||||
@@ -216,7 +225,8 @@ export function OverviewPage() {
|
||||
}
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const action = stats.isRunning ? 'stop' : 'start';
|
||||
// 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">
|
||||
@@ -237,20 +247,23 @@ export function OverviewPage() {
|
||||
>
|
||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
className={action === 'start' ? 'button primary' : 'button danger'}
|
||||
onClick={() => setConfirmationAction(action)}
|
||||
type="button"
|
||||
>
|
||||
{action === 'start' ? 'Start bot' : 'Stop bot'}
|
||||
</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 ${stats.isRunning ? 'running' : 'stopped'}`} aria-hidden="true" />
|
||||
<div><small>Bot status</small><strong data-bot-status>{stats.isRunning ? 'Running' : 'Stopped'}</strong></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>
|
||||
|
||||
@@ -77,6 +77,7 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||
.status-strip small { color: var(--muted); }
|
||||
.status-dot { width: .7rem; height: .7rem; border-radius: 50%; }
|
||||
.status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); }
|
||||
.status-dot.scanning { background: var(--warning-text); box-shadow: 0 0 0 .35rem rgba(255, 200, 87, .12); }
|
||||
.status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); }
|
||||
.pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||
.pill.neutral { color: #d4dfda; background: #2b3832; }
|
||||
|
||||
Reference in new issue
Block a user