Skip to content

Commit 8c4521d

Browse files
authored
feat(mobile): persist and recover pending task prompts on failure (port #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
1 parent 670c1ac commit 8c4521d

6 files changed

Lines changed: 303 additions & 0 deletions

File tree

apps/mobile/src/app/_layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { useAuthStore } from "@/features/auth";
1818
import { setupNotificationResponseListener } from "@/features/notifications/lib/notifications";
1919
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
20+
import { PendingPromptRecovery } from "@/features/tasks/components/PendingPromptRecovery";
2021
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
2122
import {
2223
POSTHOG_API_KEY,
@@ -197,6 +198,7 @@ export default function RootLayout() {
197198
<View style={themeVars} className="flex-1">
198199
<RootLayoutNav isConnected={isConnected} />
199200
<OfflineBanner isConnected={isConnected} />
201+
<PendingPromptRecovery />
200202
</View>
201203
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
202204
</QueryClientProvider>

apps/mobile/src/app/task/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPick
6363
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
6464
import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations";
6565
import { useWarmTask } from "@/features/tasks/hooks/useWarmTask";
66+
import { pendingPromptRecoveryStoreApi } from "@/features/tasks/stores/pendingPromptRecoveryStore";
6667
import {
6768
generatePendingTaskKey,
6869
pendingTaskPromptStoreApi,
@@ -286,6 +287,13 @@ export default function NewTaskScreen() {
286287
setAt: Date.now(),
287288
});
288289

290+
// Durably record the prompt so it survives the app being killed before
291+
// creation completes; cleared once the task exists (or on failure, when
292+
// the text is still live in the composer).
293+
if (trimmedPrompt) {
294+
pendingPromptRecoveryStoreApi.set(pendingKey, trimmedPrompt);
295+
}
296+
289297
// Tracks where the optimistic echo currently lives so the catch block
290298
// can clear the correct key regardless of how far the flow got.
291299
let currentPendingKey = pendingKey;
@@ -320,6 +328,7 @@ export default function NewTaskScreen() {
320328

321329
pendingTaskPromptStoreApi.move(pendingKey, task.id);
322330
currentPendingKey = task.id;
331+
pendingPromptRecoveryStoreApi.clear(pendingKey);
323332

324333
// Seed the per-task composer config with the mode/model/reasoning the
325334
// user picked here, so the task detail screen reflects them and every
@@ -354,6 +363,7 @@ export default function NewTaskScreen() {
354363
} catch (creationError) {
355364
log.error("Failed to create task", creationError);
356365
pendingTaskPromptStoreApi.clear(currentPendingKey);
366+
pendingPromptRecoveryStoreApi.clear(pendingKey);
357367
} finally {
358368
setCreating(false);
359369
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createElement } from "react";
2+
import { act, create } from "react-test-renderer";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const { mockPush, whenHydrated, getAllNewestFirst, clear, authState } =
6+
vi.hoisted(() => ({
7+
mockPush: vi.fn(),
8+
whenHydrated: vi.fn(() => Promise.resolve()),
9+
getAllNewestFirst: vi.fn(
10+
() => [] as Array<{ key: string; prompt: { promptText: string } }>,
11+
),
12+
clear: vi.fn(),
13+
authState: { isAuthenticated: true },
14+
}));
15+
16+
vi.mock("expo-router", () => ({ router: { push: mockPush } }));
17+
18+
vi.mock("@/features/auth", () => ({
19+
useAuthStore: (selector: (s: typeof authState) => unknown) =>
20+
selector(authState),
21+
}));
22+
23+
vi.mock("../stores/pendingPromptRecoveryStore", () => ({
24+
pendingPromptRecoveryStoreApi: { whenHydrated, getAllNewestFirst, clear },
25+
}));
26+
27+
async function mountRecovery() {
28+
vi.resetModules();
29+
const { PendingPromptRecovery } = await import("./PendingPromptRecovery");
30+
await act(async () => {
31+
create(createElement(PendingPromptRecovery));
32+
await Promise.resolve();
33+
await Promise.resolve();
34+
});
35+
return PendingPromptRecovery;
36+
}
37+
38+
describe("PendingPromptRecovery", () => {
39+
beforeEach(() => {
40+
mockPush.mockReset();
41+
clear.mockReset();
42+
whenHydrated.mockReset().mockResolvedValue(undefined);
43+
getAllNewestFirst.mockReset().mockReturnValue([]);
44+
authState.isAuthenticated = true;
45+
});
46+
47+
it("recovers the newest orphaned prompt into the composer", async () => {
48+
getAllNewestFirst.mockReturnValue([
49+
{ key: "newest", prompt: { promptText: "Newest prompt" } },
50+
{ key: "older", prompt: { promptText: "Older prompt" } },
51+
]);
52+
53+
await mountRecovery();
54+
55+
expect(clear).toHaveBeenCalledWith("newest");
56+
expect(mockPush).toHaveBeenCalledWith({
57+
pathname: "/task",
58+
params: { prompt: "Newest prompt" },
59+
});
60+
});
61+
62+
it("runs only once even when re-mounted", async () => {
63+
getAllNewestFirst.mockReturnValue([
64+
{ key: "newest", prompt: { promptText: "Newest prompt" } },
65+
]);
66+
67+
const PendingPromptRecovery = await mountRecovery();
68+
await act(async () => {
69+
create(createElement(PendingPromptRecovery));
70+
await Promise.resolve();
71+
});
72+
73+
expect(mockPush).toHaveBeenCalledTimes(1);
74+
});
75+
76+
it("does nothing when there is no orphaned prompt", async () => {
77+
getAllNewestFirst.mockReturnValue([]);
78+
79+
await mountRecovery();
80+
81+
expect(mockPush).not.toHaveBeenCalled();
82+
expect(clear).not.toHaveBeenCalled();
83+
});
84+
85+
it("does not recover until the user is authenticated", async () => {
86+
authState.isAuthenticated = false;
87+
getAllNewestFirst.mockReturnValue([
88+
{ key: "newest", prompt: { promptText: "Newest prompt" } },
89+
]);
90+
91+
await mountRecovery();
92+
93+
expect(mockPush).not.toHaveBeenCalled();
94+
});
95+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { router } from "expo-router";
2+
import { useEffect } from "react";
3+
import { useAuthStore } from "@/features/auth";
4+
import { logger } from "@/lib/logger";
5+
import { pendingPromptRecoveryStoreApi } from "../stores/pendingPromptRecoveryStore";
6+
7+
const log = logger.scope("pending-prompt-recovery");
8+
9+
let recoveryStarted = false;
10+
11+
export function PendingPromptRecovery(): null {
12+
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
13+
14+
useEffect(() => {
15+
if (!isAuthenticated || recoveryStarted) return;
16+
recoveryStarted = true;
17+
void recoverNewestPrompt();
18+
}, [isAuthenticated]);
19+
20+
return null;
21+
}
22+
23+
async function recoverNewestPrompt(): Promise<void> {
24+
await pendingPromptRecoveryStoreApi.whenHydrated();
25+
const [newest] = pendingPromptRecoveryStoreApi.getAllNewestFirst();
26+
if (!newest) return;
27+
28+
log.info("Recovering a prompt whose task never finished creating");
29+
pendingPromptRecoveryStoreApi.clear(newest.key);
30+
router.push({
31+
pathname: "/task",
32+
params: { prompt: newest.prompt.promptText },
33+
});
34+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
pendingPromptRecoveryStoreApi,
4+
usePendingPromptRecoveryStore,
5+
} from "./pendingPromptRecoveryStore";
6+
7+
describe("pendingPromptRecoveryStore", () => {
8+
beforeEach(() => {
9+
usePendingPromptRecoveryStore.setState({ byKey: {}, hasHydrated: true });
10+
});
11+
12+
it("persists a prompt on submit and clears it on success", () => {
13+
pendingPromptRecoveryStoreApi.set("pending-1", "Fix the login bug");
14+
expect(pendingPromptRecoveryStoreApi.getAllNewestFirst()).toEqual([
15+
{
16+
key: "pending-1",
17+
prompt: expect.objectContaining({ promptText: "Fix the login bug" }),
18+
},
19+
]);
20+
21+
pendingPromptRecoveryStoreApi.clear("pending-1");
22+
expect(pendingPromptRecoveryStoreApi.getAllNewestFirst()).toEqual([]);
23+
});
24+
25+
it("caps the persisted set to the newest 20 entries", () => {
26+
let now = 1000;
27+
const spy = vi.spyOn(Date, "now").mockImplementation(() => now++);
28+
for (let i = 0; i < 25; i++) {
29+
pendingPromptRecoveryStoreApi.set(`k-${i}`, `prompt ${i}`);
30+
}
31+
spy.mockRestore();
32+
33+
const entries = pendingPromptRecoveryStoreApi.getAllNewestFirst();
34+
const keys = entries.map((e) => e.key);
35+
expect(entries).toHaveLength(20);
36+
expect(keys[0]).toBe("k-24");
37+
expect(keys).not.toContain("k-0");
38+
expect(keys).not.toContain("k-4");
39+
});
40+
41+
it("orders recoverable prompts newest-first", () => {
42+
usePendingPromptRecoveryStore.setState({
43+
byKey: {
44+
old: { promptText: "old", createdAt: 1 },
45+
newest: { promptText: "newest", createdAt: 3 },
46+
mid: { promptText: "mid", createdAt: 2 },
47+
},
48+
});
49+
50+
expect(
51+
pendingPromptRecoveryStoreApi.getAllNewestFirst().map((e) => e.key),
52+
).toEqual(["newest", "mid", "old"]);
53+
});
54+
55+
it("whenHydrated resolves once the rehydration flag flips", async () => {
56+
usePendingPromptRecoveryStore.setState({ hasHydrated: false });
57+
let resolved = false;
58+
const pending = pendingPromptRecoveryStoreApi.whenHydrated().then(() => {
59+
resolved = true;
60+
});
61+
62+
expect(resolved).toBe(false);
63+
usePendingPromptRecoveryStore.getState().setHasHydrated(true);
64+
await pending;
65+
expect(resolved).toBe(true);
66+
});
67+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import AsyncStorage from "@react-native-async-storage/async-storage";
2+
import { create } from "zustand";
3+
import { createJSONStorage, persist } from "zustand/middleware";
4+
5+
// Persisted (unlike the transient in-memory echo store, whose entries vanish
6+
// the instant the live SSE copy lands) so a prompt survives the app being
7+
// killed mid-create and can be recovered on the next launch.
8+
const MAX_RECOVERABLE_PROMPTS = 20;
9+
10+
export interface RecoverablePrompt {
11+
promptText: string;
12+
createdAt: number;
13+
}
14+
15+
interface PendingPromptRecoveryState {
16+
byKey: Record<string, RecoverablePrompt>;
17+
hasHydrated: boolean;
18+
set: (key: string, promptText: string) => void;
19+
clear: (key: string) => void;
20+
setHasHydrated: (hydrated: boolean) => void;
21+
}
22+
23+
function capToNewest(
24+
byKey: Record<string, RecoverablePrompt>,
25+
): Record<string, RecoverablePrompt> {
26+
const keys = Object.keys(byKey);
27+
if (keys.length <= MAX_RECOVERABLE_PROMPTS) return byKey;
28+
const kept = keys
29+
.sort((a, b) => byKey[b].createdAt - byKey[a].createdAt)
30+
.slice(0, MAX_RECOVERABLE_PROMPTS);
31+
const trimmed: Record<string, RecoverablePrompt> = {};
32+
for (const key of kept) trimmed[key] = byKey[key];
33+
return trimmed;
34+
}
35+
36+
export const usePendingPromptRecoveryStore =
37+
create<PendingPromptRecoveryState>()(
38+
persist(
39+
(set) => ({
40+
byKey: {},
41+
hasHydrated: false,
42+
set: (key, promptText) =>
43+
set((state) => ({
44+
byKey: capToNewest({
45+
...state.byKey,
46+
[key]: { promptText, createdAt: Date.now() },
47+
}),
48+
})),
49+
clear: (key) =>
50+
set((state) => {
51+
if (!(key in state.byKey)) return state;
52+
const { [key]: _removed, ...rest } = state.byKey;
53+
return { byKey: rest };
54+
}),
55+
setHasHydrated: (hydrated) => set({ hasHydrated: hydrated }),
56+
}),
57+
{
58+
name: "pending-task-prompt-recovery",
59+
storage: createJSONStorage(() => AsyncStorage),
60+
partialize: (state) => ({ byKey: state.byKey }),
61+
onRehydrateStorage: () => (state) => {
62+
(state ?? usePendingPromptRecoveryStore.getState()).setHasHydrated(
63+
true,
64+
);
65+
},
66+
},
67+
),
68+
);
69+
70+
export const pendingPromptRecoveryStoreApi = {
71+
set(key: string, promptText: string): void {
72+
usePendingPromptRecoveryStore.getState().set(key, promptText);
73+
},
74+
clear(key: string): void {
75+
usePendingPromptRecoveryStore.getState().clear(key);
76+
},
77+
getAllNewestFirst(): { key: string; prompt: RecoverablePrompt }[] {
78+
return Object.entries(usePendingPromptRecoveryStore.getState().byKey)
79+
.map(([key, prompt]) => ({ key, prompt }))
80+
.sort((a, b) => b.prompt.createdAt - a.prompt.createdAt);
81+
},
82+
whenHydrated(): Promise<void> {
83+
if (usePendingPromptRecoveryStore.getState().hasHydrated) {
84+
return Promise.resolve();
85+
}
86+
return new Promise((resolve) => {
87+
const unsubscribe = usePendingPromptRecoveryStore.subscribe((state) => {
88+
if (state.hasHydrated) {
89+
unsubscribe();
90+
resolve();
91+
}
92+
});
93+
});
94+
},
95+
};

0 commit comments

Comments
 (0)