singularity-forge/web/components/sf/settings-panels.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

1553 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
AlertTriangle,
CheckCircle2,
Cpu,
DollarSign,
Eye,
EyeOff,
FlaskConical,
KeyRound,
LoaderCircle,
Radio,
RefreshCw,
Settings,
SlidersHorizontal,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { authFetch } from "@/lib/auth";
import type {
SettingsData,
SettingsPatternHistory,
SettingsRoutingHistory,
} from "@/lib/settings-types";
import {
formatCost,
formatTokens,
useSFWorkspaceActions,
useSFWorkspaceState,
} from "@/lib/sf-workspace-store";
import { useEditorFontSize } from "@/lib/use-editor-font-size";
import { useTerminalFontSize } from "@/lib/use-terminal-font-size";
import { cn } from "@/lib/utils";
// ═══════════════════════════════════════════════════════════════════════
// SHARED INFRASTRUCTURE
// ═══════════════════════════════════════════════════════════════════════
function SettingsHeader({
title,
icon,
subtitle,
onRefresh,
refreshing,
}: {
title: string;
icon: React.ReactNode;
subtitle?: string | null;
onRefresh: () => void;
refreshing: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<span className="text-muted-foreground">{icon}</span>
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{title}
</h3>
{subtitle && (
<span className="text-[11px] text-muted-foreground">{subtitle}</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={refreshing}
className="h-7 gap-1.5 text-xs"
>
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
);
}
function SettingsError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
);
}
function SettingsLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
);
}
function SettingsEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
);
}
function Pill({
label,
value,
variant,
}: {
label: string;
value: string | number;
variant?: "default" | "info" | "warning" | "success";
}) {
return (
<div
className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "info" && "border-info/20 bg-info/5 text-info",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "success" && "border-success/20 bg-success/5 text-success",
(!variant || variant === "default") &&
"border-border/50 bg-card/50 text-foreground/80",
)}
>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
);
}
function FlagBadge({
label,
enabled,
}: {
label: string;
enabled: boolean | undefined;
}) {
return (
<Badge
variant={enabled ? "secondary" : "outline"}
className={cn(
"text-[10px] px-1.5 py-0 font-mono",
enabled ? "border-success/30 text-success" : "text-muted-foreground",
)}
>
{label}: {enabled ? "on" : "off"}
</Badge>
);
}
function SkillBadgeList({
label,
skills,
}: {
label: string;
skills: string[] | undefined;
}) {
if (!skills?.length) return null;
return (
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground">{label}</span>
<div className="flex flex-wrap gap-1">
{skills.map((skill) => (
<Badge
key={skill}
variant="outline"
className="text-[10px] px-1.5 py-0 font-mono"
>
{skill}
</Badge>
))}
</div>
</div>
);
}
function ModelBadgeList({
models,
}: {
models: Record<string, string> | undefined;
}) {
if (!models || Object.keys(models).length === 0) return null;
return (
<div className="space-y-1">
<span className="text-[11px] text-muted-foreground">Phase Models</span>
<div className="flex flex-wrap gap-1">
{Object.entries(models)
.sort(([a], [b]) => a.localeCompare(b))
.map(([phase, model]) => (
<Badge
key={phase}
variant="outline"
className="text-[10px] px-1.5 py-0 font-mono"
>
{phase}: {model}
</Badge>
))}
</div>
</div>
);
}
function KvRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 text-xs">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-foreground/80 text-right truncate">{children}</span>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// HOOK: shared settings data access
// ═══════════════════════════════════════════════════════════════════════
function useSettingsData() {
const workspace = useSFWorkspaceState();
const { loadSettingsData } = useSFWorkspaceActions();
const state = workspace.commandSurface.settingsData;
return {
state,
data: state.data as SettingsData | null,
busy: state.phase === "loading",
refresh: () => void loadSettingsData(),
};
}
// ═══════════════════════════════════════════════════════════════════════
// PREFS PANEL
// ═══════════════════════════════════════════════════════════════════════
function tokenProfileVariant(
profile: string | undefined,
): "info" | "warning" | "success" {
if (profile === "budget") return "warning";
if (profile === "quality") return "success";
return "info";
}
export function PrefsPanel() {
const { state, data, busy, refresh } = useSettingsData();
const prefs = data?.preferences ?? null;
return (
<div className="space-y-4" data-testid="settings-prefs">
<SettingsHeader
title="Effective Preferences"
icon={<Settings className="h-3.5 w-3.5" />}
subtitle={prefs ? `${prefs.scope} scope` : null}
onRefresh={refresh}
refreshing={busy}
/>
{state.error && <SettingsError message={state.error} />}
{busy && !data && <SettingsLoading label="Loading preferences…" />}
{data && !prefs && <SettingsEmpty message="No preferences file found" />}
{prefs && (
<>
{/* Core mode & profile */}
<div className="flex flex-wrap gap-2">
<Pill label="Mode" value={prefs.mode ?? "solo"} variant="info" />
<Pill
label="Token Profile"
value={prefs.tokenProfile ?? "balanced"}
variant={tokenProfileVariant(prefs.tokenProfile)}
/>
{prefs.customInstructions?.length ? (
<Pill
label="Custom Instructions"
value={prefs.customInstructions.length}
/>
) : null}
</div>
{/* Skills */}
<div className="space-y-2">
<ModelBadgeList models={prefs.models} />
<SkillBadgeList label="Always use" skills={prefs.alwaysUseSkills} />
<SkillBadgeList label="Prefer" skills={prefs.preferSkills} />
<SkillBadgeList label="Avoid" skills={prefs.avoidSkills} />
{!prefs.models || Object.keys(prefs.models).length === 0
? !prefs.alwaysUseSkills?.length &&
!prefs.preferSkills?.length &&
!prefs.avoidSkills?.length && (
<span className="text-[11px] text-muted-foreground">
No model or skill preferences configured
</span>
)
: !prefs.alwaysUseSkills?.length &&
!prefs.preferSkills?.length &&
!prefs.avoidSkills?.length && (
<span className="text-[11px] text-muted-foreground">
No skill preferences configured
</span>
)}
</div>
{/* Toggles */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 rounded-lg border border-border/50 bg-card/50 px-3 py-2.5">
<KvRow label="Auto-Supervisor">
{prefs.autoSupervisor?.enabled ? (
<span className="text-success">
on
{prefs.autoSupervisor.softTimeoutMinutes != null &&
` (${prefs.autoSupervisor.softTimeoutMinutes}m)`}
</span>
) : (
<span className="text-muted-foreground">off</span>
)}
</KvRow>
<KvRow label="UAT Dispatch">
<span
className={
prefs.uatDispatch ? "text-success" : "text-muted-foreground"
}
>
{prefs.uatDispatch ? "on" : "off"}
</span>
</KvRow>
<KvRow label="Auto-Visualize">
<span
className={
prefs.autoVisualize ? "text-success" : "text-muted-foreground"
}
>
{prefs.autoVisualize ? "on" : "off"}
</span>
</KvRow>
<KvRow label="Preference Scope">
<span className="font-mono text-[10px]">{prefs.scope}</span>
</KvRow>
</div>
{/* Source file */}
<div className="text-[11px] text-muted-foreground truncate font-mono">
Source: {prefs.path}
</div>
{/* Warnings */}
{prefs.warnings && prefs.warnings.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-xs text-warning">
<AlertTriangle className="h-3 w-3" />
<span className="font-medium">
Warnings ({prefs.warnings.length})
</span>
</div>
{prefs.warnings.map((warning, i) => (
<div
key={i}
className="rounded border border-warning/20 bg-warning/5 px-2.5 py-1.5 text-[11px] text-warning"
>
{warning}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// MODEL ROUTING PANEL
// ═══════════════════════════════════════════════════════════════════════
function topPatterns(
history: SettingsRoutingHistory,
max = 5,
): Array<{ name: string; total: number; pattern: SettingsPatternHistory }> {
return Object.entries(history.patterns)
.map(([name, pattern]) => {
const total =
pattern.light.success +
pattern.light.fail +
pattern.standard.success +
pattern.standard.fail +
pattern.heavy.success +
pattern.heavy.fail;
return { name, total, pattern };
})
.sort((a, b) => b.total - a.total)
.slice(0, max);
}
function TierModelRow({
tier,
modelId,
}: {
tier: string;
modelId: string | undefined;
}) {
return (
<div className="flex items-center justify-between gap-4 text-xs">
<span className="text-muted-foreground capitalize">{tier}</span>
<span className="font-mono text-[11px] text-foreground/80 truncate max-w-[200px]">
{modelId ?? (
<span className="text-muted-foreground italic">default</span>
)}
</span>
</div>
);
}
function TierOutcomeBadge({
tier,
success,
fail,
}: {
tier: string;
success: number;
fail: number;
}) {
const total = success + fail;
if (total === 0) return null;
return (
<Badge
variant="outline"
className={cn(
"text-[10px] px-1.5 py-0 font-mono",
fail > 0
? "border-destructive/20 text-destructive"
: "text-muted-foreground",
)}
>
{tier}: {success}{fail > 0 && <span> {fail}</span>}
</Badge>
);
}
export function ModelRoutingPanel() {
const { state, data, busy, refresh } = useSettingsData();
const routingConfig = data?.routingConfig ?? null;
const routingHistory = data?.routingHistory ?? null;
return (
<div className="space-y-4" data-testid="settings-model-routing">
<SettingsHeader
title="Model Routing"
icon={<Cpu className="h-3.5 w-3.5" />}
onRefresh={refresh}
refreshing={busy}
/>
{state.error && <SettingsError message={state.error} />}
{busy && !data && <SettingsLoading label="Loading routing config…" />}
{data && (
<>
{/* Dynamic routing status */}
<div className="flex items-center gap-2">
<Badge
variant={routingConfig?.enabled ? "secondary" : "outline"}
className={cn(
"text-[10px] px-2 py-0.5",
routingConfig?.enabled
? "border-success/30 text-success"
: "text-muted-foreground",
)}
>
Dynamic Routing: {routingConfig?.enabled ? "enabled" : "disabled"}
</Badge>
</div>
{/* Tier assignments */}
{routingConfig?.tier_models && (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Tier Assignments
</h4>
<TierModelRow
tier="light"
modelId={routingConfig.tier_models.light}
/>
<TierModelRow
tier="standard"
modelId={routingConfig.tier_models.standard}
/>
<TierModelRow
tier="heavy"
modelId={routingConfig.tier_models.heavy}
/>
</div>
)}
{/* Routing flags */}
<div className="flex flex-wrap gap-1.5">
<FlagBadge
label="escalate_on_failure"
enabled={routingConfig?.escalate_on_failure}
/>
<FlagBadge
label="budget_pressure"
enabled={routingConfig?.budget_pressure}
/>
<FlagBadge
label="cross_provider"
enabled={routingConfig?.cross_provider}
/>
<FlagBadge label="hooks" enabled={routingConfig?.hooks} />
</div>
{/* Routing history */}
{routingHistory ? (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<Pill
label="Patterns"
value={Object.keys(routingHistory.patterns).length}
/>
<Pill label="Feedback" value={routingHistory.feedback.length} />
</div>
{/* Top patterns table */}
{Object.keys(routingHistory.patterns).length > 0 && (
<div className="space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground">
Top Patterns
</h4>
<div className="space-y-2">
{topPatterns(routingHistory).map(
({ name, total, pattern }) => (
<div
key={name}
className="rounded-lg border border-border/50 bg-card/50 px-3 py-2 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono text-foreground/80 truncate">
{name}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{total} attempts
</span>
</div>
<div className="flex flex-wrap gap-1">
<TierOutcomeBadge
tier="L"
success={pattern.light.success}
fail={pattern.light.fail}
/>
<TierOutcomeBadge
tier="S"
success={pattern.standard.success}
fail={pattern.standard.fail}
/>
<TierOutcomeBadge
tier="H"
success={pattern.heavy.success}
fail={pattern.heavy.fail}
/>
</div>
</div>
),
)}
</div>
</div>
)}
</div>
) : (
<SettingsEmpty message="No routing history yet" />
)}
</>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// BUDGET PANEL
// ═══════════════════════════════════════════════════════════════════════
function enforcementVariant(
enforcement: string | undefined,
): "info" | "warning" | "success" {
if (enforcement === "halt") return "warning";
if (enforcement === "pause") return "info";
return "success";
}
function formatChars(chars: number): string {
if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`;
if (chars >= 1_000) return `${Math.round(chars / 1_000)}K`;
return String(chars);
}
export function BudgetPanel() {
const { state, data, busy, refresh } = useSettingsData();
const prefs = data?.preferences ?? null;
const budget = data?.budgetAllocation ?? null;
const totals = data?.projectTotals ?? null;
return (
<div className="space-y-4" data-testid="settings-budget">
<SettingsHeader
title="Budget & Costs"
icon={<DollarSign className="h-3.5 w-3.5" />}
onRefresh={refresh}
refreshing={busy}
/>
{state.error && <SettingsError message={state.error} />}
{busy && !data && <SettingsLoading label="Loading budget data…" />}
{data && (
<>
{/* Budget controls */}
<div className="flex flex-wrap gap-2">
<Pill
label="Ceiling"
value={
prefs?.budgetCeiling != null
? formatCost(prefs.budgetCeiling)
: "Not set"
}
variant={prefs?.budgetCeiling != null ? "warning" : "default"}
/>
<Pill
label="Enforcement"
value={prefs?.budgetEnforcement ?? "Not set"}
variant={
prefs?.budgetEnforcement
? enforcementVariant(prefs.budgetEnforcement)
: "default"
}
/>
<Pill
label="Token Profile"
value={prefs?.tokenProfile ?? "balanced"}
variant={tokenProfileVariant(prefs?.tokenProfile)}
/>
</div>
{/* Context budget allocations */}
{budget && (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Context Budget Allocations
</h4>
<KvRow label="Summary Budget">
{formatChars(budget.summaryBudgetChars)} chars
</KvRow>
<KvRow label="Inline Context">
{formatChars(budget.inlineContextBudgetChars)} chars
</KvRow>
<KvRow label="Verification">
{formatChars(budget.verificationBudgetChars)} chars
</KvRow>
<KvRow label="Task Count Range">
{budget.taskCountRange.min}{budget.taskCountRange.max}
</KvRow>
<KvRow label="Continue Threshold">
{budget.continueThresholdPercent}%
</KvRow>
</div>
)}
{/* Project cost totals */}
{totals ? (
<div className="space-y-3">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Project Cost Totals
</h4>
{/* Summary pills */}
<div className="flex flex-wrap gap-2">
<Pill label="Units" value={totals.units} />
<Pill
label="Total Cost"
value={formatCost(totals.cost)}
variant="warning"
/>
<Pill
label="Duration"
value={`${Math.round(totals.duration / 1000)}s`}
/>
</div>
{/* Token breakdown */}
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1.5">
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Token Breakdown
</h4>
<KvRow label="Input">{formatTokens(totals.tokens.input)}</KvRow>
<KvRow label="Output">
{formatTokens(totals.tokens.output)}
</KvRow>
<KvRow label="Cache Read">
{formatTokens(totals.tokens.cacheRead)}
</KvRow>
<KvRow label="Cache Write">
{formatTokens(totals.tokens.cacheWrite)}
</KvRow>
<KvRow label="Total">{formatTokens(totals.tokens.total)}</KvRow>
</div>
{/* Interaction counts */}
<div className="flex flex-wrap gap-2">
<Pill label="Tool Calls" value={totals.toolCalls} />
<Pill label="Assistant Msgs" value={totals.assistantMessages} />
<Pill label="User Msgs" value={totals.userMessages} />
</div>
</div>
) : (
<SettingsEmpty message="No execution metrics yet" />
)}
</>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// REMOTE QUESTIONS PANEL (Integrations tab)
// ═══════════════════════════════════════════════════════════════════════
type RemoteChannel = "slack" | "discord" | "telegram";
const CHANNEL_OPTIONS: {
value: RemoteChannel;
label: string;
description: string;
idPlaceholder: string;
}[] = [
{
value: "slack",
label: "Slack",
description: "Get pinged in a Slack channel",
idPlaceholder: "Channel ID (e.g. C01ABCD2EFG)",
},
{
value: "discord",
label: "Discord",
description: "Get pinged in a Discord channel",
idPlaceholder: "Channel ID (1720 digit number)",
},
{
value: "telegram",
label: "Telegram",
description: "Get pinged via Telegram bot",
idPlaceholder: "Chat ID (numeric, may start with -)",
},
];
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
slack: /^[A-Z0-9]{9,12}$/,
discord: /^\d{17,20}$/,
telegram: /^-?\d{5,20}$/,
};
interface RemoteQuestionsApiResponse {
config: {
channel: RemoteChannel;
channelId: string;
timeoutMinutes: number;
pollIntervalSeconds: number;
} | null;
envVarSet: boolean;
tokenSet: boolean;
envVarName: string | null;
status: string;
error?: string;
}
export function RemoteQuestionsPanel() {
const { data, busy, refresh } = useSettingsData();
const existingConfig = data?.preferences?.remoteQuestions ?? null;
const [_envVarSet, setEnvVarSet] = useState(false);
const [envVarName, setEnvVarName] = useState<string | null>(null);
const [apiLoading, setApiLoading] = useState(true);
const [tokenSet, setTokenSet] = useState(false);
const [channel, setChannel] = useState<RemoteChannel>("slack");
const [channelId, setChannelId] = useState("");
const [timeoutMinutes, setTimeoutMinutes] = useState(5);
const [pollIntervalSeconds, setPollIntervalSeconds] = useState(5);
const [botToken, setBotToken] = useState("");
const [showToken, setShowToken] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [tokenSuccess, setTokenSuccess] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isConfigured, setIsConfigured] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const fetchApiStatus = useCallback(async () => {
try {
setApiLoading(true);
const res = await authFetch("/api/remote-questions", {
cache: "no-store",
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: "Unknown error" }));
setError(body.error ?? `API error ${res.status}`);
return;
}
const json: RemoteQuestionsApiResponse = await res.json();
setEnvVarSet(json.envVarSet);
setEnvVarName(json.envVarName);
setTokenSet(json.tokenSet);
setIsConfigured(json.status === "configured" && json.config !== null);
if (json.config) {
setChannel(json.config.channel);
setChannelId(json.config.channelId);
setTimeoutMinutes(json.config.timeoutMinutes);
setPollIntervalSeconds(json.config.pollIntervalSeconds);
}
} catch (e) {
setError(
e instanceof Error
? e.message
: "Failed to fetch remote questions status",
);
} finally {
setApiLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch, setState runs after await
void fetchApiStatus();
}, [fetchApiStatus]);
useEffect(() => {
if (existingConfig?.channel) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local state from loaded config
setChannel(existingConfig.channel);
setChannelId(existingConfig.channelId ?? "");
setTimeoutMinutes(existingConfig.timeoutMinutes ?? 5);
setPollIntervalSeconds(existingConfig.pollIntervalSeconds ?? 5);
}
}, [existingConfig]);
const channelIdValid =
channelId.trim().length > 0 &&
CHANNEL_ID_PATTERNS[channel].test(channelId.trim());
const canSave = channelIdValid && !saving && !deleting;
useEffect(() => {
if (!success) return;
const timer = setTimeout(() => setSuccess(null), 3000);
return () => clearTimeout(timer);
}, [success]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
const res = await authFetch("/api/remote-questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
channel,
channelId: channelId.trim(),
timeoutMinutes,
pollIntervalSeconds,
}),
});
const json = await res.json();
if (!res.ok) {
setError(json.error ?? `Save failed (${res.status})`);
return;
}
setSuccess("Configuration saved");
setIsConfigured(true);
refresh();
void fetchApiStatus();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save configuration");
} finally {
setSaving(false);
}
};
const handleDisconnect = async () => {
setDeleting(true);
setError(null);
setSuccess(null);
try {
const res = await authFetch("/api/remote-questions", {
method: "DELETE",
});
const json = await res.json();
if (!res.ok) {
setError(json.error ?? `Disconnect failed (${res.status})`);
return;
}
setSuccess("Channel disconnected");
setIsConfigured(false);
setChannelId("");
setTimeoutMinutes(5);
setPollIntervalSeconds(5);
setChannel("slack");
setTokenSet(false);
refresh();
void fetchApiStatus();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to disconnect channel");
} finally {
setDeleting(false);
}
};
const handleSaveToken = async () => {
if (!botToken.trim()) return;
setSavingToken(true);
setError(null);
setTokenSuccess(null);
try {
const res = await authFetch("/api/remote-questions", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel, token: botToken.trim() }),
});
const json = await res.json();
if (!res.ok) {
setError(json.error ?? `Token save failed (${res.status})`);
return;
}
setTokenSuccess(`Token saved (${json.masked})`);
setTokenSet(true);
setBotToken("");
setShowToken(false);
void fetchApiStatus();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save token");
} finally {
setSavingToken(false);
}
};
useEffect(() => {
if (!tokenSuccess) return;
const timer = setTimeout(() => setTokenSuccess(null), 3000);
return () => clearTimeout(timer);
}, [tokenSuccess]);
const derivedEnvVarName = envVarName ?? `${channel.toUpperCase()}_BOT_TOKEN`;
const selectedChannelOption = CHANNEL_OPTIONS.find(
(o) => o.value === channel,
)!;
if ((busy || apiLoading) && !data && !isConfigured) {
return (
<div className="space-y-5" data-testid="settings-remote-questions">
<SettingsHeader
title="Integrations"
icon={<Radio className="h-3.5 w-3.5" />}
subtitle="Remote notifications"
onRefresh={() => {
refresh();
void fetchApiStatus();
}}
refreshing
/>
<SettingsLoading label="Loading integration status…" />
</div>
);
}
return (
<div className="space-y-5" data-testid="settings-remote-questions">
<SettingsHeader
title="Integrations"
icon={<Radio className="h-3.5 w-3.5" />}
subtitle="Remote notifications"
onRefresh={() => {
refresh();
void fetchApiStatus();
}}
refreshing={busy || apiLoading}
/>
{/* Intro */}
<p className="text-xs leading-relaxed text-muted-foreground">
Connect a chat channel so the agent pings you when it needs input
instead of waiting silently.
</p>
{/* Feedback banners */}
{error && <SettingsError message={error} />}
{success && (
<div className="flex items-center gap-2.5 rounded-xl border border-success/15 bg-success/[0.04] px-4 py-3 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 shrink-0 text-success" />
{success}
</div>
)}
{/* ── Connected state banner ───────────────────────────────── */}
{isConfigured && (
<div className="rounded-xl border border-success/15 bg-success/[0.04] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-success/20 bg-success/10">
<CheckCircle2 className="h-4.5 w-4.5 text-success" />
</div>
<div>
<div className="text-sm font-medium text-foreground">
Connected to {selectedChannelOption.label}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground">
{channelId}
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleDisconnect()}
disabled={deleting}
className="h-7 text-xs text-destructive/70 hover:text-destructive"
>
{deleting ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
"Disconnect"
)}
</Button>
</div>
<div className="mt-3 flex gap-4 border-t border-success/10 pt-3 text-[11px] text-muted-foreground">
<span>Timeout: {timeoutMinutes}m</span>
<span>Poll: {pollIntervalSeconds}s</span>
</div>
</div>
)}
{/* ── Channel picker (card-based) ──────────────────────────── */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{isConfigured ? "Switch channel" : "Choose a channel"}
</div>
<div className="grid grid-cols-3 gap-2">
{CHANNEL_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setChannel(opt.value);
setError(null);
}}
disabled={saving}
className={cn(
"rounded-xl border px-3 py-3 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.97]",
channel === opt.value
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
)}
>
<div className="text-sm font-medium text-foreground">
{opt.label}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{opt.description}
</div>
</button>
))}
</div>
</div>
{/* ── Channel ID input ─────────────────────────────────────── */}
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Channel ID
</div>
<input
type="text"
value={channelId}
onChange={(e) => {
setChannelId(e.target.value);
if (error) setError(null);
}}
placeholder={selectedChannelOption.idPlaceholder}
disabled={saving}
className={cn(
"w-full rounded-xl border bg-card/50 px-4 py-2.5 font-mono text-sm text-foreground",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
"transition-colors",
channelId.trim().length > 0 &&
!CHANNEL_ID_PATTERNS[channel].test(channelId.trim())
? "border-destructive/40"
: "border-border/50",
)}
onKeyDown={(e) => {
if (e.key === "Enter" && canSave) void handleSave();
}}
/>
{channelId.trim().length > 0 &&
!CHANNEL_ID_PATTERNS[channel].test(channelId.trim()) && (
<p className="text-[11px] text-destructive/70">
Doesn&apos;t match the expected format for{" "}
{selectedChannelOption.label}
</p>
)}
</div>
{/* ── Advanced (collapsed by default) ──────────────────────── */}
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-muted-foreground transition-colors"
>
<svg
aria-hidden="true"
className={cn(
"h-3 w-3 transition-transform",
showAdvanced && "rotate-90",
)}
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6 4l4 4-4 4" />
</svg>
Advanced settings
</button>
{showAdvanced && (
<div className="grid grid-cols-2 gap-3 pl-4">
<div className="space-y-1.5">
<label
className="text-[11px] text-muted-foreground"
htmlFor="rq-timeout"
>
Timeout (min)
</label>
<input
id="rq-timeout"
type="number"
min={1}
max={30}
value={timeoutMinutes}
onChange={(e) =>
setTimeoutMinutes(
Math.max(1, Math.min(30, Number(e.target.value) || 1)),
)
}
className="w-full rounded-lg border border-border/50 bg-card/50 px-3 py-2 text-xs text-foreground tabular-nums focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label
className="text-[11px] text-muted-foreground"
htmlFor="rq-poll"
>
Poll interval (sec)
</label>
<input
id="rq-poll"
type="number"
min={2}
max={30}
value={pollIntervalSeconds}
onChange={(e) =>
setPollIntervalSeconds(
Math.max(2, Math.min(30, Number(e.target.value) || 2)),
)
}
className="w-full rounded-lg border border-border/50 bg-card/50 px-3 py-2 text-xs text-foreground tabular-nums focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
)}
{/* ── Save button ──────────────────────────────────────────── */}
{channelId.trim().length > 0 && (
<Button
type="button"
onClick={() => void handleSave()}
disabled={!canSave}
className="gap-2 transition-transform active:scale-[0.96]"
>
{saving ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{isConfigured ? "Update connection" : "Save & connect"}
</Button>
)}
{/* ── Bot token ─────────────────────────────────────────── */}
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">
Bot token
</div>
{tokenSuccess && (
<div className="flex items-center gap-2.5 rounded-xl border border-success/15 bg-success/[0.04] px-4 py-2.5 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
{tokenSuccess}
</div>
)}
{tokenSet && !tokenSuccess && (
<div className="flex items-center gap-2.5 rounded-xl border border-success/15 bg-success/[0.04] px-4 py-2.5 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
<span className="font-mono text-[11px]">{derivedEnvVarName}</span>{" "}
is configured
</div>
)}
{!tokenSet && (
<div className="flex items-center gap-2.5 rounded-xl border border-warning/15 bg-warning/[0.04] px-4 py-2.5 text-xs text-muted-foreground">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<span>
<span className="font-mono text-[11px]">{derivedEnvVarName}</span>{" "}
not configured
</span>
</div>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showToken ? "text" : "password"}
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder={`Paste your ${selectedChannelOption.label} bot token`}
disabled={savingToken}
className={cn(
"w-full rounded-xl border border-border/50 bg-card/50 pl-4 pr-10 py-2.5 font-mono text-sm text-foreground",
"placeholder:text-muted-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
"transition-colors",
)}
onKeyDown={(e) => {
if (e.key === "Enter" && botToken.trim())
void handleSaveToken();
}}
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground transition-colors"
>
{showToken ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
<Button
type="button"
size="sm"
onClick={() => void handleSaveToken()}
disabled={!botToken.trim() || savingToken}
className="h-[42px] gap-1.5 px-4"
>
{savingToken ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<KeyRound className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// GENERAL PANEL (font sizes)
// ═══════════════════════════════════════════════════════════════════════
const TERMINAL_SIZE_PRESETS = [11, 12, 13, 14, 15, 16] as const;
const EDITOR_SIZE_PRESETS = [11, 12, 13, 14, 15, 16] as const;
function FontSizeControl({
label,
description,
presets,
defaultSize,
currentSize,
onChange,
previewFont,
}: {
label: string;
description: string;
presets: readonly number[];
defaultSize: number;
currentSize: number;
onChange: (size: number) => void;
previewFont: "mono" | "sans";
}) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-3 space-y-3">
<div>
<div className="text-xs font-medium text-foreground">{label}</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{description}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{presets.map((size) => (
<button
key={size}
type="button"
onClick={() => onChange(size)}
className={cn(
"rounded-md border px-3 py-1.5 text-xs font-medium tabular-nums transition-colors",
currentSize === size
? "border-foreground/30 bg-foreground/10 text-foreground shadow-sm"
: "border-border/50 bg-card/50 text-muted-foreground hover:border-foreground/20 hover:text-foreground",
)}
>
{size}px
{size === defaultSize && (
<span className="ml-1 text-[10px] text-muted-foreground">
(default)
</span>
)}
</button>
))}
</div>
<div
className={cn(
"mt-2 rounded-md border border-border/50 bg-terminal px-3 py-2 text-foreground/80",
previewFont === "mono" ? "font-mono" : "font-sans",
)}
style={{ fontSize: `${currentSize}px`, lineHeight: 1.35 }}
>
The quick brown fox jumps over the lazy dog
</div>
</div>
);
}
export function GeneralPanel() {
const [terminalFontSize, setTerminalFontSize] = useTerminalFontSize();
const [editorFontSize, setEditorFontSize] = useEditorFontSize();
return (
<div className="space-y-5" data-testid="settings-general">
<SettingsHeader
title="General"
icon={<SlidersHorizontal className="h-3.5 w-3.5" />}
subtitle="Appearance & behavior"
onRefresh={() => {}}
refreshing={false}
/>
<FontSizeControl
label="Terminal font size"
description="Applies to all terminals and the chat mode interface"
presets={TERMINAL_SIZE_PRESETS}
defaultSize={13}
currentSize={terminalFontSize}
onChange={setTerminalFontSize}
previewFont="mono"
/>
<FontSizeControl
label="Code font size"
description="Applies to the file viewer and code editor"
presets={EDITOR_SIZE_PRESETS}
defaultSize={14}
currentSize={editorFontSize}
onChange={setEditorFontSize}
previewFont="mono"
/>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// EXPERIMENTAL PANEL
// ═══════════════════════════════════════════════════════════════════════
interface ExperimentalFlag {
key: string;
label: string;
description: string;
warning?: string;
}
const EXPERIMENTAL_FLAGS: ExperimentalFlag[] = [
{
key: "rtk",
label: "RTK Shell Compression",
description:
"Wraps shell commands through the RTK binary to reduce token usage during command execution. RTK is downloaded automatically on first use.",
warning: "Experimental — may change or be removed without notice.",
},
];
export function ExperimentalPanel() {
const { state, data, busy, refresh } = useSettingsData();
const prefs = data?.preferences ?? null;
const [flags, setFlags] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [saveError, setSaveError] = useState<string | null>(null);
// Trigger a settings load if data hasn't been fetched yet (e.g. navigating
// directly to the Experimental tab without going through sf-prefs first).
useEffect(() => {
if (!data && !busy && state.phase === "idle") {
refresh();
}
}, [state.phase, refresh, data, busy]);
// Sync local state from loaded prefs
useEffect(() => {
if (!prefs) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local copy when prefs load
setFlags({ rtk: prefs.experimental?.rtk === true });
}, [prefs]);
async function toggle(flagKey: string, next: boolean) {
setSaving((s) => ({ ...s, [flagKey]: true }));
setSaveError(null);
try {
const res = await authFetch("/api/experimental", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flag: flagKey, enabled: next }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
setFlags((f) => ({ ...f, [flagKey]: next }));
// Refresh settings data so PrefsPanel reflects the change
refresh();
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving((s) => ({ ...s, [flagKey]: false }));
}
}
return (
<div className="space-y-4" data-testid="settings-experimental">
<SettingsHeader
title="Experimental"
icon={<FlaskConical className="h-3.5 w-3.5" />}
subtitle="Opt-in features — may change without notice"
onRefresh={refresh}
refreshing={busy}
/>
{state.error && <SettingsError message={state.error} />}
{saveError && <SettingsError message={saveError} />}
{busy && !data && <SettingsLoading label="Loading preferences…" />}
<div className="space-y-3">
{EXPERIMENTAL_FLAGS.map((flag) => {
const enabled = flags[flag.key] ?? false;
const isSaving = saving[flag.key] ?? false;
return (
<div
key={flag.key}
className="rounded-lg border border-border/50 bg-card/50 px-3 py-3 space-y-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">
{flag.label}
</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[10px] font-medium",
enabled
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground",
)}
>
{enabled ? "on" : "off"}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{flag.description}
</p>
{flag.warning && (
<div className="flex items-center gap-1 text-[10px] text-warning">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{flag.warning}</span>
</div>
)}
</div>
<button
type="button"
onClick={() => toggle(flag.key, !enabled)}
disabled={isSaving || busy || !data}
className={cn(
"shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed",
enabled ? "bg-success" : "bg-muted-foreground/30",
)}
role="switch"
aria-checked={enabled}
aria-label={`Toggle ${flag.label}`}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-4" : "translate-x-0.5",
)}
/>
{isSaving && (
<span className="absolute inset-0 flex items-center justify-center">
<LoaderCircle className="h-3 w-3 animate-spin text-white" />
</span>
)}
</button>
</div>
</div>
);
})}
</div>
{data && (
<p className="text-[11px] text-muted-foreground">
Changes are written to{" "}
<span className="font-mono">
{prefs?.path ?? "~/.sf/PREFERENCES.md"}
</span>{" "}
and take effect on the next session.
</p>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════
// LEGACY EXPORTS
// ═══════════════════════════════════════════════════════════════════════
// Legacy exports for backward compatibility with sf-prefs mega-scroll
export const TerminalSizePanel = GeneralPanel;
export const EditorSizePanel = () => null;