diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index 0506c37..e632b45 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -54,6 +54,24 @@ beforeEach(() => {
vi.mocked(api.getTask).mockResolvedValue({ task: task({ title: "Detailed task" }) });
vi.mocked(api.settings).mockResolvedValue(settings());
vi.mocked(api.providerStatus).mockResolvedValue({ providers: [providerStatus()] });
+ vi.mocked(api.openAiAuth).mockResolvedValue({ configured: false, login: { phase: "idle" } });
+ vi.mocked(api.openAiAccountInfo).mockResolvedValue({
+ configured: false,
+ prefetchedAt: "2026-05-02T00:00:00.000Z",
+ recommendedModel: "gpt-5.5",
+ currentModel: "gpt-5.5",
+ currentModelSupported: true,
+ models: [],
+ });
+ vi.mocked(api.startOpenAiAuth).mockResolvedValue({ configured: false, login: { phase: "idle" } });
+ vi.mocked(api.submitOpenAiAuthInput).mockResolvedValue({
+ configured: false,
+ login: { phase: "idle" },
+ });
+ vi.mocked(api.logoutOpenAiAuth).mockResolvedValue({
+ configured: false,
+ login: { phase: "idle" },
+ });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -104,6 +122,29 @@ describe("App task detail routing", () => {
});
});
+describe("App settings view", () => {
+ it("hides board actions and uses settings-specific copy", async () => {
+ await renderApp();
+
+ await waitFor(() =>
+ expect(
+ document.body.querySelector('[role="button"][aria-label="Open task Route task"]'),
+ ).toBeTruthy(),
+ );
+ await clickButtonByText("Settings");
+
+ await waitFor(() => {
+ expect(document.body.textContent).toContain(
+ "Connect OpenAI and manage local board preferences.",
+ );
+ expect(document.body.textContent).toContain("OpenAI connection");
+ });
+
+ expect(document.body.querySelector('[aria-label="Search tasks"]')).toBeNull();
+ expect(document.body.querySelector('button[aria-label="New task"]')).toBeNull();
+ });
+});
+
async function renderApp() {
await act(async () => {
root.render( );
@@ -118,6 +159,16 @@ async function clickSelector(selector: string) {
});
}
+async function clickButtonByText(text: string) {
+ const button = Array.from(document.body.querySelectorAll("button")).find((entry) =>
+ entry.textContent?.includes(text),
+ );
+ expect(button).toBeTruthy();
+ await act(async () => {
+ button!.click();
+ });
+}
+
async function waitFor(assertion: () => void) {
let lastError: unknown;
for (let index = 0; index < 120; index += 1) {
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 8a36a34..01652be 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -89,7 +89,7 @@ const VIEW_META: Record = {
},
settings: {
title: "Settings",
- description: "Manage focus areas for your local board.",
+ description: "Connect OpenAI and manage local board preferences.",
},
};
@@ -543,6 +543,7 @@ export function App() {
() => getVisibleStatuses(boardScope, filteredTasks),
[boardScope, filteredTasks],
);
+ const showBoardHeaderActions = workspaceMode === "board" && view === "board";
useEffect(() => {
if (activeFocusAreaId && !focusAreas.some((area) => area.id === activeFocusAreaId)) {
@@ -597,7 +598,7 @@ export function App() {
{workspaceMode === "chat" ? "Chat" : activeView.title}
- {workspaceMode === "board" && (
+ {showBoardHeaderActions && (
{openAiReady ? "OpenAI ready" : "Local board"}
@@ -611,7 +612,7 @@ export function App() {
- {workspaceMode === "board" && (
+ {showBoardHeaderActions && (
<>
@@ -650,7 +651,7 @@ export function App() {
- {workspaceMode === "board" && (
+ {showBoardHeaderActions && (
diff --git a/src/client/components/SettingsView.test.tsx b/src/client/components/SettingsView.test.tsx
index e7fcd02..5b9e572 100644
--- a/src/client/components/SettingsView.test.tsx
+++ b/src/client/components/SettingsView.test.tsx
@@ -53,7 +53,9 @@ afterEach(() => {
describe("SettingsView", () => {
it("saves focus area changes", async () => {
- const onSave = vi.fn(async (_nextSettings: AppSettings) => undefined);
+ const onSave = vi.fn(
+ async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
+ );
await act(async () => {
root.render(
@@ -77,6 +79,8 @@ describe("SettingsView", () => {
);
});
+ await clickButtonByText("Board");
+
expect(container.textContent).toContain("Board settings");
expect(container.textContent).toContain("Focus areas");
@@ -101,9 +105,10 @@ describe("SettingsView", () => {
label: "Clients",
color: "blue",
});
+ expect(onSave.mock.calls[0][1]).toEqual([]);
});
- it("labels settings select triggers for assistive technology", async () => {
+ it("labels settings controls for assistive technology", async () => {
await act(async () => {
root.render(
{
);
});
- expect(container.querySelector('button[aria-label="Authentication"]')).toBeTruthy();
+ expect(container.querySelector('[aria-label="Authentication method"]')).toBeTruthy();
+ expect(container.textContent).toContain("OpenAI account");
+ expect(container.textContent).toContain("API key");
expect(container.querySelector('button[aria-label="Model"]')).toBeTruthy();
- expect(container.querySelector('button[aria-label="Personal color"]')).toBeTruthy();
expect(container.textContent).toContain("Connected as ...abc123.");
expect(container.textContent).not.toContain("00000000-0000-0000");
+
+ await clickButtonByText("Board");
+
+ expect(container.querySelector('button[aria-label="Personal color"]')).toBeTruthy();
});
it("saves advanced OpenAI request settings", async () => {
@@ -208,28 +218,34 @@ describe("SettingsView", () => {
);
});
- const maxTokensInput = container.querySelector("#openai-max-tokens");
- const timeoutInput = container.querySelector("#openai-timeout");
const orgInput = container.querySelector("#openai-organization");
const projectInput = container.querySelector("#openai-project");
- expect(maxTokensInput).toBeTruthy();
- expect(timeoutInput).toBeTruthy();
expect(orgInput).toBeTruthy();
expect(projectInput).toBeTruthy();
await act(async () => {
- setInputValue(maxTokensInput!, "8192");
- maxTokensInput!.dispatchEvent(new Event("input", { bubbles: true }));
- setInputValue(timeoutInput!, "90000");
- timeoutInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(orgInput!, "org_saved");
orgInput!.dispatchEvent(new Event("input", { bubbles: true }));
setInputValue(projectInput!, "proj_saved");
projectInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
+ await clickButtonByText("Advanced");
+
+ const maxTokensInput = container.querySelector("#openai-max-tokens");
+ const timeoutInput = container.querySelector("#openai-timeout");
+ expect(maxTokensInput).toBeTruthy();
+ expect(timeoutInput).toBeTruthy();
+
+ await act(async () => {
+ setInputValue(maxTokensInput!, "8192");
+ maxTokensInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ setInputValue(timeoutInput!, "90000");
+ timeoutInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
const saveButton = Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Save OpenAI settings"),
+ button.textContent?.includes("Save advanced settings"),
);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -245,8 +261,145 @@ describe("SettingsView", () => {
projectId: "proj_saved",
});
});
+
+ it("blocks invalid advanced numeric values", async () => {
+ const onSave = vi.fn(
+ async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
+ );
+
+ await act(async () => {
+ root.render(
+ emptyAuthState)}
+ onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ onGetOpenAiAccountInfo={vi.fn(async () => ({
+ configured: false,
+ prefetchedAt: "2026-05-02T00:00:00.000Z",
+ recommendedModel: "gpt-5.5",
+ currentModel: "gpt-5.5",
+ currentModelSupported: true,
+ models: [],
+ }))}
+ onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
+ onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ />,
+ );
+ });
+
+ await clickButtonByText("Advanced");
+
+ const temperatureInput = container.querySelector("#openai-temperature");
+ expect(temperatureInput).toBeTruthy();
+
+ await act(async () => {
+ setInputValue(temperatureInput!, "3");
+ temperatureInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await clickButtonByText("Save advanced settings");
+
+ expect(container.textContent).toContain("Temperature must be 2 or lower.");
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it("focuses a new focus area after adding it", async () => {
+ await act(async () => {
+ root.render(
+ undefined)}
+ onStartOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ onGetOpenAiAccountInfo={vi.fn(async () => ({
+ configured: false,
+ prefetchedAt: "2026-05-02T00:00:00.000Z",
+ recommendedModel: "gpt-5.5",
+ currentModel: "gpt-5.5",
+ currentModelSupported: true,
+ models: [],
+ }))}
+ onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
+ onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ />,
+ );
+ });
+
+ await clickButtonByText("Board");
+ await clickButtonByText("Add area");
+
+ const newAreaInput = container.querySelector('input[aria-label="New area name"]');
+ expect(newAreaInput).toBeTruthy();
+ expect(document.activeElement).toBe(newAreaInput);
+ });
+
+ it("blocks empty focus area names", async () => {
+ const onSave = vi.fn(
+ async (_nextSettings: AppSettings, _providerPatches: ProviderConfigPatch[]) => undefined,
+ );
+
+ await act(async () => {
+ root.render(
+ emptyAuthState)}
+ onGetOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ onGetOpenAiAccountInfo={vi.fn(async () => ({
+ configured: false,
+ prefetchedAt: "2026-05-02T00:00:00.000Z",
+ recommendedModel: "gpt-5.5",
+ currentModel: "gpt-5.5",
+ currentModelSupported: true,
+ models: [],
+ }))}
+ onSubmitOpenAiAuthInput={vi.fn(async () => emptyAuthState)}
+ onLogoutOpenAiAuth={vi.fn(async () => emptyAuthState)}
+ />,
+ );
+ });
+
+ await clickButtonByText("Board");
+
+ const nameInput = container.querySelector('input[aria-label="Personal name"]');
+ expect(nameInput).toBeTruthy();
+
+ await act(async () => {
+ setInputValue(nameInput!, "");
+ nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await clickButtonByText("Save board settings");
+
+ expect(container.textContent).toContain("Enter a focus area name.");
+ expect(container.querySelector('input[aria-label="Focus area name"]')).toBeTruthy();
+ expect(onSave).not.toHaveBeenCalled();
+ });
});
+async function clickButtonByText(text: string) {
+ const button = Array.from(container.querySelectorAll("button")).find((entry) =>
+ entry.textContent?.includes(text),
+ );
+ expect(button).toBeTruthy();
+ await act(async () => {
+ button!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, button: 0 }));
+ button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
+ button!.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
+ button!.click();
+ });
+}
+
function setInputValue(input: HTMLInputElement, value: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
diff --git a/src/client/components/SettingsView.tsx b/src/client/components/SettingsView.tsx
index fec147b..4682334 100644
--- a/src/client/components/SettingsView.tsx
+++ b/src/client/components/SettingsView.tsx
@@ -1,4 +1,5 @@
import {
+ AlertCircle,
CheckCircle2,
ExternalLink,
Gauge,
@@ -12,7 +13,7 @@ import {
SlidersHorizontal,
Trash2,
} from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type RefCallback } from "react";
import type {
AppSettings,
FocusArea,
@@ -50,15 +51,25 @@ import {
Field,
FieldContent,
FieldDescription,
+ FieldError,
FieldGroup,
FieldLabel,
+ FieldLegend,
+ FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from "@/components/ui/input-group";
import {
Item,
ItemActions,
+ ItemContent,
ItemDescription,
ItemGroup,
+ ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
@@ -69,6 +80,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import {
FOCUS_AREA_COLOR_OPTIONS,
createFocusArea,
@@ -81,6 +101,14 @@ const OPENAI_PROVIDER = "openai";
const DEFAULT_OPTION = "default";
type DefaultOption = typeof DEFAULT_OPTION;
+type SettingsSaveTarget = "openai" | "advanced" | "board";
+type OpenAiNumberField =
+ | "maxTokens"
+ | "temperature"
+ | "timeoutMs"
+ | "maxRetries"
+ | "maxRetryDelayMs";
+type OpenAiNumberErrors = Partial>;
export function SettingsView(props: {
settings: AppSettings;
@@ -145,9 +173,12 @@ export function SettingsView(props: {
const [authState, setAuthState] = useState(null);
const [accountInfo, setAccountInfo] = useState(null);
const [loginInput, setLoginInput] = useState("");
- const [saving, setSaving] = useState(false);
+ const [savingTarget, setSavingTarget] = useState(null);
const [checking, setChecking] = useState(false);
const [authError, setAuthError] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const [pendingFocusAreaFocusId, setPendingFocusAreaFocusId] = useState(null);
+ const focusAreaInputsRef = useRef>({});
useEffect(() => {
let active = true;
@@ -172,21 +203,60 @@ export function SettingsView(props: {
const supportedModels = useMemo(() => accountInfo?.models ?? [], [accountInfo]);
const openAiReady = Boolean(providerStatus?.configured || authState?.configured);
const login = authState?.login;
+ const accountMetadataLoading = authState === null && accountInfo === null;
+ const numberErrors = useMemo(
+ () =>
+ getOpenAiNumberErrors({
+ maxTokens,
+ temperature,
+ timeoutMs,
+ maxRetries,
+ maxRetryDelayMs,
+ }),
+ [maxTokens, temperature, timeoutMs, maxRetries, maxRetryDelayMs],
+ );
+ const hasNumberErrors = Object.values(numberErrors).some(Boolean);
+ const focusAreaErrors = useMemo(() => getFocusAreaErrors(focusAreas), [focusAreas]);
+ const hasFocusAreaErrors = focusAreaErrors.size > 0;
+
+ useEffect(() => {
+ if (!pendingFocusAreaFocusId) {
+ return;
+ }
+ const input = focusAreaInputsRef.current[pendingFocusAreaFocusId];
+ if (!input) {
+ return;
+ }
+ input.focus();
+ input.select();
+ setPendingFocusAreaFocusId(null);
+ }, [focusAreas, pendingFocusAreaFocusId]);
function updateFocusArea(id: string, patch: Partial): void {
+ setSaveError(null);
setFocusAreas((current) =>
current.map((area) => (area.id === id ? { ...area, ...patch } : area)),
);
}
function addFocusArea(): void {
- setFocusAreas((current) => [...current, createFocusArea("New area", current)]);
+ setSaveError(null);
+ const nextArea = createFocusArea("New area", focusAreas);
+ setFocusAreas((current) => [...current, nextArea]);
+ setPendingFocusAreaFocusId(nextArea.id);
}
function removeFocusArea(id: string): void {
+ setSaveError(null);
setFocusAreas((current) => current.filter((area) => area.id !== id));
}
+ function setFocusAreaInputRef(id: string): RefCallback {
+ return (input) => {
+ focusAreaInputsRef.current[id] = input;
+ };
+ }
+
async function refreshOpenAi() {
setChecking(true);
setAuthError(null);
@@ -256,262 +326,533 @@ export function SettingsView(props: {
}
}
- async function save() {
- setSaving(true);
+ function buildOpenAiProviderPatch(): ProviderConfigPatch {
+ return {
+ provider: OPENAI_PROVIDER,
+ label: "OpenAI",
+ model: model.trim() || "gpt-5.5",
+ baseUrl: authMode === "api_key" ? baseUrl.trim() || null : null,
+ authMode,
+ maxTokens: parseOptionalNumber(maxTokens),
+ temperature: parseOptionalNumber(temperature),
+ reasoningEffort: optionalSelectValue(reasoningEffort),
+ reasoningSummary: optionalSelectValue(reasoningSummary),
+ textVerbosity: optionalSelectValue(textVerbosity),
+ timeoutMs: parseOptionalNumber(timeoutMs),
+ maxRetries: parseOptionalNumber(maxRetries),
+ maxRetryDelayMs: parseOptionalNumber(maxRetryDelayMs),
+ cacheRetention: optionalSelectValue(cacheRetention),
+ transport: authMode === "oauth" ? transport : null,
+ organizationId: authMode === "api_key" ? organizationId.trim() || null : null,
+ projectId: authMode === "api_key" ? projectId.trim() || null : null,
+ enabled: true,
+ fallbackRank: 1,
+ apiKey: apiKey.trim() || undefined,
+ };
+ }
+
+ async function saveOpenAiSettings(target: Extract) {
+ setSaveError(null);
+ if (hasNumberErrors) {
+ setSaveError("Fix the highlighted advanced settings before saving.");
+ return;
+ }
+ setSavingTarget(target);
try {
- const normalizedFocusAreas = normalizeFocusAreaDrafts(focusAreas);
- const providerPatch: ProviderConfigPatch = {
- provider: OPENAI_PROVIDER,
- label: "OpenAI",
- model,
- baseUrl: authMode === "api_key" ? baseUrl : null,
- authMode,
- maxTokens: parseOptionalNumber(maxTokens),
- temperature: parseOptionalNumber(temperature),
- reasoningEffort: optionalSelectValue(reasoningEffort),
- reasoningSummary: optionalSelectValue(reasoningSummary),
- textVerbosity: optionalSelectValue(textVerbosity),
- timeoutMs: parseOptionalNumber(timeoutMs),
- maxRetries: parseOptionalNumber(maxRetries),
- maxRetryDelayMs: parseOptionalNumber(maxRetryDelayMs),
- cacheRetention: optionalSelectValue(cacheRetention),
- transport: authMode === "oauth" ? transport : null,
- organizationId: authMode === "api_key" ? organizationId.trim() || null : null,
- projectId: authMode === "api_key" ? projectId.trim() || null : null,
- enabled: true,
- fallbackRank: 1,
- apiKey: apiKey.trim() || undefined,
- };
await props.onSave(
{
...props.settings,
selectedProvider: OPENAI_PROVIDER,
- providerConfigs: props.settings.providerConfigs,
+ userProfile: props.settings.userProfile,
+ },
+ [buildOpenAiProviderPatch()],
+ );
+ setApiKey("");
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSavingTarget(null);
+ }
+ }
+
+ async function saveBoardSettings() {
+ setSaveError(null);
+ if (hasFocusAreaErrors) {
+ setSaveError("Fix the highlighted board settings before saving.");
+ return;
+ }
+ setSavingTarget("board");
+ try {
+ const normalizedFocusAreas = normalizeFocusAreaDrafts(focusAreas);
+ await props.onSave(
+ {
+ ...props.settings,
userProfile: {
...props.settings.userProfile,
workAreas: normalizedFocusAreas.map((area) => area.label),
focusAreas: normalizedFocusAreas,
},
},
- [providerPatch],
+ [],
);
- setApiKey("");
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : String(err));
} finally {
- setSaving(false);
+ setSavingTarget(null);
}
}
return (
-
-
-
-
-
- OpenAI connection
-
- Connect with your OpenAI account or use an API key for task execution.
-
-
-
- {openAiReady ? : }
- {openAiReady ? "Connected" : "Setup needed"}
-
-
-
-
-
- {authError && (
-
- OpenAI setup failed
- {authError}
-
- )}
+
+
+
+
+
+ OpenAI
+
+
+
+ Advanced
+
+
+
+ Board
+
+
-
-
- Authentication
- setAuthMode(value as ProviderAuthMode)}>
-
-
-
-
-
- OpenAI account
- API key
-
-
-
-
- Account auth uses your OpenAI login. API key mode uses the key stored locally or in `OPENAI_API_KEY`.
-
-
+
+
+
+
+
+ OpenAI connection
+
+ Connect your account, choose the model, and keep task execution ready.
+
+
+
+ {openAiReady ? (
+
+ ) : (
+
+ )}
+ {openAiReady ? "Connected" : "Setup needed"}
+
+
+
+
+
+
-
- Model
- {supportedModels.length > 0 ? (
-
-
-
-
-
-
- {supportedModels.map((entry) => (
-
- {entry.name}
-
- ))}
-
-
-
- ) : (
- setModel(event.target.value)}
- />
+ {accountInfo?.currentModelSupported === false && accountInfo.recommendedModel && (
+
+
+ Selected model is not available
+
+
+ {accountInfo.currentModel} is not in this account model list. Use{" "}
+ {accountInfo.recommendedModel} for the next run.
+
+ setModel(accountInfo.recommendedModel)}
+ >
+ Use recommended
+
+
+
)}
-
- {accountInfo?.currentModelSupported === false
- ? `${accountInfo.currentModel} is not in the current account model list.`
- : "Used when a task is moved into progress."}
-
-
-
- {authMode === "api_key" ? (
-
- ) : (
-
- )}
+ ))}
+
+
+
+
+ void saveBoardSettings()}
+ disabled={savingTarget !== null}
+ >
+
+ {savingTarget === "board" ? "Saving..." : "Save board settings"}
+
+
+
+
+
+
+ );
+}
-
-
-
-
-
-
- {saving ? "Saving..." : "Save OpenAI settings"}
-
-
-
-
-
-
- Board settings
-
- Focus areas appear in the task dropdown and color task cards.
-
-
-
-
-
-
- Focus areas
-
- Use focus areas for scanning and filtering work by context.
-
-
-
-
- Add area
-
-
-
- {focusAreas.map((area) => (
- 1}
- onChange={(patch) => updateFocusArea(area.id, patch)}
- onRemove={() => removeFocusArea(area.id)}
- />
- ))}
-
-
-
-
-
-
- {saving ? "Saving..." : "Save board settings"}
-
-
-
-
+function SettingsErrorAlert(props: { authError?: string | null; saveError?: string | null }) {
+ const message = props.authError ?? props.saveError;
+ if (!message) {
+ return null;
+ }
+ return (
+
+
+ {props.authError ? "OpenAI setup failed" : "Settings not saved"}
+ {message}
+
+ );
+}
+
+function ApiKeyFields(props: {
+ apiKey: string;
+ baseUrl: string;
+ organizationId: string;
+ projectId: string;
+ hasStoredApiKey: boolean;
+ onApiKeyChange: (value: string) => void;
+ onBaseUrlChange: (value: string) => void;
+ onOrganizationIdChange: (value: string) => void;
+ onProjectIdChange: (value: string) => void;
+}) {
+ return (
+
+
+ API key
+ props.onApiKeyChange(event.target.value)}
+ placeholder={props.hasStoredApiKey ? "Stored key is active" : "sk-..."}
+ />
+
+ Leave blank to keep the existing local key or environment key.
+
+
+
+ Base URL
+ props.onBaseUrlChange(event.target.value)}
+ />
+ Default OpenAI-compatible endpoint.
+
+
+ Organization ID
+ props.onOrganizationIdChange(event.target.value)}
+ placeholder="org_..."
+ />
+ Optional `OpenAI-Organization` request header.
+
+
+ Project ID
+ props.onProjectIdChange(event.target.value)}
+ placeholder="proj_..."
+ />
+ Optional `OpenAI-Project` request header.
+
+
+ );
+}
+
+function OpenAiMetadataSkeleton() {
+ return (
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function OpenAiNumberInput(props: {
+ id: string;
+ label: string;
+ description: string;
+ value: string;
+ min?: number;
+ max?: number;
+ step?: number;
+ integer?: boolean;
+ error?: string;
+ onChange: (value: string) => void;
+}) {
+ const invalid = Boolean(props.error);
+ return (
+
+ {props.label}
+ props.onChange(event.target.value)}
+ placeholder="Provider default"
+ aria-invalid={invalid}
+ />
+ {props.description}
+ {props.error}
+
);
}
@@ -527,6 +868,7 @@ function OpenAiAdvancedSettings(props: {
maxRetryDelayMs: string;
cacheRetention: OpenAiCacheRetention | DefaultOption;
transport: OpenAiCodexTransport;
+ errors: OpenAiNumberErrors;
onMaxTokensChange: (value: string) => void;
onTemperatureChange: (value: string) => void;
onReasoningEffortChange: (value: OpenAiReasoningEffort | DefaultOption) => void;
@@ -539,145 +881,50 @@ function OpenAiAdvancedSettings(props: {
onTransportChange: (value: OpenAiCodexTransport) => void;
}) {
return (
- -
-
-
-
- Advanced request settings
+ <>
+ -
+
+
+
+
+ Request tuning
- Tune the OpenAI request used by board work, chat, and memory review.
+ These settings apply to board work, chat, and memory review. Blank fields use
+ provider defaults.
-
-
-
+ >
);
}
function OpenAiAccountPanel(props: {
authState: OpenAiAuthState | null;
accountInfo: OpenAiAccountInfo | null;
+ selectedModel: string;
checking: boolean;
loginInput: string;
onLoginInputChange: (value: string) => void;
@@ -742,12 +1129,17 @@ function OpenAiAccountPanel(props: {
return (
-
-
OpenAI account
-
- {props.authState?.configured
- ? `Connected${props.authState.accountId ? ` as ${props.authState.accountId}` : ""}.`
- : "Connect your OpenAI account to use supported Codex models without pasting an API key."}
-
+
+ {props.authState?.configured ? : }
+
+
+ OpenAI account
+
+ {props.authState?.configured
+ ? `Connected${props.authState.accountId ? ` as ${props.authState.accountId}` : ""}.`
+ : "Connect your OpenAI account to use supported Codex models without pasting an API key."}
+
+
@@ -769,10 +1161,12 @@ function OpenAiAccountPanel(props: {
{login?.authUrl && login.phase !== "complete" && (
-
-
Finish sign-in
-
- {login.progress ?? "Open the sign-in page, then paste the returned code if prompted."}
-
+
+ Finish sign-in
+
+ {login.progress ?? "Open the sign-in page, then paste the returned code if prompted."}
+
+
@@ -799,14 +1193,18 @@ function OpenAiAccountPanel(props: {
{props.accountInfo && (
-
-
-
+
+
+
+
Model catalog
- Recommended model: {props.accountInfo.recommendedModel}. Available models:{" "}
- {props.accountInfo.models.length}.
+ Selected model: {props.selectedModel}. Recommended model:{" "}
+ {props.accountInfo.recommendedModel}. Available models:{" "}
+ {props.accountInfo.models.length}. Last refreshed:{" "}
+ {formatTimestamp(props.accountInfo.prefetchedAt)}.
-
+
)}
@@ -816,57 +1214,90 @@ function OpenAiAccountPanel(props: {
function FocusAreaEditor(props: {
area: FocusArea;
canRemove: boolean;
+ error?: string;
+ inputRef: RefCallback
;
onChange: (patch: Partial) => void;
onRemove: () => void;
}) {
const style = getFocusAreaStyle(props.area.color);
+ const inputId = `focus-area-${props.area.id}`;
+ const invalid = Boolean(props.error);
+ const removeButton = (
+
+
+
+ );
return (
- -
-
-
- props.onChange({ label: event.target.value })}
- aria-label={`${props.area.label || "Focus area"} name`}
- />
-
- props.onChange({ color: value as FocusAreaColor })}
- >
-
-
-
-
-
-
- {FOCUS_AREA_COLOR_OPTIONS.map((option) => (
-
-
-
- ))}
-
-
-
-
-
+
+
+ {props.area.label || "Focus area"} name
+
+
+ props.onChange({ label: event.target.value })}
+ aria-label={`${props.area.label || "Focus area"} name`}
+ aria-invalid={invalid}
+ />
+
+
+
+
+ {props.error}
+
+
+ {props.area.label || "Focus area"} color
+ props.onChange({ color: value as FocusAreaColor })}
>
-
-
+
+
+
+
+
+
+ {FOCUS_AREA_COLOR_OPTIONS.map((option) => (
+
+
+
+ ))}
+
+
+
+
+
+ {props.canRemove ? (
+ removeButton
+ ) : (
+
+
+ {removeButton}
+
+ Keep at least one focus area.
+
+ )}
);
@@ -911,6 +1342,71 @@ function normalizeFocusAreaDrafts(areas: FocusArea[]): FocusArea[] {
.filter((area): area is FocusArea => Boolean(area));
}
+function getFocusAreaErrors(areas: FocusArea[]): Map {
+ const errors = new Map();
+ for (const area of areas) {
+ if (!area.label.trim()) {
+ errors.set(area.id, "Enter a focus area name.");
+ }
+ }
+ return errors;
+}
+
+function getOpenAiNumberErrors(values: Record): OpenAiNumberErrors {
+ return {
+ maxTokens: validateOptionalNumber(values.maxTokens, {
+ label: "Max output tokens",
+ min: 1,
+ integer: true,
+ }),
+ temperature: validateOptionalNumber(values.temperature, {
+ label: "Temperature",
+ min: 0,
+ max: 2,
+ }),
+ timeoutMs: validateOptionalNumber(values.timeoutMs, {
+ label: "Request timeout",
+ min: 1000,
+ integer: true,
+ }),
+ maxRetries: validateOptionalNumber(values.maxRetries, {
+ label: "Max retries",
+ min: 0,
+ max: 10,
+ integer: true,
+ }),
+ maxRetryDelayMs: validateOptionalNumber(values.maxRetryDelayMs, {
+ label: "Retry delay",
+ min: 0,
+ integer: true,
+ }),
+ };
+}
+
+function validateOptionalNumber(
+ value: string,
+ options: { label: string; min?: number; max?: number; integer?: boolean },
+): string | undefined {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) {
+ return `${options.label} must be a number.`;
+ }
+ if (options.integer && !Number.isInteger(parsed)) {
+ return `${options.label} must be a whole number.`;
+ }
+ if (options.min !== undefined && parsed < options.min) {
+ return `${options.label} must be at least ${options.min}.`;
+ }
+ if (options.max !== undefined && parsed > options.max) {
+ return `${options.label} must be ${options.max} or lower.`;
+ }
+ return undefined;
+}
+
function parseOptionalNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) {
@@ -930,3 +1426,16 @@ function formatOptionLabel(value: string): string {
.map((part) => (part ? `${part[0].toUpperCase()}${part.slice(1)}` : part))
.join(" ");
}
+
+function formatTimestamp(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "unknown";
+ }
+ return date.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}