feat(mobile): persist and recover pending task prompts on failure (port #3053)#3087
Conversation
#3053) Ports desktop #3053 to the mobile app so a user's typed new-task prompt is never silently lost when task creation fails or the app is killed mid-flight. - Add a separate AsyncStorage-backed `pendingPromptRecoveryStore` (distinct from the transient in-memory echo store, whose entries must vanish when the live SSE copy lands). Records a prompt when creation begins, clears it once the task exists or on failure, and caps to the newest 20 entries. - Add a mount-once `PendingPromptRecovery` component that, after the store hydrates, recovers the newest orphaned prompt exactly once per launch and routes to the new-task composer pre-filled with it. - Wire clear-on-success / clear-on-failure into the create flow. Generated-By: PostHog Code Task-Id: 398f655d-ec85-4295-99cd-585d3debfd17
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
|
Reviews (1): Last reviewed commit: "feat(mobile): persist and recover pendin..." | Re-trigger Greptile |
| let recoveryStarted = false; | ||
|
|
||
| export function PendingPromptRecovery(): null { | ||
| const isAuthenticated = useAuthStore((s) => s.isAuthenticated); | ||
|
|
||
| useEffect(() => { | ||
| if (!isAuthenticated || recoveryStarted) return; | ||
| recoveryStarted = true; | ||
| void recoverNewestPrompt(); |
There was a problem hiding this comment.
recoveryStarted not reset on logout within the same session
recoveryStarted is module-level and never cleared. If a user's session expires or they explicitly log out while the app is still running (without killing the process), isAuthenticated flips to false then back to true, the useEffect fires again, but recoveryStarted is already true — so recovery is silently skipped for the new session. Resetting the flag when isAuthenticated transitions from true back to false would cover this case (e.g. add a branch in the same useEffect to reset recoveryStarted = false when !isAuthenticated).
| it("does nothing when there is no orphaned prompt", async () => { | ||
| getAllNewestFirst.mockReturnValue([]); | ||
|
|
||
| await mountRecovery(); | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled(); | ||
| expect(clear).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("does not recover until the user is authenticated", async () => { | ||
| authState.isAuthenticated = false; | ||
| getAllNewestFirst.mockReturnValue([ | ||
| { key: "newest", prompt: { promptText: "Newest prompt" } }, | ||
| ]); | ||
|
|
||
| await mountRecovery(); | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
Prefer parameterised tests for the "no-op" cases
The team rule is to prefer parameterised tests. The two "does nothing" cases — empty store and unauthenticated — both assert the same outcome (mockPush not called, clear not called) with different preconditions. Combining them into a single it.each block would express that intent more concisely and make it easier to add further no-op scenarios in the future.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…o-op tests Addresses Greptile review on #3087: - Reset the module-level `recoveryStarted` guard when `isAuthenticated` flips to false, so a logout/login within the same process re-attempts recovery of any prompt still orphaned in storage (previously skipped until a full app restart). Adds a regression test. - Collapse the two "does nothing" recovery cases into a parameterised `it.each` block, per the team's parameterised-test rule. Generated-By: PostHog Code Task-Id: 398f655d-ec85-4295-99cd-585d3debfd17
Summary
Ports desktop #3053 (persist pending task prompts and recover them on failure) to the mobile app, so a user's typed new-task prompt survives a failed or interrupted task creation instead of being silently lost.
On mobile the prompt is now durably recorded the moment a create begins, and recovered into the composer on the next launch if the task never finished creating.
What changed
pendingPromptRecoveryStore(apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts) — a small AsyncStorage-backed persisted store, kept deliberately separate from the existing transient in-memory echo store (pendingTaskPromptStore). The echo store must stay ephemeral (its entries disappear the instant the live SSEuser_message_chunkechoes back), so persisting it would replay stale echoes on launch. The recovery store instead records{ promptText, createdAt }, caps itself to the newest ~20 entries, and tracks ahasHydratedflag exposed viawhenHydrated().PendingPromptRecoverycomponent (apps/mobile/src/features/tasks/components/PendingPromptRecovery.tsx) — mounted once at the app root; after hydration it takes the newest orphaned prompt exactly once per launch, clears it, and routes to the new-task composer (/task) pre-filled via the existingpromptroute param. Guarded by a module-level flag so it runs once.apps/mobile/src/app/task/index.tsx) — records the prompt when submission begins, clears it once the task is confirmed created, and clears it on failure (the text stays live in the composer, so nothing is lost).Design notes
Tests
Vitest coverage for the store (persist-on-submit, clear-on-success, cap-to-newest eviction, newest-first ordering, hydration gating) and the recovery component (recovers newest orphan once, no-op when empty, auth-gated). All green.
Ref: desktop PR #3053.