Show no_ideas_found as the ErrorBanner with Retry (Feature 18, mock 1i)
CI / ci (push) Successful in 5m30s
CI / ci (pull_request) Successful in 5m38s

A failed ideas job always shows the ErrorBanner with Retry and no cards,
no_ideas_found included. The failure messages and the 404-means-search-
again rule move to ideas/api.ts (ideasJobFailure, ideasJobLost) for the
Quick dinners screen to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:43:51 -04:00
co-authored by Claude Opus 5.5
parent 45b104de66
commit dc81c634a3
3 changed files with 39 additions and 27 deletions
+11 -5
View File
@@ -471,18 +471,24 @@ describe('CookFromPantryPage', () => {
expect(await screen.findByRole('alert')).toHaveTextContent(message)
})
it('says no ideas were found, with no cards', async () => {
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
jobs.j2 = state('failed', 'grounding', [], false, 'no_ideas_found')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('heading', { name: 'No ideas found' })).toBeInTheDocument()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
+4 -21
View File
@@ -6,7 +6,6 @@ import { duration } from '../plan/PlanPage.tsx'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { Chip } from '../ui/Chip.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
@@ -16,6 +15,8 @@ import { TextInput } from '../ui/TextInput.tsx'
import {
FROM_INGREDIENTS_JOB_KIND,
IDEAS_KEPT,
ideasJobFailure,
ideasJobLost,
INGREDIENT_LIMIT,
MAX_INGREDIENTS,
parseIdeasResult,
@@ -42,16 +43,6 @@ const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
grounding: 'Finding recipes for them…',
}
// searchFailures says why a search failed, by its failure code. Any other
// code is shown as it is; no_ideas_found is not a failure to fix, and has
// its own empty state.
const searchFailures: Record<string, string> = {
ai_failed: "The AI assistant couldn't come up with ideas.",
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
internal_error: 'Something went wrong on the server.',
}
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
function fold(ingredient: string): string {
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
@@ -235,15 +226,7 @@ export function CookFromPantryPage() {
} else if (findIdeas.isError) {
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
} else if (jobId !== null && job.status === 'failed') {
const code = job.error!.message
results =
code === 'no_ideas_found' ? (
<EmptyState title="No ideas found" action={{ label: 'Retry', onClick: retry }}>
No recipe matched {asked!.quickOnly ? 'a quick dinner from ' : ''}those ingredients. Add a few more and try again.
</EmptyState>
) : (
<ErrorBanner onRetry={retry}>Couldn't find ideas. {searchFailures[code] ?? code}</ErrorBanner>
)
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
} else if (found.error !== null) {
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
} else if (jobId !== null) {
@@ -251,7 +234,7 @@ export function CookFromPantryPage() {
const done = job.status === 'succeeded'
// A job not found will not be found by asking again: Retry searches
// again. Any other failed poll is asked again.
const lost = job.error instanceof ApiError && job.error.status === 404
const lost = ideasJobLost(job.error)
results = (
<>
{job.error !== null && (
+24 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { parseDinner } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
@@ -49,6 +49,29 @@ export type IdeasResult = {
dropped: number
}
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
// no_ideas_found is no fault to fix, but the search still came back empty.
const IDEAS_JOB_FAILURES: Record<string, string> = {
no_ideas_found: 'No ideas came back with a recipe to match.',
ai_failed: "The AI assistant couldn't come up with ideas.",
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
search_failed: "The recipe search couldn't be reached.",
internal_error: 'Something went wrong on the server.',
}
// ideasJobFailure says why an ideas job failed; an unknown code is shown
// as it is.
export function ideasJobFailure(code: string): string {
return IDEAS_JOB_FAILURES[code] ?? code
}
// ideasJobLost says a poll of an ideas job failed because the server no
// longer has the job (404), so asking for it again will not find it: the
// search must be started again.
export function ideasJobLost(error: Error | null): boolean {
return error instanceof ApiError && error.status === 404
}
// Search is what Find ideas asks for.
export type Search = { ingredients: string[]; quickOnly: boolean }