@tomako/tools-runtime 0.1.5 → 0.1.7

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 (47) 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-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +88 -16
  8. package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.test.ts +52 -0
  9. package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.ts +3 -2
  10. package/src/features/tools/app-store-screenshot-generator/widget/generation.test.ts +126 -0
  11. package/src/features/tools/app-store-screenshot-generator/widget/generation.ts +52 -14
  12. package/src/features/tools/app-store-screenshot-generator/widget/input-media.test.ts +81 -0
  13. package/src/features/tools/app-store-screenshot-generator/widget/input-media.ts +20 -8
  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 +26 -20
  16. package/src/features/tools/competitor-analysis/widget/competitor-analysis.tsx +47 -16
  17. package/src/features/tools/disclaimer-generator/disclaimer-generator.package.ts +3 -3
  18. package/src/features/tools/email-template-generator/widget/email-template-generator.tsx +37 -4
  19. package/src/features/tools/package/to-tool-module.ts +15 -0
  20. package/src/features/tools/product-name-generator/widget/product-name-generator.tsx +60 -27
  21. package/src/features/tools/product-poster-generator/widget/constants.ts +3 -1
  22. package/src/features/tools/product-poster-generator/widget/helpers.test.ts +51 -0
  23. package/src/features/tools/product-poster-generator/widget/helpers.ts +27 -0
  24. package/src/features/tools/product-poster-generator/widget/product-poster-generator.tsx +112 -12
  25. package/src/features/tools/slogan-generator/slogan-generator.package.ts +28 -28
  26. package/src/i18n/messages/en/tools/app-icon-resizer.ts +1 -1
  27. package/src/i18n/messages/en/tools/app-store-screenshot-generator.ts +2 -2
  28. package/src/i18n/messages/en/tools/cold-start-channel-selector.ts +4 -4
  29. package/src/i18n/messages/en/tools/competitor-analysis.ts +9 -8
  30. package/src/i18n/messages/en/tools/email-template-generator.ts +10 -10
  31. package/src/i18n/messages/en/tools/product-poster-generator.ts +1 -0
  32. package/src/i18n/messages/zh/tools/app-store-screenshot-generator.ts +3 -3
  33. package/src/i18n/messages/zh/tools/cold-start-channel-selector.ts +2 -2
  34. package/src/i18n/messages/zh/tools/competitor-analysis.ts +10 -9
  35. package/src/i18n/messages/zh/tools/email-template-generator.ts +11 -11
  36. package/src/i18n/messages/zh/tools/product-poster-generator.ts +1 -0
  37. package/src/i18n/messages/zh-tw/tools/app-store-screenshot-generator.ts +44 -44
  38. package/src/i18n/messages/zh-tw/tools/cold-start-channel-selector.ts +105 -105
  39. package/src/i18n/messages/zh-tw/tools/competitor-analysis.ts +41 -40
  40. package/src/i18n/messages/zh-tw/tools/email-template-generator.ts +111 -111
  41. package/src/i18n/messages/zh-tw/tools/product-name-generator.ts +92 -92
  42. package/src/i18n/messages/zh-tw/tools/product-poster-generator.ts +74 -73
  43. package/src/i18n/messages/zh-tw/tools/twitter-gif-downloader.ts +47 -47
  44. package/src/lib/tools/cold-start-channel-selector-schema.test.ts +39 -0
  45. package/src/lib/tools/cold-start-channel-selector-schema.ts +8 -4
  46. package/src/lib/tools/competitor-analysis-schema.test.ts +69 -0
  47. package/src/lib/tools/competitor-analysis-schema.ts +53 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomako/tools-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
  }
@@ -44,7 +44,7 @@ import {
44
44
  buildGeneratedAssetSlots,
45
45
  clampAssetCount,
46
46
  dimensionsForAssetFormat,
47
- widthForAssetFormat,
47
+ gridClassForAssetFormat,
48
48
  } from "./asset-slots";
49
49
  import {
50
50
  LOGO_REFERENCE_MAX_DATA_URL_LENGTH,
@@ -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(
@@ -138,9 +160,12 @@ export function AppStoreScreenshotGenerator() {
138
160
  () => downloadableAssets.filter((asset) => downloadSelectionIds.has(asset.id)),
139
161
  [downloadSelectionIds, downloadableAssets],
140
162
  );
163
+ const soleDownloadableAsset = downloadableAssets.length === 1 ? downloadableAssets[0] : null;
141
164
  const downloadButtonLabel = downloadSelectionMode
142
165
  ? `${t("download")} ${selectedDownloadAssets.length}/${downloadableAssets.length}`
143
- : t("download");
166
+ : soleDownloadableAsset
167
+ ? t("downloadPng")
168
+ : t("download");
144
169
 
145
170
  async function submit(event: FormEvent<HTMLFormElement>) {
146
171
  event.preventDefault();
@@ -179,8 +204,16 @@ export function AppStoreScreenshotGenerator() {
179
204
  return;
180
205
  }
181
206
 
207
+ await runGeneration(parsed.data);
208
+ }
209
+
210
+ async function runGeneration(
211
+ input: AppStoreScreenshotGeneratorInput,
212
+ recoveredTaskId?: string,
213
+ recoveredStartedAtMs?: number,
214
+ ) {
182
215
  try {
183
- setDraft(parsed.data);
216
+ setDraft(input);
184
217
  setBusy(true);
185
218
  setOutput(null);
186
219
  setSelectedId(null);
@@ -190,16 +223,16 @@ export function AppStoreScreenshotGenerator() {
190
223
  setGenerationProgress({
191
224
  phase: "submitting",
192
225
  completedCount: 0,
193
- totalCount: expectedAssetCount(parsed.data),
226
+ totalCount: expectedAssetCount(input),
194
227
  failedCount: 0,
195
- estimatedRemainingSeconds: initialEstimatedSeconds(parsed.data),
228
+ estimatedRemainingSeconds: initialEstimatedSeconds(input),
196
229
  });
197
230
  requestAnimationFrame(() => {
198
231
  resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
199
232
  });
200
233
 
201
234
  const generated = await generateStoreAssets(
202
- parsed.data,
235
+ input,
203
236
  locale,
204
237
  (progress) => {
205
238
  const parsedProgressOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(
@@ -226,11 +259,18 @@ export function AppStoreScreenshotGenerator() {
226
259
  updateGenerationProgress({
227
260
  phase: "planning",
228
261
  completedCount: 0,
229
- totalCount: expectedAssetCount(parsed.data),
262
+ totalCount: expectedAssetCount(input),
230
263
  failedCount: 0,
231
- estimatedRemainingSeconds: initialEstimatedSeconds(parsed.data),
264
+ estimatedRemainingSeconds: initialEstimatedSeconds(input),
232
265
  });
233
266
  },
267
+ {
268
+ taskId: recoveredTaskId,
269
+ startedAtMs: recoveredStartedAtMs,
270
+ onTaskSubmitted: (taskId, startedAtMs) => {
271
+ persistToolTaskRecovery(RECOVERY_SLUG, taskId, input, startedAtMs);
272
+ },
273
+ },
234
274
  );
235
275
  const parsedOutput = appStoreScreenshotGeneratorOutputSchema.safeParse(generated.output);
236
276
 
@@ -286,6 +326,7 @@ export function AppStoreScreenshotGenerator() {
286
326
  }
287
327
 
288
328
  function reset() {
329
+ clearToolTaskRecovery(RECOVERY_SLUG);
289
330
  setOutput(null);
290
331
  setSelectedId(null);
291
332
  setLightboxIndex(null);
@@ -451,6 +492,14 @@ export function AppStoreScreenshotGenerator() {
451
492
  if (!downloadableAssets.length || downloading) return;
452
493
  setDownloadError(null);
453
494
 
495
+ if (soleDownloadableAsset) {
496
+ await downloadAssets([soleDownloadableAsset], {
497
+ forceZip: false,
498
+ zipName: buildScreenshotsZipName(draft, "selected"),
499
+ });
500
+ return;
501
+ }
502
+
454
503
  if (!downloadSelectionMode) {
455
504
  setDownloadSelectionMode(true);
456
505
  setDownloadSelectionIds(
@@ -483,6 +532,15 @@ export function AppStoreScreenshotGenerator() {
483
532
  });
484
533
  }
485
534
 
535
+ function handleDownloadSvgClick(asset: AppStoreScreenshotAsset) {
536
+ if (!asset.svg || downloading) return;
537
+ setDownloadError(null);
538
+ downloadBlob(
539
+ new Blob([asset.svg], { type: "image/svg+xml;charset=utf-8" }),
540
+ `${assetFileBase(draft, asset)}.svg`,
541
+ );
542
+ }
543
+
486
544
  function cancelDownloadSelection() {
487
545
  setDownloadSelectionMode(false);
488
546
  setDownloadSelectionIds(new Set());
@@ -960,6 +1018,18 @@ export function AppStoreScreenshotGenerator() {
960
1018
  {downloading ? t("downloading") : downloadButtonLabel}
961
1019
  </span>
962
1020
  </Button>
1021
+ {soleDownloadableAsset?.svg ? (
1022
+ <Button
1023
+ type="button"
1024
+ variant="outline"
1025
+ onClick={() => handleDownloadSvgClick(soleDownloadableAsset)}
1026
+ disabled={downloading}
1027
+ className="h-10 rounded-lg border-[#D8D4CA] bg-white px-4 text-xs font-semibold text-[#111111] shadow-none hover:bg-[#F4F4F4] disabled:pointer-events-none disabled:opacity-55"
1028
+ >
1029
+ <Download aria-hidden className="size-4" />
1030
+ {t("downloadSvg")}
1031
+ </Button>
1032
+ ) : null}
963
1033
  {downloadSelectionMode ? (
964
1034
  <Button
965
1035
  type="button"
@@ -1057,7 +1127,13 @@ export function AppStoreScreenshotGenerator() {
1057
1127
  </div>
1058
1128
  </div>
1059
1129
  ) : null}
1060
- <div className="flex flex-wrap items-start gap-4">
1130
+ <div
1131
+ className={`grid items-start gap-4 ${
1132
+ resultSlots.length === 1
1133
+ ? "grid-cols-1"
1134
+ : "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
1135
+ }`}
1136
+ >
1061
1137
  {resultSlots.map(({ asset, format, slot }) => {
1062
1138
  if (!asset) {
1063
1139
  const dimensions = dimensionsForAssetFormat(format);
@@ -1066,11 +1142,8 @@ export function AppStoreScreenshotGenerator() {
1066
1142
  key={`pending-${slot}`}
1067
1143
  aria-label={t("pendingImageLoading", { index: slot })}
1068
1144
  role="status"
1069
- style={{
1070
- aspectRatio: `${dimensions.width} / ${dimensions.height}`,
1071
- width: widthForAssetFormat(format),
1072
- }}
1073
- className="flex max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)]"
1145
+ style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
1146
+ className={`flex w-full max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)] ${gridClassForAssetFormat(format, resultSlots.length === 1)}`}
1074
1147
  >
1075
1148
  <span className="flex size-10 items-center justify-center rounded-lg bg-[#F4F4F4] text-[#111111]">
1076
1149
  <Loader2 aria-hidden className="size-4 animate-spin" />
@@ -1092,8 +1165,7 @@ export function AppStoreScreenshotGenerator() {
1092
1165
  return (
1093
1166
  <div
1094
1167
  key={asset.id}
1095
- style={{ width: widthForAssetFormat(asset.format) }}
1096
- className="relative max-w-full"
1168
+ className={`relative w-full max-w-full ${gridClassForAssetFormat(asset.format, resultSlots.length === 1)}`}
1097
1169
  >
1098
1170
  <button
1099
1171
  type="button"
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ import type { AppStoreScreenshotAsset } from "../../../../lib/tools/app-store-screenshot-generator-schema";
5
+
6
+ import {
7
+ buildGeneratedAssetSlots,
8
+ gridClassForAssetFormat,
9
+ } from "./asset-slots";
10
+
11
+ function asset(overrides: Partial<AppStoreScreenshotAsset>): AppStoreScreenshotAsset {
12
+ return {
13
+ id: "asset-1",
14
+ platform: "google",
15
+ format: "googlePhone",
16
+ width: 1080,
17
+ height: 1920,
18
+ imageUrl: "https://example.com/generated.png",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("app store screenshot result slots", () => {
24
+ it("keeps completed and pending Google assets in the same ordered slot model", () => {
25
+ const slots = buildGeneratedAssetSlots({
26
+ assets: [asset({ id: "phone-2", slot: 2 })],
27
+ expectedCount: 3,
28
+ includePending: true,
29
+ platform: "google",
30
+ totalCount: 3,
31
+ });
32
+
33
+ assert.deepEqual(
34
+ slots.map(({ asset: result, format, slot }) => ({ id: result?.id, format, slot })),
35
+ [
36
+ { id: undefined, format: "featureGraphic", slot: 1 },
37
+ { id: "phone-2", format: "googlePhone", slot: 2 },
38
+ { id: undefined, format: "googlePhone", slot: 3 },
39
+ ],
40
+ );
41
+ });
42
+
43
+ it("lets feature graphics span the responsive result grid", () => {
44
+ assert.equal(gridClassForAssetFormat("featureGraphic"), "sm:col-span-2 xl:col-span-3");
45
+ assert.equal(gridClassForAssetFormat("iphone"), "");
46
+ });
47
+
48
+ it("centers a sole result at an inspectable width without creating implicit grid columns", () => {
49
+ assert.equal(gridClassForAssetFormat("featureGraphic", true), "mx-auto max-w-[340px]");
50
+ assert.equal(gridClassForAssetFormat("iphone", true), "mx-auto max-w-[340px]");
51
+ });
52
+ });