The settings page had accumulated five tabs whose names no longer matched their contents, three different save models, and the whole subreddit configuration buried under a tab named after a checkbox. Structure: - Subreddits becomes its own page: a list showing each community's mode, tone, computed share of polling, and live sent/reply stats, with one subreddit edited at a time in a drawer. - Accounts becomes its own page. It is session lifecycle, not config. - Settings collapses to one scrolling page: Safety, Pacing (Monitoring and Rate limits merged), Targeting, Notifications, Advanced. - The sidebar is grouped, having gone from 9 to 11 destinations. Saving: - Every section owns its own draft, validation, risk gate and save via useConfigCard, submitting only the keys it owns. A syntax error in the advanced JSON can no longer block a rate-limit change, and enabling an autonomous policy is its own deliberate commit. - UnsavedChangesProvider restores the leave-page guard across cards that no longer share a draft. Subreddit editing: - Errors are keyed by field name instead of array index, removing the defensive invalidation that existed because row indices shifted on add and remove. Confirming an autonomous mode can no longer be attributed to the wrong row. - Weight is shown as the share it actually produces, live while editing. - Bulk actions cover start/pause polling, tone, policy and removal, because production runs 30-40 subreddits and per-row editing does not scale. Bulk policy changes name every subreddit turning autonomous, and normalise the hybrid mode's turn budget, which has no form to catch it. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. Also surfaces the effective sending rate, since the hourly limit and the minimum delay both throttle and only the binding one matters. Legacy ?section= links redirect or resolve to anchors. Fixes found while verifying in a browser: a radio-card rule was shrinking nested number inputs; .share-preview had no styles; the throughput readout reported a rate from a blank field; and the reconcile logic warned about a server change whenever a sibling card saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
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<SubredditDraft>(() =>
|
||
subreddit ? toSubredditDraft(subreddit) : createEmptySubredditDraft());
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
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<SubredditDraft>) {
|
||
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<ConversationMode>[] = conversationModes.map((mode) => ({
|
||
value: mode.value,
|
||
label: mode.label,
|
||
description: mode.description,
|
||
isRisky: mode.isAutonomous,
|
||
detail: usesAutomatedTurns(mode.value) ? (
|
||
<div className="field-row">
|
||
<NumberField
|
||
error={errors.maxAutomatedTurns}
|
||
hint={`Replies it may send before handing over (1–${MAX_AUTOMATED_TURNS}).`}
|
||
label="Maximum automated turns"
|
||
max={MAX_AUTOMATED_TURNS}
|
||
min={1}
|
||
name="max-automated-turns"
|
||
onChange={(maxAutomatedTurns) => update({ maxAutomatedTurns })}
|
||
suffix="turns"
|
||
value={draft.maxAutomatedTurns}
|
||
/>
|
||
{mode.value === 'r4r-media' ? (
|
||
<NumberField
|
||
error={errors.rapportTurnsBeforeMediaAsk}
|
||
hint="Must be lower than the maximum automated turns."
|
||
label="Rapport turns before the ask"
|
||
max={MAX_AUTOMATED_TURNS - 1}
|
||
min={1}
|
||
name="rapport-turns"
|
||
onChange={(rapportTurnsBeforeMediaAsk) => update({ rapportTurnsBeforeMediaAsk })}
|
||
suffix="turns"
|
||
value={draft.rapportTurnsBeforeMediaAsk}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : undefined,
|
||
}));
|
||
|
||
return (
|
||
<>
|
||
<Modal className="drawer-card" isDismissLocked={isSaving} labelledBy="subreddit-editor-title" onClose={onCancel}>
|
||
<header className="drawer-heading">
|
||
<div>
|
||
<span className="eyebrow">{isNew ? 'New subreddit' : 'Edit subreddit'}</span>
|
||
<h2 id="subreddit-editor-title">{isNew ? 'Add a subreddit' : `r/${subreddit.name}`}</h2>
|
||
</div>
|
||
<button aria-label="Close editor" className="icon-button" disabled={isSaving} onClick={onCancel} type="button">
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</header>
|
||
|
||
<div className="drawer-body">
|
||
<fieldset className="field-group">
|
||
<legend>Community</legend>
|
||
<div className="field-row">
|
||
<TextField
|
||
error={errors.name}
|
||
hint="Without the r/ prefix."
|
||
label="Subreddit name"
|
||
name="subreddit-name"
|
||
onChange={(name) => update({ name })}
|
||
placeholder="casualconversation"
|
||
prefix="r/"
|
||
value={draft.name}
|
||
/>
|
||
<SelectField
|
||
label="Post type"
|
||
name="subreddit-type"
|
||
onChange={(type) => update({ type })}
|
||
options={[{ value: 'image', label: 'Image posts' }, { value: 'chat', label: 'Chat and text posts' }]}
|
||
value={draft.type}
|
||
/>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<fieldset className="field-group">
|
||
<legend>Targeting</legend>
|
||
<SwitchField
|
||
checked={draft.enabled}
|
||
description="Disabled subreddits are not polled at all and take no share of attention."
|
||
label="Poll this subreddit"
|
||
onChange={(enabled) => update({ enabled })}
|
||
/>
|
||
<div className="field-row">
|
||
<NumberField
|
||
error={errors.weight}
|
||
hint="Relative to other enabled subreddits. Leave blank for the default of 1."
|
||
label="Weight"
|
||
min={0.01}
|
||
name="subreddit-weight"
|
||
onChange={(weight) => update({ weight })}
|
||
step="any"
|
||
value={draft.weight}
|
||
/>
|
||
{/* Weight only means something as a proportion, so show the
|
||
proportion it will actually produce. */}
|
||
<div className="share-preview">
|
||
<span className="field-label">Share of polling</span>
|
||
<strong aria-live="polite">{formatShare(share)}</strong>
|
||
<small>{draft.enabled ? 'Of all enabled subreddits, once saved.' : 'Nothing while polling is off.'}</small>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<fieldset className="field-group">
|
||
<legend>Voice</legend>
|
||
<div className="field-row">
|
||
<SelectField
|
||
hint="Which tone profile writes the messages."
|
||
label="Tone"
|
||
name="subreddit-tone"
|
||
onChange={(tone) => update({ tone })}
|
||
options={tones.map((tone) => ({ value: tone, label: tone }))}
|
||
value={draft.tone}
|
||
/>
|
||
</div>
|
||
<TextAreaField
|
||
hint="Sent verbatim instead of a generated opener. Leave blank to let the model write it."
|
||
label="Scripted opening message (optional)"
|
||
name="subreddit-scripted"
|
||
onChange={(scripted) => update({ scripted })}
|
||
rows={3}
|
||
value={draft.scripted}
|
||
/>
|
||
</fieldset>
|
||
|
||
<RadioCards
|
||
legend="Conversation policy"
|
||
name="conversation-mode"
|
||
onChange={(conversationMode) => update({ conversationMode })}
|
||
options={turnOptions}
|
||
value={draft.conversationMode}
|
||
/>
|
||
</div>
|
||
|
||
<footer className="drawer-footer">
|
||
<div aria-live="polite" className="settings-card-status">
|
||
{Object.keys(errors).length > 0 ? (
|
||
<strong className="save-error" role="alert">
|
||
{Object.keys(errors).length === 1
|
||
? 'Resolve 1 problem before saving.'
|
||
: `Resolve ${Object.keys(errors).length} problems before saving.`}
|
||
</strong>
|
||
) : null}
|
||
{saveError ? <strong className="save-error" role="alert">{errorMessage(saveError)}</strong> : null}
|
||
</div>
|
||
<div className="header-actions">
|
||
<button className="button secondary" disabled={isSaving} onClick={onCancel} type="button">Cancel</button>
|
||
<button
|
||
aria-busy={isSaving ? 'true' : undefined}
|
||
className="button primary"
|
||
disabled={isSaving}
|
||
onClick={submit}
|
||
type="button"
|
||
>
|
||
{isSaving ? 'Saving…' : isNew ? 'Add subreddit' : 'Save subreddit'}
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
</Modal>
|
||
|
||
{pendingRisks ? (
|
||
<RiskDialog
|
||
isPending={isSaving}
|
||
onCancel={() => setPendingRisks(null)}
|
||
onConfirm={() => onSave(pendingRisks.config)}
|
||
reasons={pendingRisks.reasons}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|