Admin AI assistant screen (mock 1o)
CI / ci (push) Successful in 1m56s
CI / ci (pull_request) Successful in 1m56s

/settings/ai edits the household's AI settings: Provider and Model pill
dropdowns (model list from GET /settings/ai/models plus "Type a model
id"), the API key as its masked last four with a Replace sheet that PUTs
the key then verifies it ("Verified · Oct 1" or the verify error), the
Creativity (temperature) slider, the mono system prompt, Reset (confirms,
then reset-prompt) and Save changes (PATCHes only the edited fields).
A 400 from the endpoint lock says to remove the key before switching
provider; the sheet's Remove key does that.

Unsaved changes are guarded on leaving: the app now runs under a data
router (createBrowserRouter) so the page can use useBlocker, plus
beforeunload for reloads and closing the tab.

Adds the Select pill-dropdown primitive. The test helper declaredStyle
skips pseudo-element rules, which jsdom cannot match.

Maestro task: a0847bb7-54d0-4244-ab15-f82500d0fa7f

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:58:56 -04:00
co-authored by Claude Opus 5.5
parent de4f539aea
commit b0e31df099
15 changed files with 1482 additions and 39 deletions
+23 -7
View File
@@ -1,12 +1,12 @@
import { act, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { mockViewport } from './test/matchMedia.ts'
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
import App, { routes } from './App.tsx'
import { SETTINGS_API } from './test/ApiProviders.tsx'
import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
@@ -20,12 +20,15 @@ function renderAt(path: string, location = fakeLocation(path)) {
function renderAppAt(path: string, location = fakeLocation(path)) {
return render(
<MemoryRouter initialEntries={[path]}>
<InDataRouter path={path}>
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
}
// The routes that render a built screen rather than a placeholder page.
const SCREENS = { '/settings': true, '/settings/ai': true }
describe('App routes', () => {
it('registers every handoff route plus the extra settings pages', () => {
expect(routes.map((route) => route.path)).toEqual([
@@ -47,7 +50,7 @@ describe('App routes', () => {
])
})
it.each(routes.filter(({ path }) => path !== '/settings').map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
'%s renders its placeholder page inside the shell',
async (path, title) => {
mockViewport(390)
@@ -70,6 +73,19 @@ describe('App routes', () => {
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
})
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
})
renderAt('/settings/ai')
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
})
})
describe('App bootstrap', () => {
@@ -190,9 +206,9 @@ describe('App offline bootstrap', () => {
goOffline()
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<MemoryRouter initialEntries={['/plan']}>
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</MemoryRouter>,
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
+5 -2
View File
@@ -6,6 +6,7 @@ import { AuthBootstrap } from './AuthBootstrap.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
@@ -31,6 +32,7 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
@@ -56,8 +58,9 @@ function reloadPage() {
window.location.reload()
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
// App expects to be rendered by a data router's splat route
// (createBrowserRouter in main.tsx, createMemoryRouter in tests), so pages can
// block navigation.
export default function App({ api, serviceWorker }: AppProps) {
const [queryClient] = useState(createQueryClient)
return (
+11 -4
View File
@@ -1,6 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { createBrowserRouter, RouterProvider } from 'react-router'
import './styles/tokens.css'
import './styles/base.css'
import App from './App.tsx'
@@ -14,10 +14,17 @@ if (!root) {
const api = createApiClient(window.location, createNetworkStatus(window), createVerifiedSession(window.localStorage))
// A data router, so pages can block navigation (useBlocker); App's own
// <Routes> match every path under its splat route.
const router = createBrowserRouter([
{
path: '*',
element: <App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />,
},
])
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App api={api} serviceWorker={'serviceWorker' in navigator ? navigator.serviceWorker : undefined} />
</BrowserRouter>
<RouterProvider router={router} />
</StrictMode>,
)
+249
View File
@@ -0,0 +1,249 @@
/* Admin · AI assistant, handoff mock 1o. */
.page {
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.loading {
height: 320px;
border-radius: 26px;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.model {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.mono {
font-family: var(--font-mono);
}
.hint,
.note,
.verified,
.error,
.status {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
}
.hint,
.note {
color: var(--color-neutral-700);
}
.verified {
font-weight: 700;
color: var(--color-accent-2-800);
}
.error {
font-weight: 700;
color: var(--color-accent-700);
}
.status {
color: var(--color-accent-2-800);
}
.status:empty {
display: none;
}
.key {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.keyRow {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 6px 0 20px;
border: 1px solid var(--color-neutral-300);
border-radius: var(--radius-pill);
background: var(--color-surface);
}
.keyValue {
flex: 1;
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.replace {
flex: none;
min-width: 44px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.replace:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.keyStatus {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0 8px;
}
.link {
min-width: 44px;
min-height: 44px;
padding: 0 4px;
border: 0;
background: transparent;
color: var(--color-accent-700);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
}
.temperature {
display: flex;
flex-direction: column;
}
.temperatureHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.value {
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
}
.ends {
display: flex;
justify-content: space-between;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-700);
}
.prompt {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.textarea {
box-sizing: border-box;
width: 100%;
min-height: 150px;
padding: 14px 18px;
border: 1px solid var(--color-neutral-300);
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
padding-top: 6px;
}
.reset,
.save {
min-height: 52px;
}
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.sheetTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.sheetText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
.sheetActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
+347
View File
@@ -0,0 +1,347 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx'
const MODELS = {
models: [
{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' },
{ id: 'claude-haiku-4-5', display_name: 'Claude Haiku' },
],
}
const DEFAULT_PROMPT = 'You are MealQuest, the default persona.'
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body; GET /settings/ai and /settings/ai/models
// answer AI_SETTINGS and MODELS unless overridden. Any other request fails
// the test.
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/ai in a data router, so its leave guard works.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<Routes>
<Route path="/settings/ai" element={<AISettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
await screen.findByRole('option', { name: 'Claude Sonnet' })
}
const provider = () => screen.getByRole('combobox', { name: 'Provider' })
const model = () => screen.getByRole('combobox', { name: 'Model' })
const prompt = () => screen.getByRole('textbox', { name: 'System prompt' })
const slider = () => screen.getByRole('slider', { name: 'Creativity (temperature)' })
const save = () => screen.getByRole('button', { name: 'Save changes' })
describe('AISettingsPage', () => {
it('loads the settings as mock 1o lays them out', async () => {
stubAPI()
await renderPage()
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
expect(screen.getByText('Settings · Admin')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'AI assistant' })).toBeInTheDocument()
expect(provider()).toHaveValue('anthropic')
expect(within(provider()).getByRole('option', { selected: true })).toHaveTextContent('Anthropic')
expect(model()).toHaveValue('claude-sonnet-4-5')
expect(within(model()).getAllByRole('option').map((o) => o.textContent)).toEqual([
'Claude Sonnet',
'Claude Haiku',
'Type a model id',
])
expect(screen.getByText('••••••••3f9a')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(screen.getByText('Verified · Oct 1')).toBeInTheDocument()
expect(slider()).toHaveValue('0.7')
expect(slider()).toHaveAttribute('step', '0.1')
expect(screen.getByText('0.7')).toBeInTheDocument()
expect(screen.getByText('Familiar')).toBeInTheDocument()
expect(screen.getByText('Adventurous')).toBeInTheDocument()
expect(prompt()).toHaveValue(AI_SETTINGS.system_prompt)
expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled()
expect(save()).toBeDisabled()
})
it('reports a load failure with a retry', async () => {
stubAPI({ 'GET /api/v1/settings/ai': () => errorResponse(500, 'internal') })
const api = createApiClient(fakeLocation('/settings/ai'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings/ai">
<AISettingsPage />
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
expect(await screen.findByRole('alert')).toHaveTextContent('AI settings could not load: internal message')
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('saves only the edited fields', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
fireEvent.change(model(), { target: { value: 'claude-haiku-4-5' } })
fireEvent.change(slider(), { target: { value: '0.4' } })
expect(screen.getByText('0.4')).toBeInTheDocument()
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
expect(save()).toBeEnabled()
fireEvent.click(save())
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/ai',
body: { model: 'claude-haiku-4-5', temperature: 0.4, system_prompt: 'Keep it cozy.' },
},
])
expect(save()).toBeDisabled()
expect(model()).toHaveValue('claude-haiku-4-5')
})
it('saves a typed model id', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(screen.queryByRole('textbox', { name: 'Model id' })).not.toBeInTheDocument()
fireEvent.change(model(), { target: { value: within(model()).getByRole('option', { name: 'Type a model id' }).getAttribute('value') } })
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'claude-opus-5-5' } })
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([{ request: 'PATCH /api/v1/settings/ai', body: { model: 'claude-opus-5-5' } }])
})
it('explains that a stored key must be removed before switching provider', async () => {
stubAPI({
'PATCH /api/v1/settings/ai': () =>
jsonResponse(400, {
error: {
code: 'invalid_settings',
message: 'invalid AI settings: provider: cannot change while an API key is stored: remove the key first',
retryable: false,
},
}),
})
await renderPage()
fireEvent.change(provider(), { target: { value: 'openai' } })
expect(screen.getByText(/Anthropic's API key is stored\. Remove it/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Replace' })).toBeDisabled()
fireEvent.change(screen.getByRole('textbox', { name: 'Model id' }), { target: { value: 'gpt-5' } })
fireEvent.click(save())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Not saved: invalid AI settings: provider: cannot change while an API key is stored')
expect(alert).toHaveTextContent('To switch provider, remove the API key first')
})
it('replaces the key in a sheet, then verifies it', async () => {
const verifiedAt = '2026-10-06T14:00:00.000Z'
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: verifiedAt }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
const input = within(sheet).getByLabelText('API key')
expect(input).toHaveAttribute('type', 'password')
fireEvent.change(input, { target: { value: 'sk-ant-secret-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByText(`Verified · ${formatVerifiedDate(verifiedAt)}`)).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByText('••••••••b7c1')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: 'sk-ant-secret-b7c1' } },
{ request: 'POST /api/v1/settings/ai/verify', body: undefined },
])
expect(document.body).not.toHaveTextContent('sk-ant-secret-b7c1')
})
it('shows the verify error and verifies again on request', async () => {
let verifyCalls = 0
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1', key_verified_at: null }),
'POST /api/v1/settings/ai/verify': () => {
verifyCalls++
return verifyCalls === 1
? errorResponse(502, 'auth_failed')
: jsonResponse(200, { ...AI_SETTINGS, api_key_last4: 'b7c1' })
},
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'sk-wrong-b7c1' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Verification failed: the provider rejected this key.')
expect(screen.queryByText(/^Verified ·/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByText('Verified · Oct 1')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('shows a refused key in the sheet', async () => {
stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(400, {
error: { code: 'invalid_settings', message: 'api_key: must be at least 8 characters', retryable: false },
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
const sheet = screen.getByRole('dialog', { name: 'Replace API key' })
fireEvent.change(within(sheet).getByLabelText('API key'), { target: { value: 'short' } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Save and verify' }))
expect(await within(sheet).findByRole('alert')).toHaveTextContent(
'The key was not stored: api_key: must be at least 8 characters',
)
})
it('removes the stored key from the sheet', async () => {
const fetchMock = stubAPI({
'PUT /api/v1/settings/ai/api-key': () =>
jsonResponse(200, { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Remove key' }))
expect(await screen.findByText('No key stored')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'PUT /api/v1/settings/ai/api-key', body: { api_key: '' } }])
})
it('resets the system prompt after confirming', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
expect(sent(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Reset prompt' }))
await waitFor(() => expect(prompt()).toHaveValue(DEFAULT_PROMPT))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/ai/reset-prompt', body: undefined }])
expect(save()).toBeDisabled()
})
it('asks before leaving with unsaved changes', async () => {
stubAPI()
await renderPage()
fireEvent.change(prompt(), { target: { value: 'Unsaved.' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(prompt()).toHaveValue('Unsaved.')
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('guards a page unload only while there are unsaved changes', async () => {
stubAPI()
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(unload()).toBe(false)
fireEvent.change(slider(), { target: { value: '0.2' } })
expect(unload()).toBe(true)
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
for (const control of [
screen.getByRole('button', { name: 'Back to Settings' }),
screen.getByRole('button', { name: 'Replace' }),
provider(),
model(),
slider(),
screen.getByRole('button', { name: 'Reset' }),
save(),
]) {
expectMinHitArea(control)
}
})
})
describe('maskedKey', () => {
it('shows only the last four characters', () => {
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
})
})
+550
View File
@@ -0,0 +1,550 @@
import { ChevronLeft } from 'lucide-react'
import { useEffect, useId, useState, type ReactNode } from 'react'
import { useBlocker, useNavigate } from 'react-router'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Slider } from '../ui/Slider.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
aiProviderLabels,
providersWithBaseURL,
useAIModels,
useAISettings,
useResetAIPrompt,
useSaveAISettings,
useSetAIKey,
useVerifyAIKey,
type AIProvider,
type AISettings,
type AISettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
const TYPE_MODEL = '\u0000type'
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
// system prompt are edited as a draft and saved together; the API key is
// stored (and verified) on its own, straight away. The client only ever
// sees the key's last four characters.
export function AISettingsPage() {
const settings = useAISettings()
return (
<div className={styles.page}>
<Header />
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
AI settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
</div>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings · Admin</p>
<h1 className={styles.title}>AI assistant</h1>
</div>
</header>
)
}
type Draft = {
provider: AIProvider
base_url: string
model: string
temperature: number
system_prompt: string
}
function draftOf(saved: AISettings): Draft {
return {
provider: saved.provider,
base_url: saved.base_url,
model: saved.model,
temperature: saved.temperature,
system_prompt: saved.system_prompt ?? '',
}
}
// patchOf is the PATCH body for the fields draft changes.
function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
const before = draftOf(saved)
const patch: AISettingsPatch = {}
if (draft.provider !== before.provider) patch.provider = draft.provider
if (draft.base_url !== before.base_url) patch.base_url = draft.base_url
if (draft.model !== before.model) patch.model = draft.model
if (draft.temperature !== before.temperature) patch.temperature = draft.temperature
if (draft.system_prompt !== before.system_prompt) patch.system_prompt = draft.system_prompt
return patch
}
function AISettingsForm({ saved }: { saved: AISettings }) {
const [draft, setDraft] = useState(() => draftOf(saved))
const [typingModel, setTypingModel] = useState(false)
const [confirmingReset, setConfirmingReset] = useState(false)
const save = useSaveAISettings()
const resetPrompt = useResetAIPrompt()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
const providerChanged = draft.provider !== saved.provider
const temperatureId = useId()
// The model list is the saved provider's, so it is asked for only while
// that provider is chosen and has the key it needs.
const canList =
!providerChanged && (aiKeyPolicy[saved.provider] !== 'required' || saved.api_key_last4 !== null)
const models = useAIModels(canList)
const listed = canList && models.isSuccess ? models.data : []
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
const blocker = useBlocker(
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
)
useEffect(() => {
if (!dirty) return
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [dirty])
function edit(change: Partial<Draft>) {
save.reset()
setDraft((current) => ({ ...current, ...change }))
}
function chooseProvider(provider: AIProvider) {
setTypingModel(provider !== saved.provider)
if (provider === saved.provider) {
edit({ provider, base_url: saved.base_url, model: saved.model })
return
}
// A fixed-URL preset refuses any other base URL, and the old model is
// another provider's: the household types the new one.
edit({ provider, base_url: providersWithBaseURL.has(provider) ? draft.base_url : '', model: '' })
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
setTypingModel(false)
},
})
}
function onConfirmReset() {
resetPrompt.mutate(undefined, {
onSuccess: (settings) => {
setDraft((current) => ({ ...current, system_prompt: settings.system_prompt ?? '' }))
setConfirmingReset(false)
},
})
}
return (
<>
<div className={styles.columns}>
<Select
label="Provider"
value={draft.provider}
onChange={(event) => chooseProvider(event.target.value as AIProvider)}
>
{Object.entries(aiProviderLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
<div className={styles.model}>
<Select
label="Model"
value={typedModel ? TYPE_MODEL : draft.model}
onChange={(event) => {
const model = event.target.value
setTypingModel(model === TYPE_MODEL)
if (model !== TYPE_MODEL) edit({ model })
}}
>
{!typedModel && draft.model === '' && (
<option value="" disabled>
Choose a model
</option>
)}
{listed.map((model) => (
<option key={model.id} value={model.id}>
{model.display_name}
</option>
))}
<option value={TYPE_MODEL}>Type a model id</option>
</Select>
{canList && models.isPending && <p className={styles.note}>Loading models…</p>}
{canList && models.isError && <p className={styles.note}>Model list unavailable: {models.error.message}</p>}
{!canList && !providerChanged && (
<p className={styles.note}>Add the API key to choose from the provider's models.</p>
)}
</div>
</div>
{providerChanged && saved.api_key_last4 !== null && (
<p className={styles.hint}>
{aiProviderLabels[saved.provider]}'s API key is stored. Remove it (Replace → Remove key) before switching
provider.
</p>
)}
{typedModel && (
<TextInput
label="Model id"
className={styles.mono}
value={draft.model}
placeholder="e.g. claude-sonnet-4-5"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ model: event.target.value })}
/>
)}
{providersWithBaseURL.has(draft.provider) && (
<TextInput
label="Base URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
)}
{aiKeyPolicy[draft.provider] === 'none' ? (
<p className={styles.hint}>{aiProviderLabels[draft.provider]} takes no API key.</p>
) : (
<APIKey saved={saved} providerChanged={providerChanged} />
)}
<div className={styles.temperature}>
<div className={styles.temperatureHeader}>
<span id={temperatureId} className={styles.label}>
Creativity (temperature)
</span>
<output htmlFor={temperatureId} className={styles.value}>
{draft.temperature.toFixed(1)}
</output>
</div>
<Slider
aria-labelledby={temperatureId}
aria-valuetext={`${draft.temperature.toFixed(1)}, from Familiar (0) to Adventurous (1)`}
value={draft.temperature}
min={0}
max={1}
step={0.1}
onChange={(event) => edit({ temperature: Math.round(Number(event.target.value) * 10) / 10 })}
/>
<div className={styles.ends} aria-hidden="true">
<span>Familiar</span>
<span>Adventurous</span>
</div>
</div>
<PromptField value={draft.system_prompt} onChange={(system_prompt) => edit({ system_prompt })} />
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Changes saved.'}
</div>
{save.isError && <p className={styles.error} role="alert">{saveErrorMessage(save.error)}</p>}
{resetPrompt.isError && !confirmingReset && (
<p className={styles.error} role="alert">
The system prompt was not reset: {resetPrompt.error.message}
</p>
)}
<div className={styles.actions}>
<Button variant="outline" className={styles.reset} onClick={() => setConfirmingReset(true)}>
Reset
</Button>
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
<Confirm
open={confirmingReset}
title="Reset the system prompt?"
confirmLabel={resetPrompt.isPending ? 'Resetting…' : 'Reset prompt'}
busy={resetPrompt.isPending}
error={resetPrompt.isError ? `The system prompt was not reset: ${resetPrompt.error.message}` : null}
onCancel={() => setConfirmingReset(false)}
onConfirm={onConfirmReset}
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes to the AI assistant have not been saved.
</Confirm>
</>
)
}
// 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)
}
function APIKey({ saved, providerChanged }: { saved: AISettings; providerChanged: boolean }) {
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
function close() {
setReplacing(false)
setKey('')
store.reset()
}
function onStore() {
verify.reset()
store.mutate(key, {
onSuccess: () => {
close()
verify.mutate(undefined)
},
})
}
function onRemove() {
verify.reset()
store.mutate('', { onSuccess: close })
}
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{last4 === null ? 'No key stored' : maskedKey(last4)}</span>
<button
type="button"
className={styles.replace}
disabled={providerChanged}
onClick={() => setReplacing(true)}
>
{last4 === null ? 'Add key' : 'Replace'}
</button>
</div>
{providerChanged && <p className={styles.note}>Save the provider change before adding its key.</p>}
<KeyStatus saved={saved} verify={verify} />
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onStore()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add API key' : 'Replace API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server for {aiProviderLabels[saved.provider]}. This device only ever sees its
last four characters.
</p>
<TextInput
label="API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
{store.isError && (
<p className={styles.error} role="alert">
The key was not stored: {store.error.message}
</p>
)}
<div className={styles.sheetActions}>
{last4 !== null && (
<Button variant="outline" disabled={store.isPending} onClick={onRemove}>
Remove key
</Button>
)}
<Button type="submit" disabled={key === '' || store.isPending}>
{store.isPending ? 'Saving…' : 'Save and verify'}
</Button>
</div>
</form>
</Sheet>
</div>
)
}
type KeyStatusProps = {
saved: AISettings
verify: ReturnType<typeof useVerifyAIKey>
}
// 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 <p className={styles.note}>Verifying…</p>
if (verify.isError) {
return (
<div className={styles.keyStatus}>
<p className={styles.error} role="alert">
{verifyErrorMessage(verify.error)}
</p>
<VerifyButton verify={verify}>Try again</VerifyButton>
</div>
)
}
if (saved.key_verified_at !== null) {
return <p className={styles.verified}>Verified · {formatVerifiedDate(saved.key_verified_at)}</p>
}
return (
<div className={styles.keyStatus}>
<p className={styles.note}>Not verified yet</p>
<VerifyButton verify={verify}>Verify</VerifyButton>
</div>
)
}
function VerifyButton({ verify, children }: { verify: KeyStatusProps['verify']; children: ReactNode }) {
return (
<button type="button" className={styles.link} onClick={() => verify.mutate(undefined)}>
{children}
</button>
)
}
// verifyErrorMessage says why POST /settings/ai/verify failed, by the
// error code the server names (internal/server/aisettings.go).
export function verifyErrorMessage(error: Error): string {
if (!(error instanceof ApiError)) return `Verification failed: ${error.message}`
switch (error.code) {
case 'auth_failed':
return 'Verification failed: the provider rejected this key.'
case 'rate_limited':
return 'Verification failed: the provider is rate limiting requests. Try again shortly.'
case 'timeout':
return 'Verification failed: the provider did not answer in time.'
case 'settings_changed':
return 'Verification failed: the settings changed while verifying. Try again.'
default:
return `Verification failed: ${error.message}`
}
}
// saveErrorMessage says why a save failed. The server refuses to move a
// stored key to another provider or base URL; it names how to fix that.
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') {
const guidance = error.message.includes('while an API key is stored')
? ' To switch provider, remove the API key first (Replace → Remove key).'
: ''
return `Not saved: ${error.message}.${guidance}`
}
return `Not saved: ${error.message}`
}
function PromptField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const id = useId()
return (
<div className={styles.prompt}>
<label htmlFor={id} className={styles.label}>
System prompt
</label>
<textarea
id={id}
className={styles.textarea}
value={value}
rows={8}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
type ConfirmProps = {
open: boolean
title: string
children: ReactNode
confirmLabel: string
cancelLabel?: string
busy?: boolean
error?: string | null
onCancel: () => void
onConfirm: () => void
}
function Confirm({
open,
title,
children,
confirmLabel,
cancelLabel = 'Cancel',
busy = false,
error = null,
onCancel,
onConfirm,
}: ConfirmProps) {
const titleId = useId()
return (
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.sheetTitle}>
{title}
</h2>
<p className={styles.sheetText}>{children}</p>
{error !== null && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.sheetActions}>
<Button variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
<Button disabled={busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</Sheet>
)
}
+5 -5
View File
@@ -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')
+148 -19
View File
@@ -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<AIProvider> = 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<AIProvider, 'required' | 'optional' | 'none'> = {
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<Pick<AISettings, 'provider' | 'base_url' | 'model' | 'temperature'>> & {
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<unknown>(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<unknown>(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<V>(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<unknown>(path, { method, body: body(variables) })),
onSuccess: (settings) => {
const before = queryClient.getQueryData<AISettings>(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<AISettings, 'provider' | 'model'>): string {
const provider = aiProviderLabels[settings.provider]
if (settings.model === '') return `${provider} · No model chosen`
return `${provider} · ${modelDisplayName(settings.model)}`
+13 -1
View File
@@ -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
+5 -1
View File
@@ -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))
}
+10
View File
@@ -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 <RouterProvider router={router} />
}
+50
View File
@@ -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;
}
+38
View File
@@ -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(
<Select label="Provider" value="a" onChange={onChange}>
<option value="a">Alpha</option>
<option value="b">Beta</option>
</Select>,
)
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(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expectMinHitArea(screen.getByRole('combobox', { name: 'Model' }))
})
it('hides its chevron from assistive technology', () => {
const { container } = render(
<Select label="Model" defaultValue="a">
<option value="a">Alpha</option>
</Select>,
)
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
})
+27
View File
@@ -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<ComponentPropsWithRef<'select'>, '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 (
<div className={styles.field}>
<label htmlFor={id} className={styles.label}>
{label}
</label>
<span className={styles.control}>
<select id={id} className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
{children}
</select>
<Icon icon={ChevronDown} className={styles.chevron} />
</span>
</div>
)
}
+1
View File
@@ -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'