@tomako/tools-runtime 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +2 -1
  2. package/src/components/tools/engine/package-widget-runtime.tsx +5 -1
  3. package/src/components/tools/engine/task-recovery.test.ts +65 -0
  4. package/src/components/tools/engine/task-recovery.ts +109 -0
  5. package/src/components/tools/engine/use-legacy-llm-task.ts +16 -3
  6. package/src/components/tools/engine/use-tool-task.ts +77 -53
  7. package/src/features/tools/app-icon-resizer/app-icon-resizer.spec.ts +1 -1
  8. package/src/features/tools/app-store-screenshot-generator/app-store-screenshot-generator.container.tsx +6 -8
  9. package/src/features/tools/app-store-screenshot-generator/app-store-screenshot-generator.spec.ts +1 -1
  10. package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +44 -6
  11. package/src/features/tools/app-store-screenshot-generator/widget/generation.test.ts +126 -0
  12. package/src/features/tools/app-store-screenshot-generator/widget/generation.ts +52 -14
  13. package/src/features/tools/cold-start-channel-selector/cold-start-channel-selector.spec.ts +1 -1
  14. package/src/features/tools/cold-start-channel-selector/widget/cold-start-channel-selector.tsx +45 -16
  15. package/src/features/tools/competitor-analysis/competitor-analysis.container.tsx +31 -26
  16. package/src/features/tools/competitor-analysis/competitor-analysis.spec.ts +1 -1
  17. package/src/features/tools/competitor-analysis/widget/competitor-analysis.tsx +47 -16
  18. package/src/features/tools/disclaimer-generator/disclaimer-generator.package.ts +4 -4
  19. package/src/features/tools/email-template-generator/email-template-generator.container.tsx +45 -173
  20. package/src/features/tools/email-template-generator/email-template-generator.spec.ts +1 -1
  21. package/src/features/tools/email-template-generator/widget/email-template-generator.tsx +37 -4
  22. package/src/features/tools/package/to-tool-module.ts +15 -0
  23. package/src/features/tools/product-name-generator/product-name-generator.spec.ts +1 -1
  24. package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +60 -27
  25. package/src/features/tools/product-poster-generator/product-poster-generator.spec.ts +1 -1
  26. package/src/features/tools/product-poster-generator/widget/constants.ts +3 -1
  27. package/src/features/tools/product-poster-generator/widget/helpers.test.ts +51 -0
  28. package/src/features/tools/product-poster-generator/widget/helpers.ts +27 -0
  29. package/src/features/tools/product-poster-generator/widget/product-poster-generator.tsx +112 -12
  30. package/src/features/tools/slogan-generator/slogan-generator.package.ts +101 -29
  31. package/src/features/tools/twitter-gif-downloader/twitter-gif-downloader.container.tsx +2 -3
  32. package/src/features/tools/twitter-gif-downloader/twitter-gif-downloader.spec.ts +1 -1
  33. package/src/i18n/messages/en/tools/app-icon-resizer.ts +1 -1
  34. package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +3 -3
  35. package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -4
  36. package/src/i18n/messages/en/tools/competitor-analysis.ts +14 -10
  37. package/src/i18n/messages/en/tools/email-template-generator.ts +13 -13
  38. package/src/i18n/messages/en/tools/product-poster-generator.ts +3 -2
  39. package/src/i18n/messages/en/tools/twitter-gif-downloader.ts +1 -1
  40. package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +4 -4
  41. package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +2 -2
  42. package/src/i18n/messages/zh/tools/competitor-analysis.ts +14 -10
  43. package/src/i18n/messages/zh/tools/email-template-generator.ts +12 -12
  44. package/src/i18n/messages/zh/tools/product-poster-generator.ts +2 -1
  45. package/src/i18n/messages/zh/tools/twitter-gif-downloader.ts +1 -1
  46. package/src/i18n/messages/zh-tw/tools/app-store-screenshot-generator.ts +44 -44
  47. package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +105 -105
  48. package/src/i18n/messages/zh-tw/tools/competitor-analysis.ts +44 -40
  49. package/src/i18n/messages/zh-tw/tools/email-template-generator.ts +112 -112
  50. package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +92 -92
  51. package/src/i18n/messages/zh-tw/tools/product-poster-generator.ts +74 -73
  52. package/src/i18n/messages/zh-tw/tools/twitter-gif-downloader.ts +48 -48
  53. package/src/lib/tools/cold-start-channel-selector-schema.test.ts +39 -0
  54. package/src/lib/tools/cold-start-channel-selector-schema.ts +8 -4
  55. package/src/lib/tools/competitor-analysis-schema.test.ts +69 -0
  56. package/src/lib/tools/competitor-analysis-schema.ts +53 -5
  57. package/src/lib/tools/ops-catalog.ts +10 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomako/tools-runtime",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Shared interactive tools runtime for Tomako-FE workspace dialogs and Tomako-SEO tool pages",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,6 +31,7 @@
31
31
  "./components/tools/engine/package-widget-runtime": "./src/components/tools/engine/package-widget-runtime.tsx",
32
32
  "./components/tools/engine/package-widget": "./src/components/tools/engine/package-widget.tsx",
33
33
  "./components/tools/engine/result-view": "./src/components/tools/engine/result-view.tsx",
34
+ "./components/tools/engine/task-recovery": "./src/components/tools/engine/task-recovery.ts",
34
35
  "./components/tools/engine/use-legacy-llm-task": "./src/components/tools/engine/use-legacy-llm-task.ts",
35
36
  "./components/tools/engine/use-tool-task": "./src/components/tools/engine/use-tool-task.ts",
36
37
  "./components/tools/market-forecast-report": "./src/components/tools/market-forecast-report.tsx",
@@ -72,7 +72,7 @@ export function PackageWidgetRuntime({
72
72
  }) {
73
73
  const locale = useLocale() as AppLocale;
74
74
  const t = useTranslations("toolsPages.engine");
75
- const { state, start, reset } = useToolTask(pkg, locale);
75
+ const { state, start, reset, recoveredValues } = useToolTask(pkg, locale);
76
76
  const resultRef = useRef<HTMLElement | null>(null);
77
77
  const artifactRegistrationRef = useRef<string | null>(null);
78
78
  const { brief: activityBrief } = useToolHandoff();
@@ -85,6 +85,10 @@ export function PackageWidgetRuntime({
85
85
  DEFAULT_DIRECT_MODEL_OPTION,
86
86
  );
87
87
 
88
+ useEffect(() => {
89
+ if (recoveredValues) setValues(recoveredValues);
90
+ }, [recoveredValues]);
91
+
88
92
  const busy = state.phase === "submitting" || state.phase === "running";
89
93
  const isWorkspace = variant === "workspace";
90
94
 
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import {
5
+ clearToolTaskRecovery,
6
+ persistToolTaskRecovery,
7
+ readToolTaskRecovery,
8
+ toolTaskRecoveryStorageKey,
9
+ } from "./task-recovery";
10
+
11
+ function memoryStorage() {
12
+ const values = new Map<string, string>();
13
+ return {
14
+ getItem: (key: string) => values.get(key) ?? null,
15
+ setItem: (key: string, value: string) => values.set(key, value),
16
+ removeItem: (key: string) => values.delete(key),
17
+ };
18
+ }
19
+
20
+ describe("tool task recovery", () => {
21
+ it("restores task identity and input without storing server result data", () => {
22
+ const storage = memoryStorage();
23
+ persistToolTaskRecovery(
24
+ "competitor-analysis",
25
+ "llm-0123456789abcdef",
26
+ { url: "https://example.com" },
27
+ 1_000,
28
+ storage,
29
+ );
30
+
31
+ assert.deepEqual(
32
+ readToolTaskRecovery("competitor-analysis", {
33
+ nowMs: 2_000,
34
+ storage,
35
+ }),
36
+ {
37
+ version: 1,
38
+ taskId: "llm-0123456789abcdef",
39
+ input: { url: "https://example.com" },
40
+ startedAtMs: 1_000,
41
+ },
42
+ );
43
+ assert.equal(
44
+ storage.getItem(toolTaskRecoveryStorageKey("competitor-analysis"))?.includes("result"),
45
+ false,
46
+ );
47
+ });
48
+
49
+ it("removes expired or invalid recovery records", () => {
50
+ const storage = memoryStorage();
51
+ persistToolTaskRecovery("cold-start", "llm-task", { audience: "founders" }, 1_000, storage);
52
+ assert.equal(
53
+ readToolTaskRecovery("cold-start", { nowMs: 10_000, ttlMs: 100, storage }),
54
+ null,
55
+ );
56
+ assert.equal(storage.getItem(toolTaskRecoveryStorageKey("cold-start")), null);
57
+ });
58
+
59
+ it("clears recovery on an explicit reset", () => {
60
+ const storage = memoryStorage();
61
+ persistToolTaskRecovery("email-template-generator", "llm-task", {}, 1_000, storage);
62
+ clearToolTaskRecovery("email-template-generator", storage);
63
+ assert.equal(readToolTaskRecovery("email-template-generator", { storage }), null);
64
+ });
65
+ });
@@ -0,0 +1,109 @@
1
+ export const TOOL_TASK_RECOVERY_TTL_MS = 24 * 60 * 60 * 1000;
2
+
3
+ export type ToolTaskRecovery<TInput = unknown> = {
4
+ version: 1;
5
+ taskId: string;
6
+ input: TInput;
7
+ startedAtMs: number;
8
+ };
9
+
10
+ type RecoveryStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
11
+
12
+ function browserSessionStorage(): RecoveryStorage | null {
13
+ if (typeof window === "undefined") return null;
14
+ try {
15
+ return window.sessionStorage;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function toolTaskRecoveryStorageKey(slug: string): string {
22
+ return `tomako.tool-task.${slug.trim().toLowerCase()}.v1`;
23
+ }
24
+
25
+ /** Persist only task identity and user input; auth and result data stay server-owned. */
26
+ export function persistToolTaskRecovery<TInput>(
27
+ slug: string,
28
+ taskId: string,
29
+ input: TInput,
30
+ startedAtMs = Date.now(),
31
+ storage: RecoveryStorage | null = browserSessionStorage(),
32
+ ): ToolTaskRecovery<TInput> | null {
33
+ if (!storage || !slug.trim() || !taskId.trim()) return null;
34
+ const recovery: ToolTaskRecovery<TInput> = {
35
+ version: 1,
36
+ taskId: taskId.trim(),
37
+ input,
38
+ startedAtMs,
39
+ };
40
+ try {
41
+ storage.setItem(toolTaskRecoveryStorageKey(slug), JSON.stringify(recovery));
42
+ return recovery;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function readToolTaskRecovery<TInput>(
49
+ slug: string,
50
+ options: {
51
+ ttlMs?: number;
52
+ nowMs?: number;
53
+ storage?: RecoveryStorage | null;
54
+ parseInput?: (input: unknown) => TInput | null;
55
+ } = {},
56
+ ): ToolTaskRecovery<TInput> | null {
57
+ const storage = options.storage === undefined
58
+ ? browserSessionStorage()
59
+ : options.storage;
60
+ if (!storage || !slug.trim()) return null;
61
+ const key = toolTaskRecoveryStorageKey(slug);
62
+ try {
63
+ const raw = storage.getItem(key);
64
+ if (!raw) return null;
65
+ const parsed = JSON.parse(raw) as Partial<ToolTaskRecovery<unknown>>;
66
+ const nowMs = options.nowMs ?? Date.now();
67
+ const ttlMs = options.ttlMs ?? TOOL_TASK_RECOVERY_TTL_MS;
68
+ if (
69
+ parsed.version !== 1 ||
70
+ typeof parsed.taskId !== "string" ||
71
+ !parsed.taskId.trim() ||
72
+ typeof parsed.startedAtMs !== "number" ||
73
+ !Number.isFinite(parsed.startedAtMs) ||
74
+ parsed.startedAtMs > nowMs + 60_000 ||
75
+ nowMs - parsed.startedAtMs > ttlMs
76
+ ) {
77
+ storage.removeItem(key);
78
+ return null;
79
+ }
80
+ const input = options.parseInput
81
+ ? options.parseInput(parsed.input)
82
+ : (parsed.input as TInput);
83
+ if (input === null) {
84
+ storage.removeItem(key);
85
+ return null;
86
+ }
87
+ return {
88
+ version: 1,
89
+ taskId: parsed.taskId.trim(),
90
+ input,
91
+ startedAtMs: parsed.startedAtMs,
92
+ };
93
+ } catch {
94
+ storage.removeItem(key);
95
+ return null;
96
+ }
97
+ }
98
+
99
+ export function clearToolTaskRecovery(
100
+ slug: string,
101
+ storage: RecoveryStorage | null = browserSessionStorage(),
102
+ ) {
103
+ if (!storage || !slug.trim()) return;
104
+ try {
105
+ storage.removeItem(toolTaskRecoveryStorageKey(slug));
106
+ } catch {
107
+ // Recovery is best effort and must never block a user reset.
108
+ }
109
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef } from "react";
4
4
 
5
5
  import {
6
6
  watchLlmTask,
@@ -41,7 +41,11 @@ export function useLegacyLlmTaskWatch<TResult>() {
41
41
  taskId: string,
42
42
  pollOnce: (taskId: string) => Promise<LlmTaskWatchPollOutcome>,
43
43
  handlers: LegacyTaskWatchHandlers<TResult>,
44
- options?: { pollIntervalMs?: number; graceWindowMs?: number },
44
+ options?: {
45
+ pollIntervalMs?: number;
46
+ graceWindowMs?: number;
47
+ startedAtMs?: number;
48
+ },
45
49
  ) => {
46
50
  stop();
47
51
  stopRef.current = watchLlmTask({
@@ -55,10 +59,19 @@ export function useLegacyLlmTaskWatch<TResult>() {
55
59
  isCancelled: () => cancelledRef.current,
56
60
  pollIntervalMs: options?.pollIntervalMs,
57
61
  graceWindowMs: options?.graceWindowMs,
62
+ startedAtMs: options?.startedAtMs,
58
63
  });
59
64
  },
60
65
  [stop],
61
66
  );
62
67
 
63
- return { start, stop, isCancelled: () => cancelledRef.current };
68
+ const isCancelled = useCallback(() => cancelledRef.current, []);
69
+
70
+ // Consumers legitimately use the controller as an effect dependency. Keep
71
+ // its identity stable so a state update does not run their cleanup and stop
72
+ // the task watcher that was just started.
73
+ return useMemo(
74
+ () => ({ start, stop, isCancelled }),
75
+ [isCancelled, start, stop],
76
+ );
64
77
  }
@@ -15,6 +15,11 @@ import {
15
15
  submitPackageTask,
16
16
  } from "../../../host/client";
17
17
  import { watchLlmTask } from "../../../host/client";
18
+ import {
19
+ clearToolTaskRecovery,
20
+ persistToolTaskRecovery,
21
+ readToolTaskRecovery,
22
+ } from "./task-recovery";
18
23
 
19
24
  export type EngineErrorCode =
20
25
  | "validation"
@@ -47,6 +52,7 @@ export function useToolTask<TResult = unknown>(
47
52
  locale: AppLocale,
48
53
  ) {
49
54
  const [state, setState] = useState<ToolTaskState<TResult>>({ phase: "idle" });
55
+ const [recoveredValues, setRecoveredValues] = useState<FormValues | null>(null);
50
56
  const cancelledRef = useRef(false);
51
57
  const stopWatchRef = useRef<(() => void) | null>(null);
52
58
  const lastSnapshotRef = useRef<{ status: string; error?: string }>({
@@ -61,11 +67,76 @@ export function useToolTask<TResult = unknown>(
61
67
  };
62
68
  }, []);
63
69
 
70
+ const startAgentWatch = useCallback((taskId: string, startedAtMs?: number) => {
71
+ if (pkg.runtime.lane !== "agent") return;
72
+ const runtime = pkg.runtime;
73
+ setState({ phase: "running", taskId, status: startedAtMs ? "RESTORING" : "PENDING" });
74
+ stopWatchRef.current = watchLlmTask({
75
+ taskId,
76
+ startedAtMs,
77
+ pollIntervalMs: runtime.watch?.pollIntervalMs,
78
+ graceWindowMs: runtime.watch?.graceWindowMs,
79
+ isCancelled: () => cancelledRef.current,
80
+ pollOnce: async (id: string) => {
81
+ const snapshot = await fetchPackageTask<TResult>(pkg, id);
82
+ lastSnapshotRef.current = { status: snapshot.status, error: snapshot.error };
83
+ if (snapshot.data) {
84
+ return { settled: true, kind: "success", data: snapshot.data };
85
+ }
86
+ if (snapshot.status === "FAILED") {
87
+ return { settled: true, kind: "error", category: "task" };
88
+ }
89
+ if (snapshot.status === "RESULT_INVALID") {
90
+ return { settled: true, kind: "error", category: "schema" };
91
+ }
92
+ return { settled: false, pendingWriteback: true };
93
+ },
94
+ handlers: {
95
+ onStatus: (status: string) => {
96
+ setState((prev) =>
97
+ prev.phase === "running" ? { ...prev, status } : prev,
98
+ );
99
+ },
100
+ onSuccess: (data: TResult) => {
101
+ setState({ phase: "done", taskId, data: data as TResult });
102
+ },
103
+ onError: (category: string) => {
104
+ const { status, error } = lastSnapshotRef.current;
105
+ let code: EngineErrorCode;
106
+ if (category === "timeout") {
107
+ code =
108
+ status === "SUCCEEDED" || status === "AWAITING_RESULT"
109
+ ? "resultWritebackMissing"
110
+ : "resultTimeout";
111
+ } else if (category === "schema") {
112
+ code =
113
+ error === "wrong_result_type"
114
+ ? "wrongResultType"
115
+ : "invalidResultSchema";
116
+ } else {
117
+ code = "taskFailed";
118
+ }
119
+ setState({ phase: "error", code, taskId });
120
+ },
121
+ },
122
+ });
123
+ }, [pkg]);
124
+
125
+ useEffect(() => {
126
+ if (pkg.runtime.lane !== "agent") return;
127
+ const recovered = readToolTaskRecovery<FormValues>(pkg.meta.slug);
128
+ if (!recovered) return;
129
+ setRecoveredValues(recovered.input);
130
+ startAgentWatch(recovered.taskId, recovered.startedAtMs);
131
+ }, [pkg.meta.slug, pkg.runtime.lane, startAgentWatch]);
132
+
64
133
  const reset = useCallback(() => {
65
134
  stopWatchRef.current?.();
66
135
  stopWatchRef.current = null;
136
+ clearToolTaskRecovery(pkg.meta.slug);
137
+ setRecoveredValues(null);
67
138
  setState({ phase: "idle" });
68
- }, []);
139
+ }, [pkg.meta.slug]);
69
140
 
70
141
  const start = useCallback(
71
142
  async (values: FormValues, options?: { directModel?: string }) => {
@@ -137,59 +208,12 @@ export function useToolTask<TResult = unknown>(
137
208
  }
138
209
 
139
210
  if (cancelledRef.current) return;
140
- setState({ phase: "running", taskId, status: "PENDING" });
141
-
142
- stopWatchRef.current = watchLlmTask({
143
- taskId,
144
- pollIntervalMs: pkg.runtime.watch?.pollIntervalMs,
145
- graceWindowMs: pkg.runtime.watch?.graceWindowMs,
146
- isCancelled: () => cancelledRef.current,
147
- pollOnce: async (id: string) => {
148
- const snapshot = await fetchPackageTask<TResult>(pkg, id);
149
- lastSnapshotRef.current = { status: snapshot.status, error: snapshot.error };
150
- if (snapshot.data) {
151
- return { settled: true, kind: "success", data: snapshot.data };
152
- }
153
- if (snapshot.status === "FAILED") {
154
- return { settled: true, kind: "error", category: "task" };
155
- }
156
- if (snapshot.status === "RESULT_INVALID") {
157
- return { settled: true, kind: "error", category: "schema" };
158
- }
159
- return { settled: false, pendingWriteback: true };
160
- },
161
- handlers: {
162
- onStatus: (status: string) => {
163
- setState((prev) =>
164
- prev.phase === "running" ? { ...prev, status } : prev,
165
- );
166
- },
167
- onSuccess: (data: TResult) => {
168
- setState({ phase: "done", taskId, data: data as TResult });
169
- },
170
- onError: (category: string) => {
171
- const { status, error } = lastSnapshotRef.current;
172
- let code: EngineErrorCode;
173
- if (category === "timeout") {
174
- code =
175
- status === "SUCCEEDED" || status === "AWAITING_RESULT"
176
- ? "resultWritebackMissing"
177
- : "resultTimeout";
178
- } else if (category === "schema") {
179
- code =
180
- error === "wrong_result_type"
181
- ? "wrongResultType"
182
- : "invalidResultSchema";
183
- } else {
184
- code = "taskFailed";
185
- }
186
- setState({ phase: "error", code, taskId });
187
- },
188
- },
189
- });
211
+ const startedAtMs = Date.now();
212
+ persistToolTaskRecovery(pkg.meta.slug, taskId, values, startedAtMs);
213
+ startAgentWatch(taskId, startedAtMs);
190
214
  },
191
- [locale, pkg],
215
+ [locale, pkg, startAgentWatch],
192
216
  );
193
217
 
194
- return { state, start, reset } as const;
218
+ return { state, start, reset, recoveredValues } as const;
195
219
  }
@@ -6,7 +6,7 @@ export const appIconResizerSpec: ToolSpec = {
6
6
  riskLevel: "low",
7
7
  seo: {
8
8
  "publishedAt": "2026-06-30",
9
- "updatedAt": "2026-08-19",
9
+ "updatedAt": "2026-08-30",
10
10
  "readingTime": "5 min",
11
11
  "featured": false,
12
12
  "ogImage": "/tools/app-icon-resizer-section-primary-8bit-v3.webp"
@@ -15,7 +15,6 @@ import { ToolCta } from "../shared/tool-cta";
15
15
  import { ToolGuideSection, ToolImageVisual, ToolWorkspaceSection } from "../shared/tool-guide-section";
16
16
  import { ToolPageShell } from "../shared/tool-page-shell";
17
17
  import type { ToolPageProps } from "../types";
18
- import { ImgConfig } from "./config";
19
18
 
20
19
  export async function AppStoreScreenshotGeneratorContainer({
21
20
  locale,
@@ -30,14 +29,13 @@ export async function AppStoreScreenshotGeneratorContainer({
30
29
  <ToolPageShell
31
30
  locale={locale}
32
31
  spec={spec}
33
- heroVariant="immersive"
34
- heroBackgroundImage={ImgConfig.heroBgImg}
35
- heroBackgroundAlt={t("heroVisualAlt")}
36
- heroBackgroundColor="#F7F6F2"
37
- heroBackgroundImageFrameClassName="inset-y-16 left-1/2 w-[calc(100%-2.5rem)] max-w-[1472px] -translate-x-1/2 sm:w-[calc(100%-4rem)] md:inset-y-24"
38
- heroBackgroundImageSizes="(min-width: 1536px) 1472px, (min-width: 640px) calc(100vw - 4rem), calc(100vw - 2.5rem)"
32
+ showDefaultHeader={false}
39
33
  >
40
- <ToolWorkspaceSection heading={t("workspaceHeading")} hint={t("workspaceHint")}>
34
+ <ToolWorkspaceSection
35
+ heading={t("workspaceHeading")}
36
+ hint={t("workspaceHint")}
37
+ headingLevel="h1"
38
+ >
41
39
  <AppStoreScreenshotGenerator />
42
40
  </ToolWorkspaceSection>
43
41
 
@@ -5,7 +5,7 @@ export const appStoreScreenshotGeneratorSpec: ToolSpec = {
5
5
  toolType: "generator",
6
6
  seo: {
7
7
  "publishedAt": "2026-06-28",
8
- "updatedAt": "2026-07-03",
8
+ "updatedAt": "2026-08-30",
9
9
  "readingTime": "5 min",
10
10
  "featured": false,
11
11
  "ogImage": "/tools/app-store-screenshot-generator-value.webp"
@@ -84,6 +84,13 @@ import type {
84
84
  OutputLanguageKey,
85
85
  StyleKey
86
86
  } from "./types";
87
+ import {
88
+ clearToolTaskRecovery,
89
+ persistToolTaskRecovery,
90
+ readToolTaskRecovery,
91
+ } from "../../../../components/tools/engine/task-recovery";
92
+
93
+ const RECOVERY_SLUG = "app-store-screenshot-generator";
87
94
 
88
95
  export function AppStoreScreenshotGenerator() {
89
96
  const locale = useLocale();
@@ -114,6 +121,21 @@ export function AppStoreScreenshotGenerator() {
114
121
  const [logoDragActive, setLogoDragActive] = useState(false);
115
122
  const { brief: activityBrief } = useToolHandoff();
116
123
 
124
+ useEffect(() => {
125
+ const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
126
+ parseInput: (value) => {
127
+ const parsed = appStoreScreenshotGeneratorInputSchema.safeParse(value);
128
+ return parsed.success ? parsed.data : null;
129
+ },
130
+ });
131
+ if (!recovered) return;
132
+ setDraft(recovered.input);
133
+ setScreenshotReferences(recovered.input.screenshotReferences);
134
+ void runGeneration(recovered.input, recovered.taskId, recovered.startedAtMs);
135
+ // Restore only once for this mounted tool route.
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, []);
138
+
117
139
  useEffect(() => {
118
140
  if (!activityBrief || busy || !output) return;
119
141
  publishActivityArtifact(
@@ -179,8 +201,16 @@ export function AppStoreScreenshotGenerator() {
179
201
  return;
180
202
  }
181
203
 
204
+ await runGeneration(parsed.data);
205
+ }
206
+
207
+ async function runGeneration(
208
+ input: AppStoreScreenshotGeneratorInput,
209
+ recoveredTaskId?: string,
210
+ recoveredStartedAtMs?: number,
211
+ ) {
182
212
  try {
183
- setDraft(parsed.data);
213
+ setDraft(input);
184
214
  setBusy(true);
185
215
  setOutput(null);
186
216
  setSelectedId(null);
@@ -190,16 +220,16 @@ export function AppStoreScreenshotGenerator() {
190
220
  setGenerationProgress({
191
221
  phase: "submitting",
192
222
  completedCount: 0,
193
- totalCount: expectedAssetCount(parsed.data),
223
+ totalCount: expectedAssetCount(input),
194
224
  failedCount: 0,
195
- estimatedRemainingSeconds: initialEstimatedSeconds(parsed.data),
225
+ estimatedRemainingSeconds: initialEstimatedSeconds(input),
196
226
  });
197
227
  requestAnimationFrame(() => {
198
228
  resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
199
229
  });
200
230
 
201
231
  const generated = await generateStoreAssets(
202
- parsed.data,
232
+ input,
203
233
  locale,
204
234
  (progress) => {
205
235
  const parsedProgressOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(
@@ -226,11 +256,18 @@ export function AppStoreScreenshotGenerator() {
226
256
  updateGenerationProgress({
227
257
  phase: "planning",
228
258
  completedCount: 0,
229
- totalCount: expectedAssetCount(parsed.data),
259
+ totalCount: expectedAssetCount(input),
230
260
  failedCount: 0,
231
- estimatedRemainingSeconds: initialEstimatedSeconds(parsed.data),
261
+ estimatedRemainingSeconds: initialEstimatedSeconds(input),
232
262
  });
233
263
  },
264
+ {
265
+ taskId: recoveredTaskId,
266
+ startedAtMs: recoveredStartedAtMs,
267
+ onTaskSubmitted: (taskId, startedAtMs) => {
268
+ persistToolTaskRecovery(RECOVERY_SLUG, taskId, input, startedAtMs);
269
+ },
270
+ },
234
271
  );
235
272
  const parsedOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(generated.output);
236
273
 
@@ -286,6 +323,7 @@ export function AppStoreScreenshotGenerator() {
286
323
  }
287
324
 
288
325
  function reset() {
326
+ clearToolTaskRecovery(RECOVERY_SLUG);
289
327
  setOutput(null);
290
328
  setSelectedId(null);
291
329
  setLightboxIndex(null);