Skip to content

Commit a71ae3d

Browse files
Share pending prompt recovery semantics
Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f
1 parent ae54cd5 commit a71ae3d

6 files changed

Lines changed: 119 additions & 54 deletions

File tree

apps/mobile/src/features/tasks/stores/pendingPromptRecoveryStore.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import {
2+
capPendingPrompts,
3+
listPendingPromptsNewestFirst,
4+
} from "@posthog/core/tasks/pendingPrompts";
15
import AsyncStorage from "@react-native-async-storage/async-storage";
26
import { create } from "zustand";
37
import { createJSONStorage, persist } from "zustand/middleware";
48

59
// Persisted (unlike the transient in-memory echo store, whose entries vanish
610
// the instant the live SSE copy lands) so a prompt survives the app being
711
// killed mid-create and can be recovered on the next launch.
8-
const MAX_RECOVERABLE_PROMPTS = 20;
9-
1012
export interface RecoverablePrompt {
1113
promptText: string;
1214
createdAt: number;
@@ -20,19 +22,6 @@ interface PendingPromptRecoveryState {
2022
setHasHydrated: (hydrated: boolean) => void;
2123
}
2224

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-
3625
export const usePendingPromptRecoveryStore =
3726
create<PendingPromptRecoveryState>()(
3827
persist(
@@ -41,7 +30,7 @@ export const usePendingPromptRecoveryStore =
4130
hasHydrated: false,
4231
set: (key, promptText) =>
4332
set((state) => ({
44-
byKey: capToNewest({
33+
byKey: capPendingPrompts({
4534
...state.byKey,
4635
[key]: { promptText, createdAt: Date.now() },
4736
}),
@@ -75,9 +64,9 @@ export const pendingPromptRecoveryStoreApi = {
7564
usePendingPromptRecoveryStore.getState().clear(key);
7665
},
7766
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);
67+
return listPendingPromptsNewestFirst(
68+
usePendingPromptRecoveryStore.getState().byKey,
69+
);
8170
},
8271
whenHydrated(): Promise<void> {
8372
if (usePendingPromptRecoveryStore.getState().hasHydrated) {

apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildPendingPromptKey } from "@posthog/core/tasks/pendingPrompts";
12
import { create } from "zustand";
23
import type { SessionNotificationAttachment } from "../types";
34

@@ -79,8 +80,9 @@ export function generatePendingTaskKey(): string {
7980
typeof globalThis !== "undefined"
8081
? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
8182
: undefined;
82-
if (cryptoObj?.randomUUID) {
83-
return cryptoObj.randomUUID();
84-
}
85-
return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
83+
return buildPendingPromptKey(
84+
cryptoObj?.randomUUID?.() ?? null,
85+
Date.now(),
86+
Math.random().toString(36).slice(2, 10),
87+
);
8688
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildPendingPromptKey,
4+
capPendingPrompts,
5+
listPendingPromptsNewestFirst,
6+
selectNewestPendingPrompt,
7+
} from "./pendingPrompts";
8+
9+
describe("pending prompts", () => {
10+
it("keeps the newest prompts up to the limit", () => {
11+
expect(
12+
capPendingPrompts(
13+
{
14+
old: { createdAt: 1 },
15+
middle: { createdAt: 2 },
16+
newest: { createdAt: 3 },
17+
},
18+
2,
19+
),
20+
).toEqual({ middle: { createdAt: 2 }, newest: { createdAt: 3 } });
21+
});
22+
23+
it("orders prompts newest first and selects the newest", () => {
24+
const prompts = { old: { createdAt: 1 }, new: { createdAt: 2 } };
25+
expect(
26+
listPendingPromptsNewestFirst(prompts).map(({ key }) => key),
27+
).toEqual(["new", "old"]);
28+
expect(selectNewestPendingPrompt(prompts)?.key).toBe("new");
29+
});
30+
31+
it.each([
32+
["uuid", 1, "abc", "uuid"],
33+
[null, 123, "abc", "pending-123-abc"],
34+
])("builds a portable pending key", (uuid, timestamp, entropy, expected) => {
35+
expect(buildPendingPromptKey(uuid, timestamp, entropy)).toBe(expected);
36+
});
37+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export const MAX_RECOVERABLE_PROMPTS = 20;
2+
3+
export interface TimestampedPendingPrompt {
4+
createdAt: number;
5+
}
6+
7+
export interface RecoverablePendingPrompt<
8+
TPrompt extends TimestampedPendingPrompt,
9+
> {
10+
key: string;
11+
prompt: TPrompt;
12+
}
13+
14+
export function capPendingPrompts<TPrompt extends TimestampedPendingPrompt>(
15+
byKey: Record<string, TPrompt>,
16+
limit: number = MAX_RECOVERABLE_PROMPTS,
17+
): Record<string, TPrompt> {
18+
const keys = Object.keys(byKey);
19+
if (keys.length <= limit) return byKey;
20+
21+
const kept = keys
22+
.sort((left, right) => byKey[right].createdAt - byKey[left].createdAt)
23+
.slice(0, limit);
24+
return Object.fromEntries(kept.map((key) => [key, byKey[key]]));
25+
}
26+
27+
export function listPendingPromptsNewestFirst<
28+
TPrompt extends TimestampedPendingPrompt,
29+
>(byKey: Record<string, TPrompt>): RecoverablePendingPrompt<TPrompt>[] {
30+
return Object.entries(byKey)
31+
.map(([key, prompt]) => ({ key, prompt }))
32+
.sort((left, right) => right.prompt.createdAt - left.prompt.createdAt);
33+
}
34+
35+
export function selectNewestPendingPrompt<
36+
TPrompt extends TimestampedPendingPrompt,
37+
>(byKey: Record<string, TPrompt>): RecoverablePendingPrompt<TPrompt> | null {
38+
return listPendingPromptsNewestFirst(byKey)[0] ?? null;
39+
}
40+
41+
export function buildPendingPromptKey(
42+
randomUuid: string | null,
43+
timestamp: number,
44+
entropy: string,
45+
): string {
46+
return randomUuid ?? `pending-${timestamp}-${entropy}`;
47+
}

packages/ui/src/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ import { useConnectivity } from "../../../hooks/useConnectivity";
3131
import { toast } from "../../../primitives/toast";
3232
import { track } from "../../../shell/analytics";
3333
import { logger } from "../../../shell/logger";
34-
import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore";
34+
import {
35+
generatePendingTaskKey,
36+
pendingTaskPromptStoreApi,
37+
} from "../../../shell/pendingTaskPromptStore";
3538
import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore";
3639
import { useAuthStateValue } from "../../auth/store";
3740
import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage";
@@ -319,7 +322,7 @@ export function useTaskCreation({
319322

320323
const shouldShowPendingView = !onTaskCreated && !!plainPromptText;
321324
const pendingTaskKey = shouldShowPendingView
322-
? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`)
325+
? generatePendingTaskKey()
323326
: null;
324327

325328
if (pendingTaskKey) {

packages/ui/src/shell/pendingTaskPromptStore.ts

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes";
2-
import { logger } from "@posthog/ui/shell/logger";
32
import { electronStorage } from "@posthog/ui/shell/rendererStorage";
43
import { create } from "zustand";
54
import { persist } from "zustand/middleware";
65

7-
const log = logger.scope("pending-task-prompts");
8-
9-
const MAX_PENDING_PROMPTS = 20;
10-
116
export interface PendingTaskPrompt {
127
promptText: string;
138
attachments: UserMessageAttachment[];
@@ -16,26 +11,6 @@ export interface PendingTaskPrompt {
1611

1712
export type PendingTaskPromptInput = Omit<PendingTaskPrompt, "createdAt">;
1813

19-
function capToNewest(
20-
byKey: Record<string, PendingTaskPrompt>,
21-
): Record<string, PendingTaskPrompt> {
22-
const keys = Object.keys(byKey);
23-
if (keys.length <= MAX_PENDING_PROMPTS) {
24-
return byKey;
25-
}
26-
const keptKeys = keys
27-
.sort((a, b) => byKey[b].createdAt - byKey[a].createdAt)
28-
.slice(0, MAX_PENDING_PROMPTS);
29-
log.warn("Dropping oldest unrecovered prompts beyond cap", {
30-
dropped: keys.length - keptKeys.length,
31-
});
32-
const kept: Record<string, PendingTaskPrompt> = {};
33-
for (const key of keptKeys) {
34-
kept[key] = byKey[key];
35-
}
36-
return kept;
37-
}
38-
3914
interface PendingTaskPromptStore {
4015
byKey: Record<string, PendingTaskPrompt>;
4116
_hasHydrated: boolean;
@@ -54,7 +29,7 @@ export const usePendingTaskPromptStore = create<PendingTaskPromptStore>()(
5429
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
5530
set: (key, prompt) =>
5631
set((state) => ({
57-
byKey: capToNewest({
32+
byKey: capPendingPrompts({
5833
...state.byKey,
5934
[key]: { ...prompt, createdAt: Date.now() },
6035
}),
@@ -110,9 +85,7 @@ export const pendingTaskPromptStoreApi = {
11085
usePendingTaskPromptStore.getState().move(fromKey, toKey),
11186
clear: (key: string) => usePendingTaskPromptStore.getState().clear(key),
11287
getAllNewestFirst: (): RecoverablePendingPrompt[] =>
113-
Object.entries(usePendingTaskPromptStore.getState().byKey)
114-
.map(([key, prompt]) => ({ key, prompt }))
115-
.sort((a, b) => b.prompt.createdAt - a.prompt.createdAt),
88+
listPendingPromptsNewestFirst(usePendingTaskPromptStore.getState().byKey),
11689
whenHydrated: (): Promise<void> => {
11790
if (usePendingTaskPromptStore.getState()._hasHydrated) {
11891
return Promise.resolve();
@@ -128,10 +101,24 @@ export const pendingTaskPromptStoreApi = {
128101
},
129102
};
130103

104+
export function generatePendingTaskKey(): string {
105+
return buildPendingPromptKey(
106+
globalThis.crypto?.randomUUID?.() ?? null,
107+
Date.now(),
108+
Math.random().toString(36).slice(2, 10),
109+
);
110+
}
111+
131112
export function usePendingTaskPrompt(
132113
key: string | undefined,
133114
): PendingTaskPrompt | undefined {
134115
return usePendingTaskPromptStore((state) =>
135116
key ? state.byKey[key] : undefined,
136117
);
137118
}
119+
120+
import {
121+
buildPendingPromptKey,
122+
capPendingPrompts,
123+
listPendingPromptsNewestFirst,
124+
} from "@posthog/core/tasks/pendingPrompts";

0 commit comments

Comments
 (0)