+ The system prompt was not reset: {resetPrompt.error.message}
+
+ )}
+
+
+
+
+
+
+ setConfirmingReset(false)}
+ onConfirm={onConfirmReset}
+ >
+ This restores MealQuest's default system prompt for everyone in the household.
+
+ blocker.reset?.()}
+ onConfirm={() => blocker.proceed?.()}
+ >
+ Your changes to the AI assistant have not been saved.
+
+ >
+ )
+}
+
+// maskedKey shows the stored key as dots and its last four characters, all
+// the client ever knows of it.
+export function maskedKey(last4: string): string {
+ return `••••••••${last4}`
+}
+
+// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
+export function formatVerifiedDate(timestamp: string): string {
+ const date = new Date(timestamp)
+ if (Number.isNaN(date.getTime())) throw new Error(`key_verified_at "${timestamp}" is not a timestamp`)
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date)
+}
+
+// keyLockedGuidance is how to change the provider or base URL while a key
+// is stored.
+const keyLockedGuidance =
+ 'An API key is stored. Remove it (Replace → Remove key) before changing the provider or base URL.'
+
+// APIKey stays mounted whatever provider the draft names, so a key stored
+// (and the verify that follows it) completes even if the household changes
+// the provider meanwhile, and a stored key can always be removed.
+function APIKey({ saved, draftProvider }: { saved: AISettings; draftProvider: AIProvider }) {
+ const [replacing, setReplacing] = useState(false)
+ const [key, setKey] = useState('')
+ const store = useSetAIKey()
+ const verify = useVerifyAIKey()
+ const labelId = useId()
+ const sheetTitleId = useId()
+ const last4 = saved.api_key_last4
+ const providerChanged = draftProvider !== saved.provider
+
+ function open() {
+ store.reset()
+ setReplacing(true)
+ }
+
+ // close keeps the store mutation: a key already sent is still stored and
+ // verified after the sheet is dismissed.
+ function close() {
+ setReplacing(false)
+ setKey('')
+ }
+
+ function onStore() {
+ verify.reset()
+ store.mutate(key, {
+ onSuccess: () => {
+ close()
+ verify.mutate(undefined)
+ },
+ })
+ }
+
+ function onRemove() {
+ verify.reset()
+ store.mutate('', { onSuccess: close })
+ }
+
+ const storeError = store.isError && (
+
+ )
+}
+
+type KeyStatusProps = {
+ saved: AISettings
+ verify: ReturnType
+}
+
+// KeyStatus is the line under the key: verifying, the verify error, when it
+// was verified, or that it has not been.
+function KeyStatus({ saved, verify }: KeyStatusProps) {
+ if (saved.api_key_last4 === null) return null
+ if (verify.isPending) return
+
+ )
+}
diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx
index 30c0639..5a0c28c 100644
--- a/web/src/settings/SettingsPage.test.tsx
+++ b/web/src/settings/SettingsPage.test.tsx
@@ -4,7 +4,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
-import { SETTINGS_API } from '../test/ApiProviders.tsx'
+import { AI_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
@@ -12,7 +12,7 @@ import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
-const AI = { provider: 'anthropic', model: 'claude-sonnet-4-5' }
+const AI = AI_SETTINGS
afterEach(() => {
vi.unstubAllGlobals()
@@ -150,14 +150,14 @@ describe('SettingsPage', () => {
it('shows another provider and model as the settings name them', async () => {
stubSettings({
- '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }),
+ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'openai_compatible', model: 'llama3.1:8b' }),
})
renderSettings()
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
})
it('says no model is chosen for the seeded AI settings', async () => {
- stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) })
+ stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, model: '' }) })
renderSettings()
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
})
@@ -213,7 +213,7 @@ describe('SettingsPage', () => {
})
it('refuses an AI provider it has no name for', async () => {
- stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'mystery', model: 'm1' }) })
+ stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { ...AI, provider: 'mystery', model: 'm1' }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts
index 638ea0a..399dc70 100644
--- a/web/src/settings/api.ts
+++ b/web/src/settings/api.ts
@@ -1,9 +1,13 @@
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
+export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
+export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
+export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
+export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
@@ -37,16 +41,157 @@ export const aiProviderLabels = {
export type AIProvider = keyof typeof aiProviderLabels
-// AISettings is the part of GET /api/v1/settings/ai the settings pages read.
+// providersWithBaseURL are the presets with no fixed API root: the
+// household supplies its base URL.
+export const providersWithBaseURL: ReadonlySet = new Set(['ollama', 'openai_compatible'])
+
+// aiKeyPolicy is whether each preset needs an API key, may take one, or
+// takes none (internal/ai/llm/presets).
+export const aiKeyPolicy: Record = {
+ anthropic: 'required',
+ openai: 'required',
+ openrouter: 'required',
+ xai: 'required',
+ ollama: 'none',
+ openai_compatible: 'optional',
+}
+
+// AISettings is GET /api/v1/settings/ai. The API key itself is never sent:
+// only its last four characters, null when none is stored.
export type AISettings = {
provider: AIProvider
+ base_url: string
model: string
+ temperature: number
+ // Null when none is stored.
+ system_prompt: string | null
+ api_key_last4: string | null
+ // When the stored key was last verified, null when it has not been.
+ key_verified_at: string | null
+ updated_at: string
}
+// AISettingsPatch is the PATCH /api/v1/settings/ai body: only the fields
+// that change.
+export type AISettingsPatch = Partial> & {
+ system_prompt?: string
+}
+
+// AIModel is one model GET /api/v1/settings/ai/models lists.
+export type AIModel = { id: string; display_name: string }
+
function isAIProvider(value: string): value is AIProvider {
return Object.hasOwn(aiProviderLabels, value)
}
+function isStringOrNull(value: unknown): value is string | null {
+ return value === null || typeof value === 'string'
+}
+
+// parseAISettings refuses a body that is not the AI settings, or names a
+// provider this client has no name for, so the settings never show a value
+// the household cannot read.
+function parseAISettings(where: string, body: unknown): AISettings {
+ if (
+ !isRecord(body) ||
+ typeof body.provider !== 'string' ||
+ typeof body.base_url !== 'string' ||
+ typeof body.model !== 'string' ||
+ typeof body.temperature !== 'number' ||
+ !isStringOrNull(body.system_prompt) ||
+ !isStringOrNull(body.api_key_last4) ||
+ !isStringOrNull(body.key_verified_at) ||
+ typeof body.updated_at !== 'string'
+ ) {
+ throw new Error(`${where}: response is not the AI settings`)
+ }
+ if (!isAIProvider(body.provider)) throw new Error(`${where}: unknown AI provider "${body.provider}"`)
+ return {
+ provider: body.provider,
+ base_url: body.base_url,
+ model: body.model,
+ temperature: body.temperature,
+ system_prompt: body.system_prompt,
+ api_key_last4: body.api_key_last4,
+ key_verified_at: body.key_verified_at,
+ updated_at: body.updated_at,
+ }
+}
+
+const AI_SETTINGS_KEY = ['settings', 'ai'] as const
+const AI_MODELS_KEY = ['settings', 'ai', 'models'] as const
+
+export function useAISettings() {
+ const { apiFetch } = useApi()
+ return useQuery({
+ queryKey: AI_SETTINGS_KEY,
+ queryFn: async () => parseAISettings(`GET ${AI_SETTINGS_PATH}`, await apiFetch(AI_SETTINGS_PATH)),
+ })
+}
+
+// useAIModels is the saved provider's live model list. Without a usable
+// provider (no key yet, say) it fails with the server's 409 not_configured.
+export function useAIModels(enabled: boolean) {
+ const { apiFetch } = useApi()
+ return useQuery({
+ queryKey: AI_MODELS_KEY,
+ enabled,
+ queryFn: async () => {
+ const body = await apiFetch(AI_MODELS_PATH)
+ const models = isRecord(body) ? body.models : undefined
+ if (
+ !Array.isArray(models) ||
+ !models.every((m) => isRecord(m) && typeof m.id === 'string' && typeof m.display_name === 'string')
+ ) {
+ throw new Error(`GET ${AI_MODELS_PATH}: response is not a model list`)
+ }
+ return models as AIModel[]
+ },
+ })
+}
+
+// useAISettingsMutation sends one AI settings change and stores the
+// settings the server answers with. A change that can alter which models
+// the provider offers refetches the model list.
+function useAISettingsMutation(path: string, method: 'POST' | 'PUT' | 'PATCH', body: (variables: V) => unknown) {
+ const { apiFetch } = useApi()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async (variables: V) =>
+ parseAISettings(`${method} ${path}`, await apiFetch(path, { method, body: body(variables) })),
+ onSuccess: (settings) => {
+ const before = queryClient.getQueryData(AI_SETTINGS_KEY)
+ queryClient.setQueryData(AI_SETTINGS_KEY, settings)
+ if (
+ before === undefined ||
+ before.provider !== settings.provider ||
+ before.base_url !== settings.base_url ||
+ before.api_key_last4 !== settings.api_key_last4 ||
+ before.key_verified_at !== settings.key_verified_at
+ ) {
+ void queryClient.invalidateQueries({ queryKey: AI_MODELS_KEY })
+ }
+ },
+ })
+}
+
+export function useSaveAISettings() {
+ return useAISettingsMutation(AI_SETTINGS_PATH, 'PATCH', (patch: AISettingsPatch) => patch)
+}
+
+// useSetAIKey stores a new API key; an empty key removes the stored one.
+export function useSetAIKey() {
+ return useAISettingsMutation(AI_KEY_PATH, 'PUT', (apiKey: string) => ({ api_key: apiKey }))
+}
+
+export function useVerifyAIKey() {
+ return useAISettingsMutation(AI_VERIFY_PATH, 'POST', () => undefined)
+}
+
+export function useResetAIPrompt() {
+ return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
+}
+
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
@@ -65,25 +210,9 @@ export function useHouseholdSettings() {
})
}
-// useAISettings refuses a provider this client has no name for, so the
-// settings never show a value the household cannot read.
-export function useAISettings() {
- const { apiFetch } = useApi()
- return useQuery({
- queryKey: ['settings', 'ai'],
- queryFn: async () => {
- const settings = await apiFetch<{ provider: string; model: string }>(AI_SETTINGS_PATH)
- if (!isAIProvider(settings.provider)) {
- throw new Error(`GET ${AI_SETTINGS_PATH}: unknown AI provider "${settings.provider}"`)
- }
- return { provider: settings.provider, model: settings.model } satisfies AISettings
- },
- })
-}
-
// aiStatus is the AI assistant row's status line: the provider and the
// model's display name, or that no model is chosen yet (the seed).
-export function aiStatus(settings: AISettings): string {
+export function aiStatus(settings: Pick): string {
const provider = aiProviderLabels[settings.provider]
if (settings.model === '') return `${provider} · No model chosen`
return `${provider} · ${modelDisplayName(settings.model)}`
diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx
index 359d115..b0c1650 100644
--- a/web/src/test/ApiProviders.tsx
+++ b/web/src/test/ApiProviders.tsx
@@ -4,6 +4,18 @@ import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
+// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
+export const AI_SETTINGS = {
+ provider: 'anthropic',
+ base_url: '',
+ model: 'claude-sonnet-4-5',
+ temperature: 0.7,
+ system_prompt: 'You are MealQuest, a dinner planning assistant for one household.',
+ api_key_last4: '3f9a',
+ key_verified_at: '2026-10-01T15:00:00.000Z',
+ updated_at: '2026-10-01T15:00:00.000Z',
+}
+
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet.
@@ -12,7 +24,7 @@ export const SETTINGS_API = {
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
- '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
+ '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
}
// ApiProviders gives the pages under test what App gives them: an API client
diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts
index 3260596..7b7e33f 100644
--- a/web/src/test/cssRules.ts
+++ b/web/src/test/cssRules.ts
@@ -8,9 +8,13 @@ function cssRules(): CSSRule[] {
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
}
+// A selector with a pseudo-element never matches an element, and jsdom's
+// matches() throws on the vendor ones (::-moz-range-track), so those rules
+// are skipped.
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
for (const rule of rules) {
- if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
+ if (!(rule instanceof CSSStyleRule) || rule.selectorText.includes('::')) continue
+ if (!element.matches(rule.selectorText)) continue
for (const property of Array.from(rule.style)) {
style.setProperty(property, rule.style.getPropertyValue(property))
}
diff --git a/web/src/test/dataRouter.tsx b/web/src/test/dataRouter.tsx
new file mode 100644
index 0000000..5ecd127
--- /dev/null
+++ b/web/src/test/dataRouter.tsx
@@ -0,0 +1,10 @@
+import { useState, type ReactNode } from 'react'
+import { createMemoryRouter, RouterProvider } from 'react-router'
+
+// InDataRouter renders children under a memory data router's splat route at
+// path, as main.tsx renders App, so pages that block navigation (useBlocker)
+// work in tests. The router is created once, on first render.
+export function InDataRouter({ path, children }: { path: string; children: ReactNode }) {
+ const [router] = useState(() => createMemoryRouter([{ path: '*', element: children }], { initialEntries: [path] }))
+ return
+}
diff --git a/web/src/ui/Select.module.css b/web/src/ui/Select.module.css
new file mode 100644
index 0000000..626bfae
--- /dev/null
+++ b/web/src/ui/Select.module.css
@@ -0,0 +1,50 @@
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.label {
+ font-family: var(--font-body);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--color-neutral-700);
+}
+
+.control {
+ position: relative;
+ display: block;
+}
+
+.select {
+ appearance: none;
+ box-sizing: border-box;
+ width: 100%;
+ min-width: 44px;
+ height: 56px;
+ padding: 0 36px 0 16px;
+ border: 1px solid var(--color-neutral-300);
+ border-radius: var(--radius-pill);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 15px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ cursor: pointer;
+}
+
+.select:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* The chevron sits over the select's right padding and lets taps through. */
+.chevron {
+ position: absolute;
+ top: 50%;
+ right: 14px;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
diff --git a/web/src/ui/Select.test.tsx b/web/src/ui/Select.test.tsx
new file mode 100644
index 0000000..d1eec29
--- /dev/null
+++ b/web/src/ui/Select.test.tsx
@@ -0,0 +1,38 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { expectMinHitArea } from '../test/hitArea.ts'
+import { Select } from './Select.tsx'
+
+describe('Select', () => {
+ it('is a native select named by its label', () => {
+ const onChange = vi.fn()
+ render(
+ ,
+ )
+ const select = screen.getByRole('combobox', { name: 'Provider' })
+ expect(select).toHaveValue('a')
+ fireEvent.change(select, { target: { value: 'b' } })
+ expect(onChange).toHaveBeenCalledOnce()
+ })
+
+ it('keeps a 44 px tap target', () => {
+ render(
+ ,
+ )
+ expectMinHitArea(screen.getByRole('combobox', { name: 'Model' }))
+ })
+
+ it('hides its chevron from assistive technology', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
+ })
+})
diff --git a/web/src/ui/Select.tsx b/web/src/ui/Select.tsx
new file mode 100644
index 0000000..d2a491e
--- /dev/null
+++ b/web/src/ui/Select.tsx
@@ -0,0 +1,27 @@
+import { ChevronDown } from 'lucide-react'
+import { useId, type ComponentPropsWithRef } from 'react'
+import { Icon } from './Icon.tsx'
+import styles from './Select.module.css'
+
+export type SelectProps = Omit, 'id' | 'multiple'> & {
+ label: string
+}
+
+// Pill dropdown: a native select on the surface fill with a chevron, always
+// associated with its label. Pass the options as children.
+export function Select({ label, className, children, ...rest }: SelectProps) {
+ const id = useId()
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts
index bab54f9..32524fc 100644
--- a/web/src/ui/index.ts
+++ b/web/src/ui/index.ts
@@ -10,6 +10,7 @@ export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Photo, type PhotoProps } from './Photo.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Sheet, type SheetProps } from './Sheet.tsx'
+export { Select, type SelectProps } from './Select.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'