Repository Public
1'use client';2import { useEffect, useState } from 'react';3import type { AosSettings, Effort, Provider } from '@aos/shared';4import { DEFAULT_SETTINGS, useModels, useSaveSettings, useSettingsQuery } from '@/lib/queries';5import { errorMessage } from '@/lib/api';6import { toast } from '@/stores/toasts';7import { EFFORT_LABEL, PROVIDER_LABEL, TIER_LABEL } from '@/lib/status';8import { Input } from '@/components/ui/input';9import { Select } from '@/components/ui/select';10import { Switch } from '@/components/ui/switch';11import { ErrorState, Section, Skeleton } from '@/components/ui/misc';12import { SecuritySettings } from '@/components/settings/security';13import { ThemeSegmented } from '@/components/shell/theme-toggle';1415function Row({ label, hint, children }: { label: string; hint?: React.ReactNode; children: React.ReactNode }) {16 return (17 <div className="flex flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">18 <div className="min-w-0 flex-1 basis-56">19 <p className="text-[13px] font-medium text-text">{label}</p>20 {hint && <p className="mt-0.5 text-xs leading-relaxed text-muted">{hint}</p>}21 </div>22 <div className="w-full shrink-0 sm:w-64">{children}</div>23 </div>24 );25}26/** Number input committed on blur/Enter, clamped to the schema bounds. */27function NumberSetting({ value, min, max, onCommit, label, unit, integer, testId }: { value: number; min: number; max: number; onCommit: (n: number) => void; label: string; unit: string; integer?: boolean; testId?: string }) {28 const [text, setText] = useState(String(value));29 useEffect(() => setText(String(value)), [value]);30 const commit = () => {31 const raw = Number(text);32 if (!Number.isFinite(raw)) return setText(String(value));33 const c = Math.max(min, Math.min(max, integer ? Math.round(raw) : Math.round(raw * 100) / 100));34 setText(String(c));35 if (c !== value) onCommit(c);36 };37 return (38 <div className="flex items-center gap-2">39 <Input aria-label={label} inputMode={integer ? 'numeric' : 'decimal'} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && commit()} className="tnum" data-testid={testId} />40 <span className="w-24 shrink-0 text-[11px] text-muted">{unit} ({min}–{max})</span>41 </div>42 );43}4445export default function SettingsPage() {46 const q = useSettingsQuery();47 const save = useSaveSettings();48 const models = useModels().data ?? [];49 const s = q.data ?? DEFAULT_SETTINGS;50 const set = (patch: Partial<AosSettings>) => save(patch).catch((e) => toast.error('Could not save settings', errorMessage(e)));51 const ofProvider = models.filter((m) => m.provider === s.defaultProvider);52 const model = models.find((m) => m.id === s.defaultModel);53 const efforts = model?.efforts ?? (['low', 'medium', 'high', 'xhigh', 'max'] as Effort[]);54 return (55 <div className="h-full overflow-y-auto">56 {q.isPending ? (57 <div className="mx-auto flex max-w-3xl flex-col gap-4 p-4 md:p-8">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-32" />)}</div>58 ) : q.isError ? (59 <ErrorState error={q.error} onRetry={() => q.refetch()} />60 ) : (61 <div className="mx-auto flex max-w-3xl flex-col gap-5 px-4 pb-32 pt-1 md:px-8 md:pb-12 md:pt-6" data-testid="settings">62 <div className="hidden md:block">63 <h1 className="text-xl font-bold tracking-tight">Settings</h1>64 <p className="text-[13px] text-text-2">Saved on the server for this administrator. Changes apply to missions started afterwards.</p>65 </div>66 <Section title="Mission defaults">67 <div className="divide-y divide-line">68 <Row label="Provider">69 <Select70 label="Default provider"71 value={s.defaultProvider}72 onValueChange={(v) => {73 const first = models.find((m) => m.provider === v && m.available && m.tier === 'main') ?? models.find((m) => m.provider === v && m.available);74 void set({ defaultProvider: v as Provider, ...(first ? { defaultModel: first.id } : {}) });75 }}76 options={(['anthropic', 'openai'] as Provider[]).map((p) => ({ value: p, label: PROVIDER_LABEL[p] }))}77 />78 </Row>79 <Row label="Model" hint="Used when a mission does not pick one. Unavailable models are not listed.">80 <Select81 label="Default model"82 value={s.defaultModel}83 onValueChange={(v) => set({ defaultModel: v })}84 options={[...ofProvider.filter((m) => m.available || m.id === s.defaultModel).map((m) => ({ value: m.id, label: m.label, hint: TIER_LABEL[m.tier] })), ...(model || !s.defaultModel ? [] : [{ value: s.defaultModel, label: s.defaultModel }])]}85 />86 </Row>87 <Row label="Reasoning effort">88 <Select label="Default effort" value={s.defaultEffort} onValueChange={(v) => set({ defaultEffort: v as Effort })} options={efforts.map((e) => ({ value: e, label: EFFORT_LABEL[e] }))} />89 </Row>90 </div>91 </Section>92 <Section title="Budgets and limits">93 <div className="divide-y divide-line">94 <Row label="Mission budget" hint="Default hard stop of a new mission. Can be changed per mission.">95 <NumberSetting label="Mission budget" value={s.missionBudgetUsd} min={0.01} max={1000} unit="USD" onCommit={(n) => set({ missionBudgetUsd: n })} testId="set-mission-budget" />96 </Row>97 <Row label="Daily cap" hint="Across all missions. Once today's spend reaches it, model calls stop and new missions are refused.">98 <NumberSetting label="Daily cap" value={s.dailyBudgetUsd} min={0.01} max={100000} unit="USD" onCommit={(n) => set({ dailyBudgetUsd: n })} testId="set-daily-budget" />99 </Row>100 <Row label="Concurrent runs" hint="How many runs the worker executes at the same time.">101 <NumberSetting label="Max concurrent runs" value={s.maxConcurrentRuns} min={1} max={32} unit="runs" integer onCommit={(n) => set({ maxConcurrentRuns: n })} testId="set-concurrency" />102 </Row>103 <Row label="Approval timeout" hint="A request you have not answered after this long expires, which counts as a denial.">104 <NumberSetting label="Approval timeout" value={s.approvalTimeoutHours} min={1} max={336} unit="hours" integer onCommit={(n) => set({ approvalTimeoutHours: n })} testId="set-approval-timeout" />105 </Row>106 </div>107 </Section>108 <Section title="Appearance">109 <Row label="Theme" hint="Light by default. “System” follows this device."><ThemeSegmented /></Row>110 </Section>111 <Section title="Notifications">112 <div className="divide-y divide-line">113 <Row label="In-app notifications" hint="A toast when an agent needs an approval or an answer while the console is open.">114 <Switch label="In-app notifications" checked={s.notifications.inApp} onCheckedChange={(v) => set({ notifications: { ...s.notifications, inApp: v } })} />115 </Row>116 <Row label="Detail level" hint="Generic text never shows what the agent wants to do; “titles” includes the request title.">117 <Select label="Notification detail" value={s.notifications.detail} onValueChange={(v) => set({ notifications: { ...s.notifications, detail: v as 'generic' | 'titles' } })} options={[{ value: 'generic', label: 'Generic' }, { value: 'titles', label: 'Include titles' }]} />118 </Row>119 </div>120 </Section>121 <SecuritySettings />122 </div>123 )}124 </div>125 );126}127