diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx
index 0399ef6..3717307 100644
--- a/apps/dashboard/src/app/app-shell.tsx
+++ b/apps/dashboard/src/app/app-shell.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
-import { navigationItems } from './navigation.js';
+import { navigationGroups, navigationItemsByGroup } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js';
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
@@ -20,18 +20,23 @@ export function AppShell() {
diff --git a/apps/dashboard/src/app/navigation.ts b/apps/dashboard/src/app/navigation.ts
index ca7400a..a426e8d 100644
--- a/apps/dashboard/src/app/navigation.ts
+++ b/apps/dashboard/src/app/navigation.ts
@@ -2,16 +2,28 @@ export interface NavigationItem {
label: string;
path: string;
description: string;
+ group: NavigationGroup;
}
+export type NavigationGroup = 'Monitor' | 'Content' | 'Analyse' | 'Configure';
+
+/** Render order for the sidebar's group headings. */
+export const navigationGroups: readonly NavigationGroup[] = ['Monitor', 'Content', 'Analyse', 'Configure'];
+
export const navigationItems: readonly NavigationItem[] = [
- { label: 'Overview', path: '/', description: 'System health and current activity' },
- { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' },
- { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' },
- { label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
- { label: 'Media', path: '/media', description: 'Private media and sync controls' },
- { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked' },
- { label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
- { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
- { label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
+ { label: 'Overview', path: '/', description: 'System health and current activity', group: 'Monitor' },
+ { label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks', group: 'Monitor' },
+ { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery', group: 'Monitor' },
+ { label: 'Conversations', path: '/conversations', description: 'Inbox and message history', group: 'Monitor' },
+ { label: 'Media', path: '/media', description: 'Private media and sync controls', group: 'Content' },
+ { label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked', group: 'Content' },
+ { label: 'Performance', path: '/performance', description: 'Response and strategy trends', group: 'Analyse' },
+ { label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics', group: 'Analyse' },
+ { label: 'Subreddits', path: '/subreddits', description: 'Where the bot posts and how it talks', group: 'Configure' },
+ { label: 'Accounts', path: '/accounts', description: 'Reddit sessions and access', group: 'Configure' },
+ { label: 'Settings', path: '/settings', description: 'Pacing, safety, and notifications', group: 'Configure' },
];
+
+export function navigationItemsByGroup(group: NavigationGroup): readonly NavigationItem[] {
+ return navigationItems.filter((item) => item.group === group);
+}
diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx
index 35c0be8..3d5860d 100644
--- a/apps/dashboard/src/app/notifications.tsx
+++ b/apps/dashboard/src/app/notifications.tsx
@@ -158,7 +158,7 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft |
? `Signed in as u/${event.payload.username}.`
: `Session status changed to ${event.payload.status}.`),
tone: event.payload.status === 'authenticated' ? 'success' : 'error',
- href: '/settings',
+ href: '/accounts',
};
case 'conversation.updated':
case 'sync.started':
diff --git a/apps/dashboard/src/app/router.tsx b/apps/dashboard/src/app/router.tsx
index 8d32f7d..e754f08 100644
--- a/apps/dashboard/src/app/router.tsx
+++ b/apps/dashboard/src/app/router.tsx
@@ -10,6 +10,8 @@ import { RouteErrorPage } from './route-error-page.js';
import { AttentionPage } from '../features/attention/attention-page.js';
import { OutreachPage } from '../features/outreach/outreach-page.js';
import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
+import { SubredditsPage } from '../features/subreddits/subreddits-page.js';
+import { AccountsPage } from '../features/accounts/accounts-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
@@ -27,6 +29,8 @@ export function createDashboardRoutes(): RouteObject[] {
{ path: 'media-library', element: },
{ path: 'performance', element: },
{ path: 'operations', element: },
+ { path: 'subreddits', element: },
+ { path: 'accounts', element: },
{ path: 'settings', element: },
],
},
diff --git a/apps/dashboard/src/components/form-fields.tsx b/apps/dashboard/src/components/form-fields.tsx
new file mode 100644
index 0000000..70ff0d9
--- /dev/null
+++ b/apps/dashboard/src/components/form-fields.tsx
@@ -0,0 +1,225 @@
+import type { ReactNode } from 'react';
+
+interface FieldShellProps {
+ error?: string;
+ /** Guidance shown when there is no error to show in its place. */
+ hint?: ReactNode;
+ label: string;
+ name: string;
+}
+
+function fieldIds(name: string) {
+ return { errorId: `${name}-error`, hintId: `${name}-hint` };
+}
+
+function FieldMessage({ error, hint, name }: FieldShellProps) {
+ const { errorId, hintId } = fieldIds(name);
+ if (error) return {error};
+ if (hint) return {hint};
+ return null;
+}
+
+function describedBy({ error, hint, name }: FieldShellProps): string | undefined {
+ const { errorId, hintId } = fieldIds(name);
+ if (error) return errorId;
+ return hint ? hintId : undefined;
+}
+
+export interface NumberFieldProps extends FieldShellProps {
+ max?: number;
+ min?: number;
+ step?: number | 'any';
+ /** Rendered inside the input as a trailing unit, e.g. "min" or "/hr". */
+ suffix?: string;
+ value: string;
+ onChange(value: string): void;
+}
+
+export function NumberField({ error, hint, label, max, min, name, onChange, step = 1, suffix, value }: NumberFieldProps) {
+ const shell = { error, hint, label, name };
+ return (
+
+ );
+}
+
+export interface TextFieldProps extends FieldShellProps {
+ placeholder?: string;
+ /** Rendered as a non-editable prefix inside the input, e.g. "r/". */
+ prefix?: string;
+ type?: 'text' | 'url';
+ value: string;
+ onChange(value: string): void;
+}
+
+export function TextField({ error, hint, label, name, onChange, placeholder, prefix, type = 'text', value }: TextFieldProps) {
+ const shell = { error, hint, label, name };
+ return (
+
+ );
+}
+
+export interface SelectFieldProps extends FieldShellProps {
+ options: ReadonlyArray<{ value: Value; label: string }>;
+ value: Value;
+ onChange(value: Value): void;
+}
+
+export function SelectField({ error, hint, label, name, onChange, options, value }: SelectFieldProps) {
+ const shell = { error, hint, label, name };
+ return (
+
+ );
+}
+
+export interface TextAreaFieldProps extends FieldShellProps {
+ monospace?: boolean;
+ placeholder?: string;
+ rows?: number;
+ value: string;
+ onChange(value: string): void;
+}
+
+export function TextAreaField({ error, hint, label, monospace, name, onChange, placeholder, rows = 3, value }: TextAreaFieldProps) {
+ const shell = { error, hint, label, name };
+ return (
+
+ );
+}
+
+export interface SwitchFieldProps {
+ checked: boolean;
+ description?: ReactNode;
+ label: string;
+ /** Emphasises the field as a consequential control. */
+ tone?: 'default' | 'critical';
+ onChange(checked: boolean): void;
+}
+
+export function SwitchField({ checked, description, label, onChange, tone = 'default' }: SwitchFieldProps) {
+ return (
+
+ );
+}
+
+export interface RadioCardOption {
+ value: Value;
+ label: string;
+ description: string;
+ /** Extra controls revealed only while this option is selected. */
+ detail?: ReactNode;
+ /** Flags the option as operationally risky. */
+ isRisky?: boolean;
+}
+
+export interface RadioCardsProps {
+ legend: string;
+ name: string;
+ options: ReadonlyArray>;
+ value: Value;
+ onChange(value: Value): void;
+}
+
+/**
+ * One-of choice where each option's explanation is body copy rather than a
+ * footnote, and its dependent settings are nested inside the chosen card so
+ * revealing them does not reflow the options above.
+ */
+export function RadioCards({ legend, name, onChange, options, value }: RadioCardsProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/unsaved-changes.tsx b/apps/dashboard/src/components/unsaved-changes.tsx
new file mode 100644
index 0000000..3c8d269
--- /dev/null
+++ b/apps/dashboard/src/components/unsaved-changes.tsx
@@ -0,0 +1,94 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+ type ReactNode,
+} from 'react';
+import { useBlocker } from 'react-router-dom';
+import { Modal } from './modal.js';
+
+interface UnsavedChangesContextValue {
+ register(id: string, isDirty: boolean): void;
+ release(id: string): void;
+}
+
+const UnsavedChangesContext = createContext(null);
+
+/**
+ * Page-level guard for a screen made of independently saved cards.
+ *
+ * Each card reports its own dirty state by id, so the guard can warn about
+ * leaving while *any* card has unsaved edits without the cards needing to
+ * share a draft. Section switches within a page do not trip it: only a
+ * pathname change is blocked.
+ */
+export function UnsavedChangesProvider({ children }: PropsWithChildren): ReactNode {
+ const [dirtyIds, setDirtyIds] = useState([]);
+
+ const register = useCallback((id: string, isDirty: boolean) => {
+ setDirtyIds((current) => {
+ const isTracked = current.includes(id);
+ if (isDirty === isTracked) return current;
+ return isDirty ? [...current, id] : current.filter((item) => item !== id);
+ });
+ }, []);
+
+ const release = useCallback((id: string) => {
+ setDirtyIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : current));
+ }, []);
+
+ const value = useMemo(() => ({ register, release }), [register, release]);
+ const hasUnsavedChanges = dirtyIds.length > 0;
+
+ useEffect(() => {
+ if (!hasUnsavedChanges) return;
+ function warnBeforeUnload(event: BeforeUnloadEvent) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ window.addEventListener('beforeunload', warnBeforeUnload);
+ return () => window.removeEventListener('beforeunload', warnBeforeUnload);
+ }, [hasUnsavedChanges]);
+
+ const blocker = useBlocker(({ currentLocation, nextLocation }) =>
+ hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname);
+
+ return (
+
+ {children}
+ {blocker.state === 'blocked' ? (
+ blocker.reset()}>
+ Unsaved changes
+
Leave without saving?
+
+ {dirtyIds.length === 1
+ ? 'One section has edits that have not been saved. Leaving will discard them.'
+ : `${dirtyIds.length} sections have edits that have not been saved. Leaving will discard them.`}
+
+
+ {pausedCount === 1 ? 'One account is paused.' : `${pausedCount} accounts are paused.`}
+
+ A paused account is waiting for a fresh session dump. Other accounts keep running normally.
+
Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.
- {username ?
Verified account: .
: null}
-
- {accountsQuery.isPending ?
Loading Reddit accounts…
: null}
- {accountsQuery.isError ? (
-
- Could not load Reddit accounts: {errorMessage(accountsQuery.error)}{' '}
-
-
- {
- const file = event.target.files?.[0];
- // Reset the input so picking the same file again re-triggers change.
- event.target.value = '';
- void importSession(file);
- }}
- ref={importInput}
- type="file"
- />
-
-
-
-
Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.
- {importFileError ?
{importFileError}
: null}
- {importMutation.isSuccess ?
Reddit session imported and verified.Account runtime is running independently.
: null}
- {importMutation.isError ?
{errorMessage(importMutation.error)}
: null}
-
- );
-}
diff --git a/apps/dashboard/src/features/settings/advanced-card.tsx b/apps/dashboard/src/features/settings/advanced-card.tsx
new file mode 100644
index 0000000..a8026db
--- /dev/null
+++ b/apps/dashboard/src/features/settings/advanced-card.tsx
@@ -0,0 +1,85 @@
+import { useState } from 'react';
+import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
+import { TextAreaField } from '../../components/form-fields.js';
+import { SettingsCard } from './settings-card.js';
+import { createAdvancedDraft, validateAdvancedDraft, type AdvancedDraft } from './settings-model.js';
+import { useConfigCard } from './use-config-card.js';
+
+/** Top-level keys of the advanced blob, so the card says what it holds while locked. */
+function summariseKeys(advanced: Record): string[] {
+ return Object.keys(advanced).sort((left, right) => left.localeCompare(right));
+}
+
+export function AdvancedCard({ config }: { config: ConfigResponse }) {
+ const [isUnlocked, setIsUnlocked] = useState(false);
+ const card = useConfigCard(config, {
+ id: 'advanced',
+ toDraft: createAdvancedDraft,
+ validate: validateAdvancedDraft,
+ });
+
+ const keys = config.advanced ? summariseKeys(config.advanced) : [];
+
+ // Locked state is a plain section: no draft, so no save footer to show.
+ if (!isUnlocked) {
+ return (
+
+
+
+ Raw configuration
+
Advanced
+
Models, prompts, filtering, media sync and strategy settings, edited as raw JSON. Some changes take effect only after a restart.
+
+ Locked
+
+
+ {keys.length > 0 ? (
+
+ Currently configured
+
{keys.map((key) =>
{key}
)}
+
+ ) : null}
+
+ There is no validation beyond JSON shape here.
+ A wrong value can stop the bot from running or change what it says to people. Edit this only if you know the schema.
+
Enter valid numbers to see the effective sending rate.
;
+ }
+ return (
+
+ {estimate.effectivePerHour} messages per hour
+
+ {estimate.spacingLabel}
+ {estimate.isDelayBound
+ ? ' — the minimum delay is the binding limit, so raising the hourly limit alone changes nothing.'
+ : ' — the hourly limit is the binding limit.'}
+
+
+ );
+}
+
+export function PacingCard({ config }: { config: ConfigResponse }) {
+ const card = useConfigCard(config, {
+ id: 'pacing',
+ toDraft: createPacingDraft,
+ validate: validatePacingDraft,
+ risks: pacingRisks,
+ });
+ const { draft, errors } = card;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/features/settings/push-notifications-panel.tsx b/apps/dashboard/src/features/settings/push-notifications-panel.tsx
index 9a19305..a59d9a8 100644
--- a/apps/dashboard/src/features/settings/push-notifications-panel.tsx
+++ b/apps/dashboard/src/features/settings/push-notifications-panel.tsx
@@ -5,6 +5,8 @@ import {
type PushNotificationEventKey,
type PushNotificationSettingsResponse,
} from '../../../../../packages/api-contracts/src/index.js';
+import { SelectField, SwitchField, TextField } from '../../components/form-fields.js';
+import { useUnsavedChangesFlag } from '../../components/unsaved-changes.js';
import {
usePushNotificationsQuery,
useSavePushNotificationsMutation,
@@ -81,7 +83,7 @@ function isDirty(draft: PushDraft, persisted: PushDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
}
-export function PushNotificationsPanel() {
+export function PushNotificationsCard() {
const settingsQuery = usePushNotificationsQuery();
const saveMutation = useSavePushNotificationsMutation();
const testMutation = useTestPushNotificationMutation();
@@ -89,6 +91,9 @@ export function PushNotificationsPanel() {
const [state, setState] = useState<{ persisted: PushDraft; draft: PushDraft } | null>(null);
const [errors, setErrors] = useState>({});
+ const dirty = state ? isDirty(state.draft, state.persisted) : false;
+ useUnsavedChangesFlag('notifications', dirty);
+
// Seed on first load, and reconcile server refetches: adopt them into a
// clean editor, keep the operator's edits in a dirty one.
useEffect(() => {
@@ -105,22 +110,31 @@ export function PushNotificationsPanel() {
if (settingsQuery.isError) {
return (
-
- Notification settings unavailable{errorMessage(settingsQuery.error)}
-
+
+
+ Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app to receive
+ them. Anyone who knows the topic name can read it, so treat it as a secret.
+
- Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app on your
- phone to receive them. Anyone who knows the topic name can read it, so treat it as a secret.
-
-
-
-
-
-
-
- {settings?.hasAuthToken
- ? 'An access token is configured, so protected topics will accept these notifications.'
- : 'No access token is configured. Set NTFY_AUTH_TOKEN in the environment if your topic requires one.'}
-
-
-
-
-
-
- {dirty ? You have unsaved changes. : All changes saved.}
- {saveMutation.isSuccess && !dirty ? Notification settings saved. : null}
+
+
);
}
diff --git a/apps/dashboard/src/features/settings/safety-card.tsx b/apps/dashboard/src/features/settings/safety-card.tsx
new file mode 100644
index 0000000..d1cf437
--- /dev/null
+++ b/apps/dashboard/src/features/settings/safety-card.tsx
@@ -0,0 +1,46 @@
+import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
+import { SwitchField } from '../../components/form-fields.js';
+import { SettingsCard } from './settings-card.js';
+import { createSafetyDraft, safetyRisks, validateSafetyDraft, type SafetyDraft } from './settings-model.js';
+import { useConfigCard } from './use-config-card.js';
+
+export function SafetyCard({ config }: { config: ConfigResponse }) {
+ const card = useConfigCard(config, {
+ id: 'safety',
+ toDraft: createSafetyDraft,
+ validate: validateSafetyDraft,
+ risks: safetyRisks,
+ });
+ const isLive = !card.persisted.dryRun;
+
+ return (
+ {isLive ? 'Sending live' : 'Dry run'}}
+ title="Safety"
+ >
+ {/* State first: the operator should read what is happening now before
+ being shown the control that changes it. */}
+
+ {isLive ? 'Messages are being sent to real people.' : 'No messages are being sent.'}
+
+ {isLive
+ ? 'Every evaluated post can produce a real outbound message, subject to the pacing limits.'
+ : 'Posts are evaluated and replies are drafted, but nothing is delivered to Reddit.'}
+
+
+
+ card.update({ ...card.draft, dryRun })}
+ tone="critical"
+ />
+
+ );
+}
diff --git a/apps/dashboard/src/features/settings/settings-card.tsx b/apps/dashboard/src/features/settings/settings-card.tsx
new file mode 100644
index 0000000..733fb50
--- /dev/null
+++ b/apps/dashboard/src/features/settings/settings-card.tsx
@@ -0,0 +1,97 @@
+import type { ReactNode } from 'react';
+import { RiskDialog } from './risk-dialog.js';
+import type { ConfigCard } from './use-config-card.js';
+
+interface SettingsCardProps {
+ /** Anchor target and heading id stem, e.g. "pacing". */
+ anchor: string;
+ card: ConfigCard;
+ children: ReactNode;
+ description: string;
+ eyebrow: string;
+ /** Rendered in the heading, opposite the title — usually a status pill. */
+ status?: ReactNode;
+ saveLabel: string;
+ title: string;
+}
+
+function errorMessage(error: Error | null): string {
+ return error?.message ?? 'Something went wrong.';
+}
+
+/**
+ * A self-contained settings section: its own draft, its own validation, and its
+ * own save. Nothing in this card can block or be blocked by another card.
+ */
+export function SettingsCard({
+ anchor,
+ card,
+ children,
+ description,
+ eyebrow,
+ saveLabel,
+ status,
+ title,
+}: SettingsCardProps) {
+ const headingId = `${anchor}-heading`;
+ return (
+
+
+
+
+
+ {/* Value resets to the placeholder each render: these are actions, not
+ a field showing the current shared value (there may not be one). */}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/features/subreddits/subreddit-editor.tsx b/apps/dashboard/src/features/subreddits/subreddit-editor.tsx
new file mode 100644
index 0000000..a206e45
--- /dev/null
+++ b/apps/dashboard/src/features/subreddits/subreddit-editor.tsx
@@ -0,0 +1,266 @@
+import { useState } from 'react';
+import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
+import {
+ NumberField,
+ RadioCards,
+ SelectField,
+ SwitchField,
+ TextAreaField,
+ TextField,
+ type RadioCardOption,
+} from '../../components/form-fields.js';
+import { Modal } from '../../components/modal.js';
+import { RiskDialog } from '../settings/risk-dialog.js';
+import {
+ conversationModes,
+ createEmptySubredditDraft,
+ formatShare,
+ MAX_AUTOMATED_TURNS,
+ previewShare,
+ toSubredditDraft,
+ usesAutomatedTurns,
+ validateSubredditDraft,
+ type ConversationMode,
+ type SubredditConfig,
+ type SubredditDraft,
+} from './subreddit-model.js';
+
+interface SubredditEditorProps {
+ /** The subreddit being edited, or null to create a new one. */
+ subreddit: SubredditConfig | null;
+ allSubreddits: readonly SubredditConfig[];
+ tones: ConfigResponse['tones'];
+ isSaving: boolean;
+ saveError: Error | null;
+ onCancel(): void;
+ onSave(config: SubredditConfig): void;
+}
+
+function errorMessage(error: Error | null): string {
+ return error?.message ?? 'Something went wrong.';
+}
+
+/**
+ * Edits exactly one subreddit. Because the draft is a single row, validation
+ * errors are keyed by plain field name — there are no array indices to shift
+ * when rows are added or removed elsewhere.
+ */
+export function SubredditEditor({
+ allSubreddits,
+ isSaving,
+ onCancel,
+ onSave,
+ saveError,
+ subreddit,
+ tones,
+}: SubredditEditorProps) {
+ const isNew = subreddit === null;
+ const [draft, setDraft] = useState(() =>
+ subreddit ? toSubredditDraft(subreddit) : createEmptySubredditDraft());
+ const [errors, setErrors] = useState>({});
+ const [pendingRisks, setPendingRisks] = useState<{ config: SubredditConfig; reasons: string[] } | null>(null);
+
+ const takenNames = allSubreddits
+ .filter((item) => item.name !== subreddit?.name)
+ .map((item) => item.name);
+
+ function update(next: Partial) {
+ setErrors({});
+ setDraft((current) => ({ ...current, ...next }));
+ }
+
+ function submit() {
+ const validation = validateSubredditDraft(draft, takenNames);
+ setErrors(validation.errors);
+ if (!validation.config) return;
+
+ const reasons: string[] = [];
+ const previousMode = subreddit?.conversation.mode;
+ const nextMeta = conversationModes.find((mode) => mode.value === validation.config!.conversation.mode);
+ if (nextMeta?.isAutonomous && previousMode !== validation.config.conversation.mode) {
+ reasons.push(
+ `r/${validation.config.name} will reply autonomously, without approval for each message.`
+ + (validation.config.conversation.mode === 'r4r-media'
+ ? ' After rapport it can make one optional, non-explicit image request.'
+ : ' It will not request sexual media.'),
+ );
+ reasons.push('Conversations already in progress keep the policy they started with.');
+ }
+
+ if (reasons.length > 0) {
+ setPendingRisks({ config: validation.config, reasons });
+ return;
+ }
+ onSave(validation.config);
+ }
+
+ const share = previewShare(draft, allSubreddits, subreddit?.name ?? null);
+ const turnOptions: RadioCardOption[] = conversationModes.map((mode) => ({
+ value: mode.value,
+ label: mode.label,
+ description: mode.description,
+ isRisky: mode.isAutonomous,
+ detail: usesAutomatedTurns(mode.value) ? (
+
+ {selected.length === 1 ? 'Stop using 1 subreddit?' : `Stop using ${selected.length} subreddits?`}
+
+
+ They will no longer be polled and their configuration is discarded. Conversations already started there
+ are not affected, and their history stays in reporting.
+
+ It will no longer be polled and its configuration is discarded. Conversations already started there are
+ not affected, and its history stays in reporting.
+