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 - - - 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 ? ( - - ) : ( - 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. + + + + )} - - {accountInfo?.currentModelSupported === false - ? `${accountInfo.currentModel} is not in the current account model list.` - : "Used when a task is moved into progress."} - - -
- {authMode === "api_key" ? ( -
- - API key - setApiKey(event.target.value)} - placeholder={openAiConfig?.hasApiKey ? "Stored key is active" : "sk-..."} - /> +
+ + Authentication method + { + if (value) { + setAuthMode(value as ProviderAuthMode); + setSaveError(null); + } + }} + variant="outline" + size="sm" + spacing={0} + className="w-full sm:w-fit" + aria-label="Authentication method" + > + + + OpenAI account + + + + API key + + + + Account auth is the default path. API key mode uses a local key or + `OPENAI_API_KEY`. + + + + + Model + {supportedModels.length > 0 ? ( + + ) : ( + { + setModel(event.target.value); + setSaveError(null); + }} + /> + )} + + Used when a task moves into progress or the chat asks OpenAI to help. + + +
+ + {authMode === "api_key" ? ( + { + setApiKey(value); + setSaveError(null); + }} + onBaseUrlChange={(value) => { + setBaseUrl(value); + setSaveError(null); + }} + onOrganizationIdChange={(value) => { + setOrganizationId(value); + setSaveError(null); + }} + onProjectIdChange={(value) => { + setProjectId(value); + setSaveError(null); + }} + /> + ) : accountMetadataLoading ? ( + + ) : ( + + )} + + + + + + + + + + + + Advanced request settings + + Optional OpenAI request tuning. Provider defaults are usually the right choice. + + + + + + { + setMaxTokens(value); + setSaveError(null); + }} + onTemperatureChange={(value) => { + setTemperature(value); + setSaveError(null); + }} + onReasoningEffortChange={(value) => { + setReasoningEffort(value); + setSaveError(null); + }} + onReasoningSummaryChange={(value) => { + setReasoningSummary(value); + setSaveError(null); + }} + onTextVerbosityChange={(value) => { + setTextVerbosity(value); + setSaveError(null); + }} + onTimeoutMsChange={(value) => { + setTimeoutMs(value); + setSaveError(null); + }} + onMaxRetriesChange={(value) => { + setMaxRetries(value); + setSaveError(null); + }} + onMaxRetryDelayMsChange={(value) => { + setMaxRetryDelayMs(value); + setSaveError(null); + }} + onCacheRetentionChange={(value) => { + setCacheRetention(value); + setSaveError(null); + }} + onTransportChange={(value) => { + setTransport(value); + setSaveError(null); + }} + /> + + + + + + + + + + + + Board settings + + Focus areas appear in task creation, filters, and task card color. + + + + + + + + Focus areas - Leave blank to keep the existing local key or environment key. + Keep the list short enough to scan while still matching the contexts you + work in. - - - Base URL - setBaseUrl(event.target.value)} - /> - Default OpenAI-compatible endpoint. - - - Organization ID - setOrganizationId(event.target.value)} - placeholder="org_..." - /> - Optional `OpenAI-Organization` request header. - - - Project ID - setProjectId(event.target.value)} - placeholder="proj_..." + + + + + {focusAreas.map((area) => ( + 1} + error={focusAreaErrors.get(area.id)} + inputRef={setFocusAreaInputRef(area.id)} + onChange={(patch) => updateFocusArea(area.id, patch)} + onRemove={() => removeFocusArea(area.id)} /> - Optional `OpenAI-Project` request header. -
-
- ) : ( - - )} + ))} + +
+
+ + + +
+ + + + ); +} - - - - - - - - - - - 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. - - - - - - {focusAreas.map((area) => ( - 1} - onChange={(patch) => updateFocusArea(area.id, patch)} - onRemove={() => removeFocusArea(area.id)} - /> - ))} - - - - - - - -
+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. -
-
- - Max output tokens - + + +
+
+ Output + Control response length and detail. + + props.onMaxTokensChange(event.target.value)} - placeholder="Provider default" - /> - - - Temperature - props.onTemperatureChange(event.target.value)} - placeholder="Provider default" + min={1} + integer + error={props.errors.maxTokens} + onChange={props.onMaxTokensChange} /> - - - Reasoning effort - - - - Reasoning summary - - - - Text verbosity - - - - Prompt cache - - - {props.authMode === "oauth" && ( - Codex transport + Text verbosity + + Choose how compact or detailed generated text should be. + - )} - - Timeout ms - +
+ +
+ Reasoning + + Let the model decide unless a workflow needs tighter control. + + + + Reasoning effort + + + Higher effort can improve difficult tasks but may take longer. + + + + Reasoning summary + + + Optional summary detail for reasoning-capable models. + + + +
+ +
+ Reliability + Set bounds for slow or retrying requests. + + + props.onTimeoutMsChange(event.target.value)} - placeholder="Provider default" + min={1000} + integer + error={props.errors.timeoutMs} + onChange={props.onTimeoutMsChange} /> - - - Max retries - props.onMaxRetriesChange(event.target.value)} - placeholder="Provider default" + integer + error={props.errors.maxRetries} + onChange={props.onMaxRetriesChange} /> - - - Max retry delay ms - props.onMaxRetryDelayMsChange(event.target.value)} - placeholder="Provider default" + min={0} + integer + error={props.errors.maxRetryDelayMs} + onChange={props.onMaxRetryDelayMsChange} /> - -
+ + + +
+ Transport and cache + + Advanced account transport and prompt cache behavior. + + + + Prompt caching + + + Provider default is best unless a run needs explicit cache policy. + + + {props.authMode === "oauth" && ( + + Transport + + + Leave on auto unless a specific Codex transport is required. + + + )} + +
- + ); } 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."} + + + ); return ( - -
-
- - -