feat: Offline AI Plant/Wildlife Identification#1906
Conversation
1 similar comment
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
|
The Biome CI shows Is there something specific you'd like me to address or change? The wildlife identification feature is fully implemented and all new files pass lint checks. Let me know if you have any specific requests or feedback! |
|
@coderabbitai Please review this PR for code quality, best practices, and potential issues. |
|
✅ Actions performedFull review triggered. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughA new wildlife identification feature is introduced with offline and online identification capabilities. Includes route components, UI screens, state management via Jotai atoms, custom hooks, an API endpoint with OpenAI integration, offline species database with search utilities, and internationalization support for the feature. Changes
Sequence DiagramssequenceDiagram
participant User
participant ExpoCli as Expo Client
participant OfflineID as Offline Identifier
participant APIServer as API Server
participant OpenAI as OpenAI Service
participant Database as Species Database
User->>ExpoCli: Select image & enter description
ExpoCli->>OfflineID: identifyFromDescription(description)
OfflineID->>Database: searchSpecies(query)
Database-->>OfflineID: Matching species
OfflineID-->>ExpoCli: Offline results (fallback)
ExpoCli->>APIServer: POST /api/wildlife/identify (image)
alt Network Available
APIServer->>OpenAI: generateObject(schema, image)
OpenAI-->>APIServer: SpeciesResult[]
APIServer-->>ExpoCli: Online results
else Network Error
APIServer-->>ExpoCli: Error (fallback to offline)
Note over ExpoCli: Use offline results
end
ExpoCli->>Database: Save to history (wildlifeHistoryAtom)
ExpoCli->>User: Display results & save history
sequenceDiagram
participant User
participant ExpoCli as Expo Client
participant History as Wildlife History (Jotai)
participant DetailScreen as Species Detail Screen
participant Database as Species Database
User->>ExpoCli: Navigate to Wildlife screen
ExpoCli->>History: useWildlifeHistory() load history
History-->>ExpoCli: WildlifeIdentification[]
ExpoCli->>User: Display history list
User->>ExpoCli: Tap history item
ExpoCli->>DetailScreen: Navigate /wildlife/[id]
DetailScreen->>Database: getSpeciesById(id)
alt Found in Database
Database-->>DetailScreen: SpeciesEntry
else Not in Database
DetailScreen->>History: Fallback to history entry
History-->>DetailScreen: Species from history
end
DetailScreen->>User: Display species detail
Estimated code review effort🎯 4 (Complex) | ⏱️ ~65 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
✅ Actions performedFull review triggered. |
|
@copilot CodeRabbit has provided review feedback on this PR. Please address the comments and suggestions. Once you've made the necessary changes, I'll ask CodeRabbit to re-review. |
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (1)
apps/expo/features/wildlife/types.ts (1)
29-32: Make theconfidencecontract explicit.
SpeciesCardmultiplies this value by 100, so a plainnumberhere leaves every producer guessing whether the API returns0..1or0..100. Please document or narrow the range at the type boundary.💡 Suggested tightening
export interface IdentificationResult { species: SpeciesEntry; + /** Normalized confidence score in the range [0, 1]. */ confidence: number; source: 'online' | 'offline'; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/types.ts` around lines 29 - 32, The IdentificationResult.confidence field is ambiguous (0..1 vs 0..100); update the contract to explicitly represent a 0..1 probability by replacing the plain number with a constrained/Branded type (e.g., Confidence0To1 or type alias with a descriptive JSDoc) and update usages like SpeciesCard to rely on that 0..1 contract (it currently multiplies by 100). Change the IdentificationResult interface (symbol: IdentificationResult) to use the new Confidence type and add a brief JSDoc comment clarifying the range so producers and consumers know the expected 0..1 probability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/expo/features/wildlife/atoms/wildlifeAtoms.ts`:
- Around line 6-17: The current wildlifeStorage wrapper manually calls
JSON.parse/JSON.stringify around AsyncStorage causing double-encoding and
hydration failures; replace the custom adapter by passing AsyncStorage directly
into createJSONStorage (i.e., use createJSONStorage with AsyncStorage as the
storage backend) and remove the getItem/setItem/removeItem JSON serialization
logic so createJSONStorage can handle parsing/stringifying itself; update the
wildlifeStorage declaration (symbol: wildlifeStorage) to use createJSONStorage
with AsyncStorage.
In `@apps/expo/features/wildlife/components/SpeciesCard.tsx`:
- Around line 1-3: SpeciesCard is rendering enum-backed values (source,
dangerLevel, category) raw; replace those direct uses with translated labels by
mapping each enum to an i18n key or using the app's t()/translate utility inside
SpeciesCard (or a helper map) to render user-facing strings (e.g., map
IdentificationResult.source values like "offline" to translation keys such as
"species.source.offline"); update all usages of source, dangerLevel, and
category in the SpeciesCard render (lines previously flagged ~38-59) to call the
translator or lookup map so non-English locales show localized labels.
In `@apps/expo/features/wildlife/data/speciesDatabase.ts`:
- Around line 494-502: searchSpecies currently treats blank/whitespace queries
as matches because it lowercases without checking for emptiness; modify
searchSpecies to trim the incoming query and immediately return an empty array
if the trimmed string is empty, otherwise proceed with the existing lowercase
match logic against SPECIES_DATABASE (references: searchSpecies,
SPECIES_DATABASE, commonName, scientificName, description, characteristics).
In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts`:
- Line 57: The offline query selection currently uses the nullish coalescing
operator so an empty description string ("" ) wins; update the logic in
useWildlifeIdentification where identifyFromDescription is called so
blank/whitespace descriptions are treated as absent—e.g., evaluate offlineQuery
with a trim check (use offlineQuery && offlineQuery.trim().length > 0) and pass
offlineQuery only when non-empty, otherwise pass selectedImage.fileName to
identifyFromDescription.
- Line 56: In useWildlifeIdentification, replace the current raw error logging
in the catch/fallback that calls console.warn('Online identification
unavailable, using offline database:', error) with a sanitized log that only
includes non-sensitive fields (e.g., error?.code and error?.message) or remove
the error payload entirely; update the console.warn invocation in that block so
it doesn't print the full Axios error object.
In `@apps/expo/features/wildlife/screens/IdentificationScreen.tsx`:
- Line 20: The selectedImage URI is being stored directly instead of a persisted
local filename; update the useImagePicker destructure to include
permanentlyPersistImageLocally, call
permanentlyPersistImageLocally(selectedImage) just before invoking
addIdentification, and pass the returned persisted filename (not
selectedImage.uri) into addIdentification; reference the useImagePicker hook,
the permanentlyPersistImageLocally function, the selectedImage object, and the
addIdentification call to locate where to change the code.
In `@apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx`:
- Around line 30-42: The fallback lookup currently finds a history entry by
history entry id (historyLoadable.data.find(h => h.id === id)) which fails when
the route id is a species id; change the fallback to search historyLoadable.data
for any entry whose results array contains a result with result.species.id ===
id (update the logic that sets historyEntry/displaySpecies accordingly), and
ensure you do not render the not-found UI until wildlife history has finished
loading (check historyLoadable.state === 'hasData' before showing the
speciesNotFound view and render a loading state while it is still pending).
In `@apps/expo/features/wildlife/screens/WildlifeScreen.tsx`:
- Line 34: The hardcoded fallback label "Unknown species" in WildlifeScreen
should be localized; replace the literal Text node with the i18n lookup used
elsewhere (call t with an appropriate key like t('wildlife.unknownSpecies')),
add that key to the translations files under the wildlife namespace in all
locales, and update the component to use t('wildlife.unknownSpecies') instead of
the hardcoded string so the fallback label is localized across languages.
- Around line 48-52: The code collapses non-'hasData' loadable states into an
empty array—update handling of wildlifeHistoryAtom/historyLoadable so you check
for historyLoadable.state === 'hasError' and surface an error UI (or message)
before falling back to empty array and ensure the same explicit error branch is
used in the rendering logic around the 84-114 conditional block; additionally
remove the hardcoded "Unknown species" string by either moving useTranslation()
into the HistoryItem component or passing the t function into HistoryItem and
replacing the literal with a translation key (e.g.,
t('wildlife.unknownSpecies')) so the fallback is localized.
In `@packages/api/src/routes/wildlife/index.ts`:
- Around line 110-114: The temp-upload deletion currently runs only after a
successful call to WildlifeIdentificationService.identifySpecies and can leak or
convert a success into a 500; refactor the handler so the model call
(service.identifySpecies(imageUrl)) runs inside a try block, move
PACKRAT_BUCKET.delete(image) into a finally block and make the delete
best-effort (catch/log any errors from PACKRAT_BUCKET.delete so they don't
change the response), ensuring the identification result (identification) is
returned or the model error is handled independently of the upload cleanup.
- Around line 142-149: The 500-response currently echoes internal error details
via c.json({ error: `Failed to identify species: ${error.message}` }, 500);
update the 500 branch in the error handler (the conditional that checks error
instanceof Error and the current return c.json(...) for 500) to return a generic
message (e.g., { error: 'Internal server error' } or { error: 'Failed to
identify species' }) without including error.message, while leaving the 400
branch (Invalid image / Unsupported image format) and existing logging/Sentry
reporting unchanged.
---
Nitpick comments:
In `@apps/expo/features/wildlife/types.ts`:
- Around line 29-32: The IdentificationResult.confidence field is ambiguous
(0..1 vs 0..100); update the contract to explicitly represent a 0..1 probability
by replacing the plain number with a constrained/Branded type (e.g.,
Confidence0To1 or type alias with a descriptive JSDoc) and update usages like
SpeciesCard to rely on that 0..1 contract (it currently multiplies by 100).
Change the IdentificationResult interface (symbol: IdentificationResult) to use
the new Confidence type and add a brief JSDoc comment clarifying the range so
producers and consumers know the expected 0..1 probability.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4590d1c7-0d37-45bf-9591-7478eafc8075
📒 Files selected for processing (25)
apps/expo/app/(app)/(tabs)/(home)/index.tsxapps/expo/app/(app)/wildlife/[id].tsxapps/expo/app/(app)/wildlife/identify.tsxapps/expo/app/(app)/wildlife/index.tsxapps/expo/config.tsapps/expo/features/wildlife/atoms/wildlifeAtoms.tsapps/expo/features/wildlife/components/SpeciesCard.tsxapps/expo/features/wildlife/components/WildlifeTile.tsxapps/expo/features/wildlife/components/index.tsapps/expo/features/wildlife/data/speciesDatabase.tsapps/expo/features/wildlife/hooks/index.tsapps/expo/features/wildlife/hooks/useWildlifeHistory.tsapps/expo/features/wildlife/hooks/useWildlifeIdentification.tsapps/expo/features/wildlife/index.tsapps/expo/features/wildlife/lib/offlineIdentifier.tsapps/expo/features/wildlife/screens/IdentificationScreen.tsxapps/expo/features/wildlife/screens/SpeciesDetailScreen.tsxapps/expo/features/wildlife/screens/WildlifeScreen.tsxapps/expo/features/wildlife/screens/index.tsapps/expo/features/wildlife/types.tsapps/expo/lib/i18n/locales/en.jsonpackages/api/src/routes/index.tspackages/api/src/routes/wildlife/index.tspackages/api/src/services/index.tspackages/api/src/services/wildlifeIdentificationService.ts
| const wildlifeStorage = createJSONStorage<WildlifeIdentification[]>(() => ({ | ||
| getItem: async (key: string) => { | ||
| const value = await AsyncStorage.getItem(key); | ||
| return value ? JSON.parse(value) : null; | ||
| }, | ||
| setItem: async (key: string, value: unknown) => { | ||
| await AsyncStorage.setItem(key, JSON.stringify(value)); | ||
| }, | ||
| removeItem: async (key: string) => { | ||
| await AsyncStorage.removeItem(key); | ||
| }, | ||
| })); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Jotai createJSONStorage AsyncStorage example and whether createJSONStorage already performs JSON.parse/JSON.stringify for the underlying storage adapter
💡 Result:
React Native AsyncStorage example (Jotai)
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
import AsyncStorage from '@react-native-async-storage/async-storage'
const storage = createJSONStorage(() => AsyncStorage)
const storedAtom = atomWithStorage('stored-key', { hello: 'world' }, storage)
// If you want the stored value immediately on init:
// atomWithStorage('stored-key', { hello: 'world' }, storage, { getOnInit: true })This is the pattern shown in Jotai’s Storage docs under “React-Native implementation.” [1]
Does createJSONStorage already do JSON.parse / JSON.stringify?
Yes.
- The docs explicitly describe
createJSONStorageas the utility to create a storage implementation “withJSON.stringify()/JSON.parse().” [1] - The actual implementation wraps a string storage and:
getItemreads a string and runsJSON.parse(...)setItemwritesJSON.stringify(...)[2]
So your underlying adapter (e.g., React Native AsyncStorage) should read/write strings; you should not pre-stringify values yourself when using createJSONStorage. [1][2]
Practical implication for AsyncStorage atoms
Because AsyncStorage is async, the atom value can become async; Jotai’s docs note you may need to await the current value when updating based on previous state. [1]
const countAtom = atomWithStorage('count-key', 0, storage)
setCount(async (promiseOrValue) => (await promiseOrValue) + 1)Sources
[1] Jotai docs – Storage utilities (atomWithStorage, createJSONStorage, React Native example)
[2] Jotai source – createJSONStorage implementation (JSON.parse/JSON.stringify)
🏁 Script executed:
# Check if the file exists
fd "wildlifeAtoms.ts" apps/expo
# If found, check the specific lines
cat -n apps/expo/features/wildlife/atoms/wildlifeAtoms.ts | head -20Repository: PackRat-AI/PackRat
Length of output: 1010
Remove manual JSON serialization from the storage adapter.
createJSONStorage already handles JSON.parse() on read and JSON.stringify() on write. The manual serialization here causes double-encoding on writes and breaks hydration on reads. Pass AsyncStorage directly instead.
🐛 Suggested fix
-const wildlifeStorage = createJSONStorage<WildlifeIdentification[]>(() => ({
- getItem: async (key: string) => {
- const value = await AsyncStorage.getItem(key);
- return value ? JSON.parse(value) : null;
- },
- setItem: async (key: string, value: unknown) => {
- await AsyncStorage.setItem(key, JSON.stringify(value));
- },
- removeItem: async (key: string) => {
- await AsyncStorage.removeItem(key);
- },
-}));
+const wildlifeStorage = createJSONStorage<WildlifeIdentification[]>(() => AsyncStorage);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/atoms/wildlifeAtoms.ts` around lines 6 - 17, The
current wildlifeStorage wrapper manually calls JSON.parse/JSON.stringify around
AsyncStorage causing double-encoding and hydration failures; replace the custom
adapter by passing AsyncStorage directly into createJSONStorage (i.e., use
createJSONStorage with AsyncStorage as the storage backend) and remove the
getItem/setItem/removeItem JSON serialization logic so createJSONStorage can
handle parsing/stringifying itself; update the wildlifeStorage declaration
(symbol: wildlifeStorage) to use createJSONStorage with AsyncStorage.
| import { Text } from '@packrat/ui/nativewindui'; | ||
| import { Pressable, View } from 'react-native'; | ||
| import type { IdentificationResult } from '../types'; |
There was a problem hiding this comment.
Translate enum-backed labels instead of rendering raw values.
source, dangerLevel, and category are user-facing here, but they render the internal enum values directly. That will leave strings like offline and dangerous untranslated in non-English locales.
🌐 Suggested fix
+import { useTranslation } from 'react-i18next';
import { Text } from '@packrat/ui/nativewindui';
import { Pressable, View } from 'react-native';
import type { IdentificationResult } from '../types';
@@
export function SpeciesCard({ result, onPress }: SpeciesCardProps) {
+ const { t } = useTranslation();
const { species, confidence, source } = result;
@@
<Text className="text-sm font-medium text-primary">{confidencePercent}%</Text>
- <Text className="text-xs text-muted-foreground">{source}</Text>
+ <Text className="text-xs text-muted-foreground">
+ {t(`wildlife.source.${source}`)}
+ </Text>
</View>
)}
@@
<Text
className={`text-xs font-medium capitalize ${DANGER_TEXT_COLORS[species.dangerLevel]}`}
>
- {species.dangerLevel}
+ {t(`wildlife.dangerLevel.${species.dangerLevel}`)}
</Text>
</View>
<View className="px-2 py-0.5 rounded-full bg-primary/10">
- <Text className="text-xs font-medium capitalize text-primary">{species.category}</Text>
+ <Text className="text-xs font-medium capitalize text-primary">
+ {t(`wildlife.category.${species.category}`)}
+ </Text>
</View>Also applies to: 38-59
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/components/SpeciesCard.tsx` around lines 1 - 3,
SpeciesCard is rendering enum-backed values (source, dangerLevel, category) raw;
replace those direct uses with translated labels by mapping each enum to an i18n
key or using the app's t()/translate utility inside SpeciesCard (or a helper
map) to render user-facing strings (e.g., map IdentificationResult.source values
like "offline" to translation keys such as "species.source.offline"); update all
usages of source, dangerLevel, and category in the SpeciesCard render (lines
previously flagged ~38-59) to call the translator or lookup map so non-English
locales show localized labels.
| export function searchSpecies(query: string): SpeciesEntry[] { | ||
| const lower = query.toLowerCase(); | ||
| return SPECIES_DATABASE.filter( | ||
| (s) => | ||
| s.commonName.toLowerCase().includes(lower) || | ||
| s.scientificName.toLowerCase().includes(lower) || | ||
| s.description.toLowerCase().includes(lower) || | ||
| s.characteristics.some((c) => c.toLowerCase().includes(lower)), | ||
| ); |
There was a problem hiding this comment.
Return no matches for blank offline queries.
apps/expo/features/wildlife/screens/IdentificationScreen.tsx, Lines 65-67, forwards descriptionText even when it is still empty. With the current implementation, a blank or whitespace-only query matches the whole database, so offline fallback can show every species as a “result” instead of the intended empty state.
🐛 Suggested fix
export function searchSpecies(query: string): SpeciesEntry[] {
- const lower = query.toLowerCase();
+ const lower = query.trim().toLowerCase();
+ if (!lower) return [];
return SPECIES_DATABASE.filter(
(s) =>
s.commonName.toLowerCase().includes(lower) ||📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function searchSpecies(query: string): SpeciesEntry[] { | |
| const lower = query.toLowerCase(); | |
| return SPECIES_DATABASE.filter( | |
| (s) => | |
| s.commonName.toLowerCase().includes(lower) || | |
| s.scientificName.toLowerCase().includes(lower) || | |
| s.description.toLowerCase().includes(lower) || | |
| s.characteristics.some((c) => c.toLowerCase().includes(lower)), | |
| ); | |
| export function searchSpecies(query: string): SpeciesEntry[] { | |
| const lower = query.trim().toLowerCase(); | |
| if (!lower) return []; | |
| return SPECIES_DATABASE.filter( | |
| (s) => | |
| s.commonName.toLowerCase().includes(lower) || | |
| s.scientificName.toLowerCase().includes(lower) || | |
| s.description.toLowerCase().includes(lower) || | |
| s.characteristics.some((c) => c.toLowerCase().includes(lower)), | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/data/speciesDatabase.ts` around lines 494 - 502,
searchSpecies currently treats blank/whitespace queries as matches because it
lowercases without checking for emptiness; modify searchSpecies to trim the
incoming query and immediately return an empty array if the trimmed string is
empty, otherwise proceed with the existing lowercase match logic against
SPECIES_DATABASE (references: searchSpecies, SPECIES_DATABASE, commonName,
scientificName, description, characteristics).
| // Only fall back to offline identification for network/connectivity errors. | ||
| // Authorization errors, validation failures, etc. are re-thrown. | ||
| if (isNetworkError(error)) { | ||
| console.warn('Online identification unavailable, using offline database:', error); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd "useWildlifeIdentification.ts" --type fRepository: PackRat-AI/PackRat
Length of output: 124
🏁 Script executed:
cat -n apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts | head -80Repository: PackRat-AI/PackRat
Length of output: 2671
Log only sanitized error information to avoid exposing sensitive data.
console.warn(..., error) at line 56 logs the raw Axios error object, which can contain request config with auth headers, signed URLs, and other sensitive metadata. Since this fallback path is expected on connectivity loss, log only the error code and message:
console.warn('Online identification unavailable, using offline database:', {
code: error?.code,
message: error?.message,
});Alternatively, omit the error details entirely if they're not needed for debugging.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts` at line 56,
In useWildlifeIdentification, replace the current raw error logging in the
catch/fallback that calls console.warn('Online identification unavailable, using
offline database:', error) with a sanitized log that only includes non-sensitive
fields (e.g., error?.code and error?.message) or remove the error payload
entirely; update the console.warn invocation in that block so it doesn't print
the full Axios error object.
| // Authorization errors, validation failures, etc. are re-thrown. | ||
| if (isNetworkError(error)) { | ||
| console.warn('Online identification unavailable, using offline database:', error); | ||
| return identifyFromDescription(offlineQuery ?? selectedImage.fileName); |
There was a problem hiding this comment.
Treat blank descriptions as absent before choosing the offline query.
apps/expo/features/wildlife/screens/IdentificationScreen.tsx initializes descriptionText to '' and always passes it here. Because this uses ??, the untouched field wins over selectedImage.fileName, so the offline matcher receives an empty query on the default photo-only flow.
♻️ Proposed fix
- return identifyFromDescription(offlineQuery ?? selectedImage.fileName);
+ const offlineText = offlineQuery?.trim() || selectedImage.fileName;
+ return identifyFromDescription(offlineText);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return identifyFromDescription(offlineQuery ?? selectedImage.fileName); | |
| const offlineText = offlineQuery?.trim() || selectedImage.fileName; | |
| return identifyFromDescription(offlineText); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts` at line 57,
The offline query selection currently uses the nullish coalescing operator so an
empty description string ("" ) wins; update the logic in
useWildlifeIdentification where identifyFromDescription is called so
blank/whitespace descriptions are treated as absent—e.g., evaluate offlineQuery
with a trim check (use offlineQuery && offlineQuery.trim().length > 0) and pass
offlineQuery only when non-empty, otherwise pass selectedImage.fileName to
identifyFromDescription.
| const historyEntry = | ||
| !species && historyLoadable.state === 'hasData' | ||
| ? historyLoadable.data.find((h) => h.id === id) | ||
| : null; | ||
|
|
||
| const displaySpecies = species ?? historyEntry?.results[0]?.species; | ||
|
|
||
| if (!displaySpecies) { | ||
| return ( | ||
| <> | ||
| <Stack.Screen options={{ title: t('wildlife.speciesDetail') }} /> | ||
| <View className="flex-1 items-center justify-center p-8"> | ||
| <Text className="text-center text-muted-foreground">{t('wildlife.speciesNotFound')}</Text> |
There was a problem hiding this comment.
Resolve detail lookups by species ID, not just history entry ID.
apps/expo/features/wildlife/screens/IdentificationScreen.tsx, Lines 76-80, pushes result.species.id, and packages/api/src/routes/wildlife/index.ts, Lines 118-121, generates online IDs that will not exist in SPECIES_DATABASE. Here, the fallback only does historyLoadable.data.find((h) => h.id === id), so tapping a fresh online result lands in speciesNotFound. This screen needs to search h.results for a matching result.species.id, and it should wait for wildlifeHistoryAtom to finish loading before rendering the not-found state.
🐛 Suggested fix
- const historyEntry =
- !species && historyLoadable.state === 'hasData'
- ? historyLoadable.data.find((h) => h.id === id)
- : null;
-
- const displaySpecies = species ?? historyEntry?.results[0]?.species;
+ const historyEntry =
+ !species && historyLoadable.state === 'hasData'
+ ? historyLoadable.data.find(
+ (h) => h.id === id || h.results.some((result) => result.species.id === id),
+ )
+ : null;
+
+ const displaySpecies =
+ species ??
+ historyEntry?.results.find((result) => result.species.id === id)?.species ??
+ historyEntry?.results[0]?.species;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx` around lines 30
- 42, The fallback lookup currently finds a history entry by history entry id
(historyLoadable.data.find(h => h.id === id)) which fails when the route id is a
species id; change the fallback to search historyLoadable.data for any entry
whose results array contains a result with result.species.id === id (update the
logic that sets historyEntry/displaySpecies accordingly), and ensure you do not
render the not-found UI until wildlife history has finished loading (check
historyLoadable.state === 'hasData' before showing the speciesNotFound view and
render a loading state while it is still pending).
| </Text> | ||
| </> | ||
| ) : ( | ||
| <Text className="text-base text-muted-foreground">Unknown species</Text> |
There was a problem hiding this comment.
Localize the fallback label.
"Unknown species" is hardcoded English while the rest of this screen goes through t('wildlife.*'). Add a translation key and use it here so non-English locales do not regress.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/screens/WildlifeScreen.tsx` at line 34, The
hardcoded fallback label "Unknown species" in WildlifeScreen should be
localized; replace the literal Text node with the i18n lookup used elsewhere
(call t with an appropriate key like t('wildlife.unknownSpecies')), add that key
to the translations files under the wildlife namespace in all locales, and
update the component to use t('wildlife.unknownSpecies') instead of the
hardcoded string so the fallback label is localized across languages.
| const [historyLoadable] = useAtom(wildlifeHistoryAtom); | ||
|
|
||
| const history = historyLoadable.state === 'hasData' ? historyLoadable.data : []; | ||
| const isLoading = historyLoadable.state === 'loading'; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
wc -l apps/expo/features/wildlife/screens/WildlifeScreen.tsxRepository: PackRat-AI/PackRat
Length of output: 120
🏁 Script executed:
cat -n apps/expo/features/wildlife/screens/WildlifeScreen.tsxRepository: PackRat-AI/PackRat
Length of output: 5243
Don't collapse history load failures into the empty state.
Anything other than 'hasData' currently becomes [] (line 50), so a hasError loadable will render "No Identifications Yet" instead of surfacing the read failure. This makes existing history look deleted and hides AsyncStorage recovery/debugging work. Add a dedicated error branch before the empty state check.
Also applies to: 84-114 (the conditional rendering that needs to surface error states).
Translate the hardcoded "Unknown species" fallback.
Line 34 shows a hardcoded string that will remain in English on non-English devices. Move useTranslation into HistoryItem or pass the t function as a prop, then replace the string with a translation key.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/screens/WildlifeScreen.tsx` around lines 48 - 52,
The code collapses non-'hasData' loadable states into an empty array—update
handling of wildlifeHistoryAtom/historyLoadable so you check for
historyLoadable.state === 'hasError' and surface an error UI (or message) before
falling back to empty array and ensure the same explicit error branch is used in
the rendering logic around the 84-114 conditional block; additionally remove the
hardcoded "Unknown species" string by either moving useTranslation() into the
HistoryItem component or passing the t function into HistoryItem and replacing
the literal with a translation key (e.g., t('wildlife.unknownSpecies')) so the
fallback is localized.
| const service = new WildlifeIdentificationService(c); | ||
| const identification = await service.identifySpecies(imageUrl); | ||
|
|
||
| await PACKRAT_BUCKET.delete(image); | ||
|
|
There was a problem hiding this comment.
Run temporary upload cleanup in finally.
PACKRAT_BUCKET.delete(image) only executes after a successful model call, so failed identifications leak temp uploads. It can also turn a successful identification into a 500 if the delete itself fails. Make cleanup best-effort in finally so the upload lifecycle is independent from the main response path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/api/src/routes/wildlife/index.ts` around lines 110 - 114, The
temp-upload deletion currently runs only after a successful call to
WildlifeIdentificationService.identifySpecies and can leak or convert a success
into a 500; refactor the handler so the model call
(service.identifySpecies(imageUrl)) runs inside a try block, move
PACKRAT_BUCKET.delete(image) into a finally block and make the delete
best-effort (catch/log any errors from PACKRAT_BUCKET.delete so they don't
change the response), ensuring the identification result (identification) is
returned or the model error is handled independently of the upload cleanup.
| if (error instanceof Error) { | ||
| if ( | ||
| error.message.includes('Invalid image') || | ||
| error.message.includes('Unsupported image format') | ||
| ) { | ||
| return c.json({ error: error.message }, 400); | ||
| } | ||
| return c.json({ error: `Failed to identify species: ${error.message}` }, 500); |
There was a problem hiding this comment.
Keep 500 responses generic.
The 500 branch echoes error.message back to the client after already logging and sending the full exception to Sentry. That can expose internal provider, storage, or env-validation details to authenticated callers.
🔒 Suggested fix
- return c.json({ error: `Failed to identify species: ${error.message}` }, 500);
+ return c.json({ error: 'Failed to identify species' }, 500);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (error instanceof Error) { | |
| if ( | |
| error.message.includes('Invalid image') || | |
| error.message.includes('Unsupported image format') | |
| ) { | |
| return c.json({ error: error.message }, 400); | |
| } | |
| return c.json({ error: `Failed to identify species: ${error.message}` }, 500); | |
| if (error instanceof Error) { | |
| if ( | |
| error.message.includes('Invalid image') || | |
| error.message.includes('Unsupported image format') | |
| ) { | |
| return c.json({ error: error.message }, 400); | |
| } | |
| return c.json({ error: 'Failed to identify species' }, 500); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/api/src/routes/wildlife/index.ts` around lines 142 - 149, The
500-response currently echoes internal error details via c.json({ error: `Failed
to identify species: ${error.message}` }, 500); update the 500 branch in the
error handler (the conditional that checks error instanceof Error and the
current return c.json(...) for 500) to return a generic message (e.g., { error:
'Internal server error' } or { error: 'Failed to identify species' }) without
including error.message, while leaving the 400 branch (Invalid image /
Unsupported image format) and existing logging/Sentry reporting unchanged.
- Fix searchSpecies() to also search imageDescription and habitat fields
(offline identification was skipping these purpose-built matching fields)
- Replace hardcoded 'Unknown species' with t('wildlife.unknownSpecies')
and add the key to en.json
- Remove unused wildlifeHistoryCountAtom (dead code)
- Remove redundant `as SelectedImage` cast and unused SelectedImage import
in IdentificationScreen (TypeScript narrows type in the conditional branch)
- Refactor WildlifeScreen to use useWildlifeHistory hook instead of
reading wildlifeHistoryAtom directly for consistent abstraction
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Addressed the issues in commit
All files pass |
|
@copilot CodeRabbit has reviewed this PR and provided feedback. Please address the review comments and push updates. Once you've made the changes, the monitor will ask CodeRabbit to re-review. |
|
@copilot CodeRabbit has provided review feedback on this PR. Please review and address the comments. |
Critical: - wildlifeAtoms: fix double JSON encoding - pass AsyncStorage directly to createJSONStorage instead of manually calling JSON.parse/stringify - useWildlifeIdentification: fix empty-string ?? bug (offlineQuery='' won over filename); sanitize error log (no more raw Axios object in console) - SpeciesDetailScreen: fix history lookup to also search by species.id in h.results (was only checking history entry id, broke navigation from online identification results); add loading spinner before 'not found' - API route: move R2 delete to finally with best-effort catch so temp uploads are always cleaned up; make 500 response generic (no error.message) Major: - speciesDatabase: trim + early return in searchSpecies for blank queries - IdentificationScreen: call permanentlyPersistImageLocally() before addIdentification to persist image across cache evictions - WildlifeScreen: add explicit hasError branch (was silently showing empty state when AsyncStorage fails) - SpeciesCard: translate source/dangerLevel/category via t() Minor: - types.ts: add JSDoc documenting confidence is [0,1] - en.json: add historyLoadError, source.*, dangerLevel.*, category.* keys Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
All CodeRabbit comments addressed in commit Critical:
Major:
Minor:
All changes pushed in commit |
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
CodeRabbit has not posted a new review yet — the only review on record is the original one on commit |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
apps/expo/features/wildlife/screens/IdentificationScreen.tsx (1)
68-74: Consider wrapping persistence in try/catch to prevent silent failures.If
permanentlyPersistImageLocally()throws (not just returnsnull), the error could propagate and preventaddIdentificationfrom being called, leaving the user with no feedback about why their identification wasn't saved.🛡️ Proposed defensive handling
{ onSuccess: async (identificationResults) => { setSavedResults(identificationResults); - const persistedUri = (await permanentlyPersistImageLocally()) ?? selectedImage.uri; - await addIdentification(persistedUri, identificationResults); + let persistedUri = selectedImage.uri; + try { + persistedUri = (await permanentlyPersistImageLocally()) ?? selectedImage.uri; + } catch (err) { + console.warn('Failed to persist image locally, using original URI:', err); + } + await addIdentification(persistedUri, identificationResults); }, },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/screens/IdentificationScreen.tsx` around lines 68 - 74, The onSuccess handler should guard the call to permanentlyPersistImageLocally() with a try/catch so exceptions don't block saving; wrap the persist call in try/catch inside the onSuccess callback (around permanentlyPersistImageLocally()), on error log or report the exception and fall back to selectedImage.uri, then proceed to call addIdentification(persistedUri, identificationResults) and retain setSavedResults(identificationResults) behavior so the identification is saved/recorded even if persistence fails; reference permanentlyPersistImageLocally, addIdentification, setSavedResults, and the onSuccess callback when making the change.apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts (1)
8-22: Consider adding Zod schema validation for the API response.The
OnlineIdentificationResponseinterface is used to type the API response, but there's no runtime validation. As per coding guidelines, Zod schemas should be used for API request/response validation to ensure the response conforms to the expected shape.♻️ Proposed refactor to add Zod validation
+import { z } from 'zod'; + +const IdentificationResultSchema = z.object({ + species: z.object({ + id: z.string(), + commonName: z.string(), + scientificName: z.string(), + // ... other required fields + }), + confidence: z.number(), + source: z.enum(['online', 'offline']), +}); + +const OnlineIdentificationResponseSchema = z.object({ + results: z.array(IdentificationResultSchema), +}); async function identifyOnline(selectedImage: SelectedImage): Promise<IdentificationResult[]> { const image = await uploadImage(selectedImage.fileName, selectedImage.uri); if (!image) { throw new Error("Couldn't upload image"); } const response = await axiosInstance.post<OnlineIdentificationResponse>( '/api/wildlife/identify', { image }, { timeout: 30000 }, ); - return response.data.results; + const parsed = OnlineIdentificationResponseSchema.parse(response.data); + return parsed.results; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts` around lines 8 - 22, Replace the plain TypeScript interface runtime assumption with a Zod schema: define a zod schema (e.g., onlineIdentificationResponseSchema) that validates an object with a "results" array of IdentificationResult shape, then call axiosInstance.post as before in identifyOnline and run onlineIdentificationResponseSchema.parse(response.data) (or safeParse and throw a descriptive Error on failure) before returning the parsed.results; ensure you reference/translate the existing IdentificationResult shape into the Zod definitions and include error handling for parse failures so unexpected API shapes are rejected rather than returned.apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx (1)
84-101: Consider localizingdangerLevelandcategoryfor consistency.The PR objectives mention that
SpeciesCardtranslatessource,dangerLevel, andcategory. However, here these values are displayed directly without translation. This could lead to inconsistent user experience between the card view and detail view.♻️ Proposed refactor to localize badge text
<Text className={`text-sm font-medium capitalize ${DANGER_TEXT[displaySpecies.dangerLevel]}`} > - {displaySpecies.dangerLevel} + {t(`wildlife.dangerLevel.${displaySpecies.dangerLevel}`)} </Text> </View> <View className="px-3 py-1 rounded-full bg-primary/10"> <Text className="text-sm font-medium capitalize text-primary"> - {displaySpecies.category} + {t(`wildlife.category.${displaySpecies.category}`)} </Text> </View>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx` around lines 84 - 101, SpeciesDetailScreen renders displaySpecies.dangerLevel and displaySpecies.category directly causing inconsistency with SpeciesCard translations; update the component to pass these values through the same i18n/translation helper used by SpeciesCard (or the shared translator function) before rendering so badges use localized strings, e.g., translate displaySpecies.dangerLevel and displaySpecies.category where they are used (retain DANGER_TEXT mapping by key if it expects raw keys—translate only the displayed text, not the lookup key), and ensure any CSS/class usage (DANGER_TEXT[displaySpecies.dangerLevel]) still receives the original key if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/expo/config.ts`:
- Line 8: The feature flag enableWildlifeIdentification is currently set true;
change its default to false so the new feature ships dark. Locate the flag
definition (exported config object containing enableWildlifeIdentification) and
set it to false, and ensure any runtime checks remain gated behind
featureFlags.enableWildlifeIdentification (no behavioral changes elsewhere).
In `@apps/expo/features/wildlife/hooks/useWildlifeHistory.ts`:
- Around line 30-39: deleteIdentification and clearHistory only remove metadata
and leave persisted image files orphaned; update these handlers to delete the
associated files from disk before updating state. In deleteIdentification(id)
lookup the entry being removed (e.g., find by id on history), call the app's
file-delete utility (the function used when persisting images, or Expo
FileSystem.deleteAsync / RNFS.unlink) to remove entry.photoPath (or similar
photo path field), handle/recover from errors, then update setHistory to filter
out the id. In clearHistory() iterate all current entries, delete each entry's
photoPath concurrently (Promise.all) and only after deletes resolve clear
setHistory([]); ensure both handlers remain wrapped in useCallback and do not
block UI unnecessarily (use async/await and local error logging).
- Around line 24-32: The updaters use setHistory with a synchronous callback but
atomWithStorage may supply a Promise during hydration; change the callbacks in
addIdentification and deleteIdentification to async functions that await the
incoming prev value (e.g., setHistory(async (prev) => { const resolved = await
prev; return [entry, ...resolved]; }) and setHistory(async (prev) => { const
resolved = await prev; return resolved.filter(e => e.id !== id); })) so you
always operate on the hydrated array.
In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts`:
- Around line 36-41: The isNetworkError fallback in useWildlifeIdentification.ts
currently treats any non-null object without a response as a network error;
update the logic in the isNetworkError function to only classify known network
error shapes as network errors (e.g., Axios errors with isAxiosError === true or
objects missing response but having typical network error indicators like code
=== 'ECONNABORTED' or message containing 'Network'/'timeout'), and return false
for thrown primitives or unknown custom objects; locate the isNetworkError
implementation and tighten the type checks (use typeof checks and explicit
property guards) so unexpected error shapes are not misclassified.
In `@packages/api/src/routes/wildlife/index.ts`:
- Around line 134-156: The species.id creation inside the
identification.results.map currently builds a slug from r.scientificName and can
produce an empty string if scientificName is empty; update the mapping in the
results variable so species.id falls back to a stable alternative when
r.scientificName is falsy (e.g., build a slug from r.commonName if present, or
use a deterministic fallback like `unknown-{index}` or a short hashed value),
preserving the same sanitization rules (.toLowerCase, replaceAll) and keeping
the rest of the mapped fields unchanged; locate and modify the mapping where
identification.results.map is used and the code that references r.scientificName
to implement this fallback.
---
Nitpick comments:
In `@apps/expo/features/wildlife/hooks/useWildlifeIdentification.ts`:
- Around line 8-22: Replace the plain TypeScript interface runtime assumption
with a Zod schema: define a zod schema (e.g.,
onlineIdentificationResponseSchema) that validates an object with a "results"
array of IdentificationResult shape, then call axiosInstance.post as before in
identifyOnline and run onlineIdentificationResponseSchema.parse(response.data)
(or safeParse and throw a descriptive Error on failure) before returning the
parsed.results; ensure you reference/translate the existing IdentificationResult
shape into the Zod definitions and include error handling for parse failures so
unexpected API shapes are rejected rather than returned.
In `@apps/expo/features/wildlife/screens/IdentificationScreen.tsx`:
- Around line 68-74: The onSuccess handler should guard the call to
permanentlyPersistImageLocally() with a try/catch so exceptions don't block
saving; wrap the persist call in try/catch inside the onSuccess callback (around
permanentlyPersistImageLocally()), on error log or report the exception and fall
back to selectedImage.uri, then proceed to call addIdentification(persistedUri,
identificationResults) and retain setSavedResults(identificationResults)
behavior so the identification is saved/recorded even if persistence fails;
reference permanentlyPersistImageLocally, addIdentification, setSavedResults,
and the onSuccess callback when making the change.
In `@apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx`:
- Around line 84-101: SpeciesDetailScreen renders displaySpecies.dangerLevel and
displaySpecies.category directly causing inconsistency with SpeciesCard
translations; update the component to pass these values through the same
i18n/translation helper used by SpeciesCard (or the shared translator function)
before rendering so badges use localized strings, e.g., translate
displaySpecies.dangerLevel and displaySpecies.category where they are used
(retain DANGER_TEXT mapping by key if it expects raw keys—translate only the
displayed text, not the lookup key), and ensure any CSS/class usage
(DANGER_TEXT[displaySpecies.dangerLevel]) still receives the original key if
needed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: eee55f3f-75ab-4cd2-9881-e26516287465
📒 Files selected for processing (25)
apps/expo/app/(app)/(tabs)/(home)/index.tsxapps/expo/app/(app)/wildlife/[id].tsxapps/expo/app/(app)/wildlife/identify.tsxapps/expo/app/(app)/wildlife/index.tsxapps/expo/config.tsapps/expo/features/wildlife/atoms/wildlifeAtoms.tsapps/expo/features/wildlife/components/SpeciesCard.tsxapps/expo/features/wildlife/components/WildlifeTile.tsxapps/expo/features/wildlife/components/index.tsapps/expo/features/wildlife/data/speciesDatabase.tsapps/expo/features/wildlife/hooks/index.tsapps/expo/features/wildlife/hooks/useWildlifeHistory.tsapps/expo/features/wildlife/hooks/useWildlifeIdentification.tsapps/expo/features/wildlife/index.tsapps/expo/features/wildlife/lib/offlineIdentifier.tsapps/expo/features/wildlife/screens/IdentificationScreen.tsxapps/expo/features/wildlife/screens/SpeciesDetailScreen.tsxapps/expo/features/wildlife/screens/WildlifeScreen.tsxapps/expo/features/wildlife/screens/index.tsapps/expo/features/wildlife/types.tsapps/expo/lib/i18n/locales/en.jsonpackages/api/src/routes/index.tspackages/api/src/routes/wildlife/index.tspackages/api/src/services/index.tspackages/api/src/services/wildlifeIdentificationService.ts
| setHistory((prev) => [entry, ...prev]); | ||
| return entry; | ||
| }, | ||
| [setHistory], | ||
| ); | ||
|
|
||
| const deleteIdentification = useCallback( | ||
| (id: string) => { | ||
| setHistory((prev) => prev.filter((entry) => entry.id !== id)); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, find and examine the hook file
find . -name "useWildlifeHistory.ts" -type fRepository: PackRat-AI/PackRat
Length of output: 119
🏁 Script executed:
# Find the atoms file
find . -name "wildlifeAtoms.ts" -type fRepository: PackRat-AI/PackRat
Length of output: 114
🏁 Script executed:
# Check if the files exist in the expected location
ls -la apps/expo/features/wildlife/hooks/useWildlifeHistory.ts 2>&1 || echo "File not found"
ls -la apps/expo/features/wildlife/atoms/wildlifeAtoms.ts 2>&1 || echo "File not found"Repository: PackRat-AI/PackRat
Length of output: 265
🏁 Script executed:
cat -n apps/expo/features/wildlife/hooks/useWildlifeHistory.tsRepository: PackRat-AI/PackRat
Length of output: 1615
🏁 Script executed:
cat -n apps/expo/features/wildlife/atoms/wildlifeAtoms.tsRepository: PackRat-AI/PackRat
Length of output: 745
🏁 Script executed:
# Let me verify Jotai's atomWithStorage behavior with AsyncStorage
# Check if there's any documentation or tests in the repo
rg -i "atomWithStorage" apps/expo --type ts --type tsx -A 5 -B 2Repository: PackRat-AI/PackRat
Length of output: 89
🌐 Web query:
Jotai atomWithStorage AsyncStorage atom value Promise async behavior
💡 Result:
-
With React Native
AsyncStorage(async backend),atomWithStorageis typed/implemented as an atom whose value can beValue | Promise<Value>(becausestorage.getItem(...)returns aPromiseLike<Value>). In other words, the atom can temporarily “be a Promise”, which makes it behave like an async read atom (i.e., it can Suspend unless you handle it). [1][2][3] -
Initialization / first render
- Default (
getOnInitomitted /false): the atom’s initial value isinitialValue, then on mount Jotai callssetAtom(storage.getItem(...))(a promise), so the atom can transition into a Promise state until it resolves. [1][2] - With
options: { getOnInit: true }: the atom’s initial state isstorage.getItem(...)immediately, so it can be a Promise right away (use Suspense orloadable). The docs explicitly call outgetOnInitfor preferring the stored value on initialization. [1][2]
- Default (
-
Reading it safely
- If you read the atom directly and it’s currently a Promise, Jotai treats it like any async atom (Suspense). Jotai recommends either wrapping the UI in
Suspenseor usingloadable(...)to get{ state: 'loading' | 'hasData' | 'hasError', ... }instead of suspending. [3]
- If you read the atom directly and it’s currently a Promise, Jotai treats it like any async atom (Suspense). Jotai recommends either wrapping the UI in
-
Writing
- If you
setthe atom to a Promise,atomWithStoragewill wait for it to resolve, then store the resolved value in the base atom and persist it viastorage.setItem. If you set a plain value, it persists immediately. [2]
- If you
Sources: Jotai storage docs (getOnInit, AsyncStorage usage) [1], atomWithStorage implementation (Promise handling in read/write paths) [2], Jotai async atom behavior + Suspense/loadable guidance [3].
🏁 Script executed:
# Let me check if there's more context on how setHistory is used elsewhere
rg "setHistory\|deleteIdentification\|clearHistory" apps/expo/features/wildlife --type ts -B 2 -A 3Repository: PackRat-AI/PackRat
Length of output: 44
🏁 Script executed:
# Also check the types to understand the structure better
cat -n apps/expo/features/wildlife/types.ts 2>/dev/null | head -50Repository: PackRat-AI/PackRat
Length of output: 1339
🏁 Script executed:
# Check how setHistory is actually used in screens/components
find apps/expo/features/wildlife -name "*.ts" -o -name "*.tsx" | head -20Repository: PackRat-AI/PackRat
Length of output: 833
🏁 Script executed:
# Look at IdentificationScreen to see how the hook is used
find . -name "IdentificationScreen*" -type fRepository: PackRat-AI/PackRat
Length of output: 124
🏁 Script executed:
# Search for any tests or examples of atomWithStorage usage in the project
rg "atomWithStorage" apps/ -A 10 -B 2 --type tsRepository: PackRat-AI/PackRat
Length of output: 5306
🏁 Script executed:
cat -n apps/expo/features/wildlife/screens/IdentificationScreen.tsx | head -100Repository: PackRat-AI/PackRat
Length of output: 4248
🏁 Script executed:
# Check how weather atoms handle updates with AsyncStorage
cat -n apps/expo/features/weather/hooks/*.ts 2>/dev/null | head -100Repository: PackRat-AI/PackRat
Length of output: 3732
🏁 Script executed:
# Look for patterns in how setters are used with atomWithStorage
rg "setAtom\|setLocation" apps/expo/features/weather --type ts -B 2 -A 3Repository: PackRat-AI/PackRat
Length of output: 44
🏁 Script executed:
# Let me verify the actual behavior by checking Jotai's source or documentation in the project
rg "updater\|setValue" apps/ -t ts | head -20Repository: PackRat-AI/PackRat
Length of output: 44
🏁 Script executed:
# Check if there are any other async atom updates in the codebase
rg "async.*prev.*=>" apps/expo --type ts -B 2 -A 2Repository: PackRat-AI/PackRat
Length of output: 44
Await the hydrated history before deriving the next value.
With atomWithStorage and AsyncStorage, the atom value can be a Promise during hydration. Sync updaters that reference prev will fail if called while the atom is still resolving. Convert updaters to async and await the value.
Suggested fix
- setHistory((prev) => [entry, ...prev]);
+ setHistory(async (prev) => [entry, ...(await prev)]);
@@
- setHistory((prev) => prev.filter((entry) => entry.id !== id));
+ setHistory(async (prev) => (await prev).filter((entry) => entry.id !== id));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/expo/features/wildlife/hooks/useWildlifeHistory.ts` around lines 24 -
32, The updaters use setHistory with a synchronous callback but atomWithStorage
may supply a Promise during hydration; change the callbacks in addIdentification
and deleteIdentification to async functions that await the incoming prev value
(e.g., setHistory(async (prev) => { const resolved = await prev; return [entry,
...resolved]; }) and setHistory(async (prev) => { const resolved = await prev;
return resolved.filter(e => e.id !== id); })) so you always operate on the
hydrated array.
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
|
@copilot Please address the CodeRabbit review feedback on this PR (11 inline comments). |
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (3)
packages/api/src/routes/wildlife/index.ts (1)
137-140:⚠️ Potential issue | 🟡 MinorHandle empty
scientificNameto avoid empty ID.If the AI returns an empty string for
scientificName, the resultingidwould be empty after sanitization, which could cause issues with lookups and storage downstream.🛡️ Proposed fix to handle empty scientific name
const results = identification.results.map((r) => ({ species: { - id: r.scientificName + id: (r.scientificName || r.commonName || 'unknown-species') .toLowerCase() .replaceAll(/[\s.]+/g, '-') .replaceAll(/[^a-z0-9-]/g, ''),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/api/src/routes/wildlife/index.ts` around lines 137 - 140, The id generation can produce an empty string when r.scientificName is empty; update the id creation so you first pick a fallback source (e.g., use r.scientificName if non-empty after trimming, else use r.commonName or a fixed token like 'unknown') and then apply the existing sanitization (.toLowerCase().replaceAll(...)) to that chosen value; ensure the final id is non-empty (if still empty, append a short deterministic or random suffix) so the id field never becomes an empty string.apps/expo/features/wildlife/hooks/useWildlifeHistory.ts (2)
30-39:⚠️ Potential issue | 🟠 MajorDelete persisted images when removing history entries.
This flow now stores a permanent local
imageUri, butdeleteIdentificationandclearHistoryonly drop metadata. Please delete the saved files first; otherwise history cleanup leaks disk space over time.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/hooks/useWildlifeHistory.ts` around lines 30 - 39, When deleting entries in deleteIdentification and clearing all entries in clearHistory, first delete each entry's persisted imageUri from disk before updating state: locate deleteIdentification and clearHistory and for each matching history entry read its imageUri and call the platform file-delete API (e.g., Expo FileSystem.deleteAsync) inside a try/catch to avoid aborting the flow, then proceed to setHistory to remove the entry or set to []; do the same in clearHistory by iterating all history entries and deleting any imageUri, and ensure you import the FileSystem (or app file-storage utility) and log or ignore delete errors so state update always completes.
24-24:⚠️ Potential issue | 🟠 MajorAwait the hydrated Jotai value before deriving the next history array.
baseWildlifeHistoryAtomis backed by async storage, soprevcan still be a Promise during hydration. These synchronous updaters can fail on a cold start if the user adds or deletes history before hydration finishes.Suggested fix
- setHistory((prev) => [entry, ...prev]); + setHistory(async (prev) => [entry, ...(await prev)]); @@ - setHistory((prev) => prev.filter((entry) => entry.id !== id)); + setHistory(async (prev) => (await prev).filter((entry) => entry.id !== id));Also applies to: 32-32
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/expo/features/wildlife/hooks/useWildlifeHistory.ts` at line 24, The updater at setHistory in useWildlifeHistory uses a potentially-hydrated async atom (baseWildlifeHistoryAtom) as "prev", so resolve the atom before deriving the new array: instead of relying on the functional updater to receive a resolved array, explicitly await the hydrated value (e.g. via getPromise(baseWildlifeHistoryAtom) or Promise.resolve(the atom value) depending on your jotai helpers) inside the async handler in useWildlifeHistory, then call setHistory with the resolved array ([entry, ...resolvedPrev]); apply the same pattern for the other occurrence around the delete path so you never pass a Promise into setHistory.
🧹 Nitpick comments (1)
packages/api/src/services/wildlifeIdentificationService.ts (1)
66-93: Consider adding error handling for API failures.The
identifySpeciesmethod lacks explicit error handling. While the calling route handles errors, wrapping thegenerateObjectcall could provide more specific error context (e.g., distinguishing API key issues from model failures) and enable retry logic for transient failures.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/api/src/services/wildlifeIdentificationService.ts` around lines 66 - 93, Wrap the generateObject call inside identifySpecies in a try/catch so API failures are caught and enriched: call getEnv/createOpenAI as before, then try await generateObject(...) and on error inspect the error to distinguish auth errors (missing/invalid OPENAI_API_KEY), model errors, and transient network errors; log or throw a new, more descriptive error that includes the original error message and context (imageUrl, model DEFAULT_MODELS.OPENAI_CHAT, and identificationResponseSchema), and optionally implement a simple retry loop (e.g., 2 retries with backoff) for transient failures before rethrowing; ensure the method still returns the same IdentificationResponse on success and rethrows or wraps errors for upstream handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/expo/app/`(app)/(tabs)/(home)/index.tsx:
- Around line 132-145: The wildlife search metadata currently hardcodes English
keywords (the wildlife object with title and keywords) so searches won't match
translated labels; update the metadata to be locale-aware by generating the
title and keywords via the app's i18n/localization helper (e.g., call the same
translation key used by WildlifeTile) and include translated variants for
relevant keywords (title, synonyms like 'plant', 'animal', 'bird', etc.) so the
wildlife object (used for search) uses i18n.t(...) outputs instead of fixed
English strings.
In `@apps/expo/features/wildlife/atoms/wildlifeAtoms.ts`:
- Around line 8-14: baseWildlifeHistoryAtom is created with atomWithStorage
which returns the fallback synchronously, so wrapping it with loadable in
wildlifeHistoryAtom never sees a loading state; update the atom creation by
passing the option { getOnInit: true } to atomWithStorage (i.e., call
atomWithStorage<WildlifeIdentification[]>(_key_, _initial_, wildlifeStorage, {
getOnInit: true })) so that baseWildlifeHistoryAtom suspends and
wildlifeHistoryAtom will enter 'loading' on initial load, preserving the
existing load checks in WildlifeScreen and SpeciesDetailScreen.
In `@apps/expo/features/wildlife/screens/IdentificationScreen.tsx`:
- Around line 68-72: The onSuccess handler (used with setSavedResults,
permanentlyPersistImageLocally, and addIdentification) can write results for a
previous image if the user selects a new image while a request is pending;
modify the flow so stale completions are ignored: when starting an identify
request capture/stamp the current selectedImage.uri (or an incrementing
requestId) in scope, then in onSuccess compare that stamp against the current
selectedImage.uri (or latest requestId) and bail out if they differ;
alternatively disable the change-photo control while isPending so selection
cannot change until completion — apply the same guard to the other completion
block around lines 105-111 as well.
In `@apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsx`:
- Around line 81-93: The danger/category badges are rendering raw enum values
(displaySpecies.dangerLevel and displaySpecies.category); replace those raw
strings with localized labels via the i18n translation keys (e.g. use
t(`wildlife.dangerLevel.${displaySpecies.dangerLevel}`) for the danger badge and
t(`wildlife.category.${displaySpecies.category}`) for the category badge) while
keeping the existing styling and classes (DANGER_BADGE, DANGER_TEXT, and the
container View). Ensure you import/use the app's translation hook/function (t)
in SpeciesDetailScreen and pass the localized value into the Text components
instead of the enum.
- Around line 42-63: Add a new early-return branch that surfaces history read
failures before showing "speciesNotFound": when species is falsy and
historyLoadable.state === 'hasError' (i.e., the same condition described in the
review), return the same Stack.Screen and centered View but render
t('wildlife.historyLoadError') instead of the not-found copy; place this check
immediately before the existing if (!displaySpecies) return so the history error
is shown when historyLoadable indicates hasError (references: species,
historyLoadable, displaySpecies, t('wildlife.historyLoadError')).
---
Duplicate comments:
In `@apps/expo/features/wildlife/hooks/useWildlifeHistory.ts`:
- Around line 30-39: When deleting entries in deleteIdentification and clearing
all entries in clearHistory, first delete each entry's persisted imageUri from
disk before updating state: locate deleteIdentification and clearHistory and for
each matching history entry read its imageUri and call the platform file-delete
API (e.g., Expo FileSystem.deleteAsync) inside a try/catch to avoid aborting the
flow, then proceed to setHistory to remove the entry or set to []; do the same
in clearHistory by iterating all history entries and deleting any imageUri, and
ensure you import the FileSystem (or app file-storage utility) and log or ignore
delete errors so state update always completes.
- Line 24: The updater at setHistory in useWildlifeHistory uses a
potentially-hydrated async atom (baseWildlifeHistoryAtom) as "prev", so resolve
the atom before deriving the new array: instead of relying on the functional
updater to receive a resolved array, explicitly await the hydrated value (e.g.
via getPromise(baseWildlifeHistoryAtom) or Promise.resolve(the atom value)
depending on your jotai helpers) inside the async handler in useWildlifeHistory,
then call setHistory with the resolved array ([entry, ...resolvedPrev]); apply
the same pattern for the other occurrence around the delete path so you never
pass a Promise into setHistory.
In `@packages/api/src/routes/wildlife/index.ts`:
- Around line 137-140: The id generation can produce an empty string when
r.scientificName is empty; update the id creation so you first pick a fallback
source (e.g., use r.scientificName if non-empty after trimming, else use
r.commonName or a fixed token like 'unknown') and then apply the existing
sanitization (.toLowerCase().replaceAll(...)) to that chosen value; ensure the
final id is non-empty (if still empty, append a short deterministic or random
suffix) so the id field never becomes an empty string.
---
Nitpick comments:
In `@packages/api/src/services/wildlifeIdentificationService.ts`:
- Around line 66-93: Wrap the generateObject call inside identifySpecies in a
try/catch so API failures are caught and enriched: call getEnv/createOpenAI as
before, then try await generateObject(...) and on error inspect the error to
distinguish auth errors (missing/invalid OPENAI_API_KEY), model errors, and
transient network errors; log or throw a new, more descriptive error that
includes the original error message and context (imageUrl, model
DEFAULT_MODELS.OPENAI_CHAT, and identificationResponseSchema), and optionally
implement a simple retry loop (e.g., 2 retries with backoff) for transient
failures before rethrowing; ensure the method still returns the same
IdentificationResponse on success and rethrows or wraps errors for upstream
handling.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f46b6ad0-fdf5-4403-82c4-8cef0ded449c
📒 Files selected for processing (25)
apps/expo/app/(app)/(tabs)/(home)/index.tsxapps/expo/app/(app)/wildlife/[id].tsxapps/expo/app/(app)/wildlife/identify.tsxapps/expo/app/(app)/wildlife/index.tsxapps/expo/config.tsapps/expo/features/wildlife/atoms/wildlifeAtoms.tsapps/expo/features/wildlife/components/SpeciesCard.tsxapps/expo/features/wildlife/components/WildlifeTile.tsxapps/expo/features/wildlife/components/index.tsapps/expo/features/wildlife/data/speciesDatabase.tsapps/expo/features/wildlife/hooks/index.tsapps/expo/features/wildlife/hooks/useWildlifeHistory.tsapps/expo/features/wildlife/hooks/useWildlifeIdentification.tsapps/expo/features/wildlife/index.tsapps/expo/features/wildlife/lib/offlineIdentifier.tsapps/expo/features/wildlife/screens/IdentificationScreen.tsxapps/expo/features/wildlife/screens/SpeciesDetailScreen.tsxapps/expo/features/wildlife/screens/WildlifeScreen.tsxapps/expo/features/wildlife/screens/index.tsapps/expo/features/wildlife/types.tsapps/expo/lib/i18n/locales/en.jsonpackages/api/src/routes/index.tspackages/api/src/routes/wildlife/index.tspackages/api/src/services/index.tspackages/api/src/services/wildlifeIdentificationService.ts
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
All 5 actionable items and 2 nitpick items from the re-review addressed in commit
|
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
…i18n, error handling Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
All 6 items addressed in commit
The 3 "duplicate" flags (file deletion in |
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
|
@copilot CodeRabbit re-review found additional issues (22 inline comments total). Please address all remaining review feedback. |
|
👋 @copilot |
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
|
@copilot There are still 22 unresolved CodeRabbit inline comments on this PR. Please address all remaining review feedback. |
|
@copilot CodeRabbit has reviewed this PR. Please address the feedback. |
searchSpeciesto includeimageDescriptionandhabitatin searcht('wildlife.unknownSpecies')wildlifeHistoryCountAtomas SelectedImagecastWildlifeScreento useuseWildlifeHistoryhookwildlifeAtoms.tssearchSpecies??bug inuseWildlifeIdentificationoffline pathuseWildlifeIdentificationpermanentlyPersistImageLocallyinIdentificationScreenSpeciesDetailScreenhistory lookup + loading statehasErrorbranch inWildlifeScreenfinallyin API routesource,dangerLevel,categoryinSpeciesCardandSpeciesDetailScreenconfidenceintypes.tsen.jsonenableWildlifeIdentification: false(ship dark by default)deleteIdentification/clearHistoryisNetworkError(remove over-broadreturn truefallback)scientificNamefallback in API species.id slugpermanentlyPersistImageLocallyin try/catch inIdentificationScreenhasErrorbranch inSpeciesDetailScreen(surfaces history load error instead of "not found"){ getOnInit: true }toatomWithStoragesoloadablecorrectly enters loading state on cold startIdentificationScreenonSuccess(ignore results if user changed image mid-flight)t()for wildlife tile title and category keywords inhome/index.tsxgenerateObjectinwildlifeIdentificationService.tsWildlifeScreenOriginal prompt
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.
Summary by CodeRabbit
New Features
Documentation