sync: update from private repo (2ef0fa6)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-17 05:23:28 +00:00
parent 517142c61d
commit 1602d52510
42 changed files with 3387 additions and 64 deletions
+30 -6
View File
@@ -6,6 +6,7 @@ import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
import { SubtaskInlineCard } from './SubtaskInlineCard';
import RotatingTips from './RotatingTips';
import { useJobStream } from '../../hooks/useJobStream';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
@@ -192,6 +193,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
const inputLocked = jobStatus === 'dispatching';
// A job is already accepted but not yet running. The task is NOT idle: a new
// message must fold into this pending job (server reuses it, no duplicate),
// so the composer presents it as an addition rather than a fresh request.
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
const hasActiveJob = isBusy || isPending;
// Release the submit lock once the agent is visibly responding: the new user
// comment is reflected in the list AND the job has been picked up by a worker
@@ -201,10 +207,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
if (!submitting) return;
const baseline = submitBaselineRef.current;
if (baseline === null) return;
if (comments.length > baseline && isBusy) {
if (comments.length > baseline && hasActiveJob) {
releaseSubmitting();
}
}, [submitting, comments.length, isBusy]);
}, [submitting, comments.length, hasActiveJob]);
// Clear the safety-net timeout on unmount.
useEffect(() => {
@@ -383,6 +389,9 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
)}
</div>
)}
{/* General rotating tips to make the wait useful (running only). */}
{isBusy && <RotatingTips />}
</div>
</div>
@@ -406,17 +415,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{/* Composer */}
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
{isBusy && (
{hasActiveJob && (
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
canInterject
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
: isPending
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
}`}>
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>{canInterject ? t('pane.interjectHint') : t('pane.agentRunningWait')}</span>
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
</div>
)}
{sendError && !isBusy && (
@@ -468,7 +479,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
onPaste={e => void handlePaste(e)}
rows={2}
disabled={inputLocked}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : t('pane.placeholder.default')}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
/>
{isBusy && onCancel ? (
@@ -498,6 +509,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
{cancelling ? t('pane.stopping') : t('pane.stop')}
</button>
</div>
) : isPending ? (
<button
disabled={submitting || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
title={t('pane.addToQueuedHint')}
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
{t('pane.addToQueued')}
</button>
) : (
<button
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import ja from '../../i18n/locales/ja/chat.json';
import en from '../../i18n/locales/en/chat.json';
// Pure JSON parity check for the rotating-tips strings consumed by
// RotatingTips.tsx. The component itself is DOM + timer based and isn't
// exercised here; this guards the data contract (chat.tips.items) instead.
describe('chat tips (RotatingTips data)', () => {
it('ja and en both define a non-empty tips.items array', () => {
expect(Array.isArray(ja.tips?.items)).toBe(true);
expect(Array.isArray(en.tips?.items)).toBe(true);
expect(ja.tips.items.length).toBeGreaterThan(0);
expect(en.tips.items.length).toBeGreaterThan(0);
});
it('ja and en have the same number of tips (no locale drift)', () => {
expect(ja.tips.items.length).toBe(en.tips.items.length);
});
it('every tip is a non-empty string', () => {
for (const list of [ja.tips.items, en.tips.items]) {
for (const tip of list) {
expect(typeof tip).toBe('string');
expect(tip.trim().length).toBeGreaterThan(0);
}
}
});
it('defines a tips.label in both locales', () => {
expect(typeof ja.tips.label).toBe('string');
expect(typeof en.tips.label).toBe('string');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
/**
* RotatingTips — a subtle, rotating general "did you know" line shown while the
* agent is running, to make the wait useful. Tips are static, curated, and
* localized (chat.tips.items). Mount this only when the job is busy; it stops
* rotating automatically on unmount.
*
* Personalized, prompt-specific coaching is a separate feature (the on-demand
* prompt evaluator in the create dialog) — these tips are general only.
*/
const ROTATE_MS = 7000;
const FADE_MS = 300;
export default function RotatingTips() {
const { t } = useTranslation('chat');
const items = t('tips.items', { returnObjects: true }) as unknown;
const tips = Array.isArray(items) ? (items as string[]) : [];
// Random start so the same tip isn't always first; guarded for empty list.
const [idx, setIdx] = useState(() => (tips.length ? Math.floor(Math.random() * tips.length) : 0));
const [visible, setVisible] = useState(true);
useEffect(() => {
if (tips.length <= 1) return;
const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
const rotate = setInterval(() => {
setVisible(false);
const swap = setTimeout(() => {
setIdx((i) => (i + 1) % tips.length);
setVisible(true);
pendingTimeouts.delete(swap);
}, FADE_MS);
pendingTimeouts.add(swap);
}, ROTATE_MS);
return () => {
clearInterval(rotate);
pendingTimeouts.forEach(clearTimeout);
};
}, [tips.length]);
if (tips.length === 0) return null;
return (
<div className="flex justify-start" aria-live="polite">
<div className="inline-flex items-start gap-2 px-2.5 py-1 max-w-[80%] text-2xs text-slate-500 dark:text-slate-400">
<span className="flex-shrink-0 opacity-70" aria-hidden="true">💡</span>
<span className="font-medium flex-shrink-0 text-slate-400 dark:text-slate-500">
{t('tips.label')}
</span>
<span
className={`transition-opacity ease-in-out ${visible ? 'opacity-100' : 'opacity-0'}`}
style={{ transitionDuration: `${FADE_MS}ms` }}
>
{tips[idx]}
</span>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
import { AttachmentDropzone } from './AttachmentDropzone';
import { PromptCoachPanel } from './PromptCoachPanel';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { resolvePieceOptions } from '../../lib/splitPieces';
@@ -196,6 +197,13 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{/* Attachments */}
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
{/* Prompt coach (on-demand draft evaluation) */}
<PromptCoachPanel
body={form.body}
piece={initialPiece ?? form.piece}
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))}
/>
{/* MCP warnings (always visible when applicable) */}
{missingMcp.length > 0 && (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
@@ -0,0 +1,142 @@
import { useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { evaluatePrompt, type PromptCoachResult } from '../../api';
interface PromptCoachPanelProps {
/** Current draft body from the dialog's textarea. */
body: string;
/** Selected piece ('auto' = unset). */
piece?: string;
/** Inject the rewrite suggestion back into the textarea. */
onApplyRewrite: (text: string) => void;
}
function scoreColor(score: number, max: number): string {
const ratio = max > 0 ? score / max : 0;
if (ratio >= 0.75) return 'text-emerald-600 dark:text-emerald-400';
if (ratio >= 0.4) return 'text-amber-600 dark:text-amber-400';
return 'text-rose-600 dark:text-rose-400';
}
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
const { t } = useTranslation('create');
const mutation = useMutation<PromptCoachResult, Error, void>({
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
});
// Discard a stale evaluation once the draft (body or piece) changes, so the
// panel never shows a score/rewrite that no longer matches the textarea —
// and "Apply to request" can't overwrite the draft with an outdated rewrite.
const { reset } = mutation;
useEffect(() => {
reset();
}, [body, piece, reset]);
const result = mutation.data;
const disabled = !body.trim() || mutation.isPending;
return (
<div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => mutation.mutate()}
className="px-3 py-1.5 border border-accent/40 text-accent rounded-xl text-xs font-bold hover:bg-accent/5 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{mutation.isPending && (
<span className="inline-block w-3 h-3 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
)}
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
</button>
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
</div>
{mutation.isError && (
<div className="mt-2 text-xs text-rose-600">{t('coach.failed')}</div>
)}
{result && (
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
{/* Overall score */}
<div className="flex items-baseline gap-2">
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
<span className={`text-2xl font-extrabold ${scoreColor(result.overall, 100)}`}>{result.overall}</span>
<span className="text-xs text-slate-400">/ 100</span>
</div>
{/* Axes */}
{result.axes.length > 0 && (
<div className="space-y-1.5">
{result.axes.map((ax, i) => (
<div key={i} className="text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700 dark:text-slate-200">{ax.name}</span>
<span className={`font-bold ${scoreColor(ax.score, 10)}`}>{ax.score}/10</span>
</div>
{ax.comment && <div className="text-slate-500 dark:text-slate-400 mt-0.5">{ax.comment}</div>}
</div>
))}
</div>
)}
{/* Rewrite suggestion */}
{result.rewrite && (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-bold text-slate-500">{t('coach.rewrite')}</span>
<button
type="button"
onClick={() => onApplyRewrite(result.rewrite)}
className="px-2 py-0.5 rounded bg-accent text-accent-fg text-2xs font-bold hover:bg-accent-deep"
>
{t('coach.applyRewrite')}
</button>
</div>
<div className="text-xs text-slate-700 dark:text-slate-200 whitespace-pre-wrap bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 leading-relaxed">
{result.rewrite}
</div>
</div>
)}
{/* Predicted piece */}
{result.predicted_piece && (
<div className="text-xs">
<span className="font-bold text-slate-500">{t('coach.predictedPiece')}</span>{' '}
<span className="font-semibold text-accent">{result.predicted_piece.name}</span>
{result.predicted_piece.reason && (
<span className="text-slate-500 dark:text-slate-400"> {result.predicted_piece.reason}</span>
)}
</div>
)}
{/* MAESTRO tips */}
{result.maestro_tips.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.maestroTips')}</div>
<ul className="space-y-1">
{result.maestro_tips.map((tip, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">
<span className="font-semibold">{tip.feature}</span>
{tip.suggestion && <span className="text-slate-500 dark:text-slate-400"> {tip.suggestion}</span>}
</li>
))}
</ul>
</div>
)}
{/* Personalized notes */}
{result.personalized.length > 0 && (
<div>
<div className="text-xs font-bold text-slate-500 mb-1">{t('coach.personalized')}</div>
<ul className="list-disc list-inside space-y-0.5">
{result.personalized.map((note, i) => (
<li key={i} className="text-xs text-slate-600 dark:text-slate-300">{note}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useState } from 'react';
import type { SetupStatus } from '../../hooks/useSetupState';
// Full-screen first-run onboarding for a fresh, no-auth `docker compose up`.
// Configures the LLM connection (required) plus an optional server port and
// auth bootstrap, then applies everything in ONE call to /api/setup/apply.
// See docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md.
type ConnectionType = 'direct' | 'aao_gateway';
type AuthMode = 'none' | 'local' | 'oauth';
type OAuthProvider = 'google' | 'gitea';
interface ApplyResult {
ok: boolean;
restartRequired: boolean;
adminEmail?: string;
}
const PRIMARY_BTN =
'px-4 py-2 text-sm font-semibold bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50 transition-colors';
const SECONDARY_BTN =
'px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft disabled:opacity-50 transition-colors';
const INPUT =
'w-full px-3 py-2 text-sm border border-slate-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-accent/40';
const LABEL = 'block text-sm font-medium text-slate-700 mb-1';
function restartCommand(hint: SetupStatus['deployHint']): string {
return hint === 'docker' ? 'docker compose restart' : 'scripts/server.sh restart';
}
export function SetupWizard({ status, onComplete }: { status: SetupStatus; onComplete: () => void }) {
const tokenRequired = status.tokenRequired;
const [token, setToken] = useState('');
// Step 1 — LLM (required)
const [connectionType, setConnectionType] = useState<ConnectionType>('direct');
const [endpoint, setEndpoint] = useState('http://localhost:11434/v1');
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState('');
const [models, setModels] = useState<string[]>([]);
const [probing, setProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
// Step 2 — port (optional)
const [port, setPort] = useState('');
// Step 3 — auth (optional)
const [authMode, setAuthMode] = useState<AuthMode>('none');
const [localEmail, setLocalEmail] = useState('');
const [localPassword, setLocalPassword] = useState('');
const [oauthProvider, setOauthProvider] = useState<OAuthProvider>('gitea');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [callbackUrl, setCallbackUrl] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [oauthAdminEmail, setOauthAdminEmail] = useState('');
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyResult | null>(null);
function headers(): Record<string, string> {
const h: Record<string, string> = { 'content-type': 'application/json' };
if (tokenRequired && token.trim()) h['x-setup-token'] = token.trim();
return h;
}
async function runProbe() {
setProbing(true);
setProbeError(null);
setModels([]);
try {
const res = await fetch('/api/setup/probe', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ connectionType, endpoint, apiKey: apiKey || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setProbeError(data.error || `connection failed (HTTP ${res.status})`);
return;
}
const list: string[] = Array.isArray(data.models) ? data.models : [];
setModels(list);
if (list.length && !model) setModel(list[0]);
if (!list.length) setProbeError('connected, but no models were returned — enter a model name manually');
} catch (e) {
setProbeError(String(e));
} finally {
setProbing(false);
}
}
function authBlock(): unknown | undefined {
if (authMode === 'local') {
return { mode: 'local', local: { email: localEmail.trim(), password: localPassword } };
}
if (authMode === 'oauth') {
return {
mode: 'oauth',
oauth: {
provider: oauthProvider,
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
callbackUrl: callbackUrl.trim(),
adminEmail: oauthAdminEmail.trim(),
...(oauthProvider === 'gitea' ? { baseUrl: baseUrl.trim() } : {}),
},
};
}
return undefined;
}
const canApply =
(!tokenRequired || token.trim().length > 0) &&
endpoint.trim().length > 0 &&
model.trim().length > 0 &&
(connectionType !== 'aao_gateway' || apiKey.trim().length > 0) &&
(authMode !== 'local' || (localEmail.trim().length > 0 && localPassword.length >= 8)) &&
(authMode !== 'oauth' ||
(clientId.trim() &&
clientSecret.trim() &&
callbackUrl.trim() &&
oauthAdminEmail.trim().includes('@') &&
(oauthProvider !== 'gitea' || baseUrl.trim())));
async function runApply() {
setApplying(true);
setApplyError(null);
try {
const body: Record<string, unknown> = {
llm: { connectionType, endpoint: endpoint.trim(), model: model.trim(), apiKey: apiKey || undefined },
};
if (port.trim()) body.port = Number(port.trim());
const auth = authBlock();
if (auth) body.auth = auth;
const res = await fetch('/api/setup/apply', {
method: 'POST',
headers: headers(),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setApplyError(data.error || `apply failed (HTTP ${res.status})`);
return;
}
const r: ApplyResult = { ok: true, restartRequired: !!data.restartRequired, adminEmail: data.adminEmail };
setResult(r);
if (!r.restartRequired) {
// LLM-only: the wizard's job is done — refresh into the app.
onComplete();
}
} catch (e) {
setApplyError(String(e));
} finally {
setApplying(false);
}
}
// --- Finish screen (restart required) ---
if (result?.restartRequired) {
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-2">Almost there one restart needed</h1>
<p className="text-sm text-slate-600 mb-4">
Your LLM connection is live. The port and/or authentication changes take effect after a restart.
</p>
<div className="bg-slate-900 text-slate-100 text-sm rounded-lg px-4 py-3 font-mono mb-4 select-all">
{restartCommand(status.deployHint)}
</div>
{result.adminEmail && (
<p className="text-sm text-slate-600">
After restart, sign in as <span className="font-semibold">{result.adminEmail}</span> with the password you
just set. Change it from <span className="font-medium">Settings Account</span> afterwards.
</p>
)}
</Shell>
);
}
// --- Wizard form ---
return (
<Shell>
<h1 className="text-xl font-semibold text-slate-800 mb-1">Welcome let's get you set up</h1>
<p className="text-sm text-slate-600 mb-6">
Connect a language model to start running tasks. Optionally set the server port and turn on sign-in.
</p>
{tokenRequired && (
<Section title="Setup token" subtitle="Find it in the server logs: “open the UI and enter this setup token”.">
<input className={INPUT} value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste the setup token" />
</Section>
)}
<Section title="1 · Language model" subtitle="Required. This is reflected immediately — no restart.">
<label className={LABEL}>Connection</label>
<select
className={`${INPUT} mb-3`}
value={connectionType}
onChange={(e) => setConnectionType(e.target.value as ConnectionType)}
>
<option value="direct">Direct (Ollama / vLLM / OpenAI-compatible)</option>
<option value="aao_gateway">AAO gateway</option>
</select>
<label className={LABEL}>Endpoint URL</label>
<input className={`${INPUT} mb-3`} value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="http://localhost:11434/v1" />
{connectionType === 'aao_gateway' && (
<>
<label className={LABEL}>API key</label>
<input className={`${INPUT} mb-3`} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-aao-…" />
</>
)}
<div className="flex items-center gap-3 mb-3">
<button className={SECONDARY_BTN} onClick={runProbe} disabled={probing || !endpoint.trim() || (tokenRequired && !token.trim())}>
{probing ? 'Testing' : 'Test connection'}
</button>
{probeError && <span className="text-sm text-red-600">{probeError}</span>}
{!probeError && models.length > 0 && <span className="text-sm text-green-600">Found {models.length} model(s)</span>}
</div>
<label className={LABEL}>Model</label>
{models.length > 0 ? (
<select className={INPUT} value={model} onChange={(e) => setModel(e.target.value)}>
{!models.includes(model) && model && <option value={model}>{model}</option>}
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input className={INPUT} value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3.1:8b" />
)}
</Section>
<Section title="2 · Server port" subtitle="Optional. Leave blank to keep the current port. Takes effect after a restart.">
<input className={INPUT} value={port} onChange={(e) => setPort(e.target.value)} placeholder={String(status.port)} inputMode="numeric" />
</Section>
<Section title="3 · Sign-in" subtitle="Optional. Leave off to stay open (no login). Takes effect after a restart.">
<select className={`${INPUT} mb-3`} value={authMode} onChange={(e) => setAuthMode(e.target.value as AuthMode)}>
<option value="none">No sign-in (open)</option>
<option value="local">Email + password (first admin)</option>
<option value="oauth">OAuth (Google / Gitea)</option>
</select>
{authMode === 'local' && (
<>
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={localEmail} onChange={(e) => setLocalEmail(e.target.value)} placeholder="[email protected]" />
<label className={LABEL}>Admin password (min 8 chars)</label>
<input className={INPUT} type="password" value={localPassword} onChange={(e) => setLocalPassword(e.target.value)} />
</>
)}
{authMode === 'oauth' && (
<>
<label className={LABEL}>Provider</label>
<select className={`${INPUT} mb-3`} value={oauthProvider} onChange={(e) => setOauthProvider(e.target.value as OAuthProvider)}>
<option value="gitea">Gitea</option>
<option value="google">Google</option>
</select>
<label className={LABEL}>Client ID</label>
<input className={`${INPUT} mb-3`} value={clientId} onChange={(e) => setClientId(e.target.value)} />
<label className={LABEL}>Client secret</label>
<input className={`${INPUT} mb-3`} type="password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} />
<label className={LABEL}>Callback URL</label>
<input className={`${INPUT} mb-3`} value={callbackUrl} onChange={(e) => setCallbackUrl(e.target.value)} placeholder="https://your-host/auth/gitea/callback" />
<label className={LABEL}>Admin email</label>
<input className={`${INPUT} mb-3`} value={oauthAdminEmail} onChange={(e) => setOauthAdminEmail(e.target.value)} placeholder="[email protected] — becomes admin on first login" />
{oauthProvider === 'gitea' && (
<>
<label className={LABEL}>Gitea base URL</label>
<input className={INPUT} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://gitea.example.com" />
</>
)}
</>
)}
</Section>
{applyError && <p className="text-sm text-red-600 mb-3">{applyError}</p>}
<div className="flex justify-end pt-2">
<button className={PRIMARY_BTN} onClick={runApply} disabled={applying || !canApply}>
{applying ? 'Applying' : 'Apply & start'}
</button>
</div>
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="h-dvh overflow-y-auto bg-slate-50 flex items-start justify-center py-10 px-4">
<div className="w-full max-w-xl bg-white border border-slate-200 rounded-2xl shadow-sm p-8">{children}</div>
</div>
);
}
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<div className="mb-6 pb-6 border-b border-slate-100 last:border-b-0">
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 mb-3">{subtitle}</p>}
{children}
</div>
);
}