@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
@@ -3,216 +3,88 @@ import { createToolGuideTranslator } from "../shared/tool-message-translator";
3
3
  import { EmailTemplateGenerator } from "./widget";
4
4
 
5
5
  import {
6
- ToolCardGrid,
7
- ToolFaqSection,
6
+ ToolBoundarySection,
7
+ ToolFaqSection,
8
+ ToolSection,
9
+ ToolStandardsSection,
10
+ ToolTextRows,
11
+ ToolUseCasesSection,
12
+ ToolValueSection,
13
+ type ToolBoundaryGroup,
14
+ type ToolFaqItem,
15
+ type ToolTextCard,
8
16
  } from "../shared/tool-content-sections";
9
17
  import { ToolCta } from "../shared/tool-cta";
10
18
  import { ToolImageVisual, ToolWorkspaceSection } from "../shared/tool-guide-section";
11
19
  import { ToolPageShell } from "../shared/tool-page-shell";
12
20
  import type { ToolPageProps } from "../types";
13
21
 
14
- type TextCard = {
15
- title: string;
16
- body: string;
17
- };
18
-
19
- type BoundaryGroup = {
20
- title: string;
21
- items: readonly string[];
22
- };
23
-
24
- type FaqItem = {
25
- question: string;
26
- answer: string;
27
- };
28
-
29
- function OutputSection({
30
- heading,
31
- intro,
32
- items,
33
- visualAlt,
34
- }: {
35
- heading: string;
36
- intro: string;
37
- items: readonly TextCard[];
38
- visualAlt: string;
39
- }) {
40
- return (
41
- <section className="border-t border-[#E1DDD3] py-14 md:py-16">
42
- <div className="grid gap-8 md:grid-cols-[0.92fr_1.08fr] md:items-center">
43
- <div>
44
- <h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
45
- {heading}
46
- </h2>
47
- <p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
48
- </div>
49
- <ToolImageVisual src="/tools/email-template-generator-value.webp" alt={visualAlt} wide />
50
- </div>
51
- <div className="mt-8">
52
- <ToolCardGrid items={items} />
53
- </div>
54
- </section>
55
- );
56
- }
57
-
58
- function StandardsSection({
59
- heading,
60
- intro,
61
- items,
62
- }: {
63
- heading: string;
64
- intro: string;
65
- items: readonly TextCard[];
66
- }) {
67
- return (
68
- <section className="border-t border-[#E1DDD3] py-14 md:py-16">
69
- <div className="max-w-3xl">
70
- <h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
71
- {heading}
72
- </h2>
73
- <p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
74
- </div>
75
- <div className="mt-7">
76
- <ToolCardGrid items={items} />
77
- </div>
78
- </section>
79
- );
80
- }
81
-
82
- function BoundarySection({
83
- heading,
84
- intro,
85
- groups,
86
- visualAlt,
87
- }: {
88
- heading: string;
89
- intro: string;
90
- groups: readonly BoundaryGroup[];
91
- visualAlt: string;
92
- }) {
93
- return (
94
- <section className="grid gap-8 border-t border-[#E1DDD3] py-14 md:grid-cols-[0.98fr_1.02fr] md:items-center md:py-16">
95
- <ToolImageVisual src="/tools/email-template-generator-support.webp" alt={visualAlt} />
96
- <div>
97
- <h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
98
- {heading}
99
- </h2>
100
- <p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
101
- <div className="mt-6 grid gap-3">
102
- {groups.map((group) => (
103
- <article
104
- key={group.title}
105
- className="rounded-[1.25rem] border border-[#D8D4CA] bg-white p-5 shadow-[0_12px_28px_rgba(17,17,17,0.05)]"
106
- >
107
- <h3 className="text-base font-semibold leading-tight text-[#111111]">
108
- {group.title}
109
- </h3>
110
- <ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-6 text-[#555555]">
111
- {group.items.map((item) => (
112
- <li key={item}>{item}</li>
113
- ))}
114
- </ul>
115
- </article>
116
- ))}
117
- </div>
118
- </div>
119
- </section>
120
- );
121
- }
122
-
123
- function GuidanceSection({
124
- heading,
125
- intro,
126
- items,
127
- visualAlt,
128
- }: {
129
- heading: string;
130
- intro: string;
131
- items: readonly string[];
132
- visualAlt: string;
133
- }) {
134
- return (
135
- <section className="grid gap-8 border-t border-[#E1DDD3] py-14 md:grid-cols-[1fr_1fr] md:items-center md:py-16">
136
- <div>
137
- <h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
138
- {heading}
139
- </h2>
140
- <p className="mt-4 text-base leading-7 text-[#555555]">{intro}</p>
141
- <div className="mt-6 space-y-4">
142
- {items.map((item) => (
143
- <p key={item} className="text-base leading-7 text-[#3D3D3D]">
144
- {item}
145
- </p>
146
- ))}
147
- </div>
148
- </div>
149
- <ToolImageVisual src="/tools/email-template-generator-export.webp" alt={visualAlt} />
150
- </section>
151
- );
152
- }
153
-
154
22
  export async function EmailTemplateGeneratorContainer({ locale, spec }: ToolPageProps) {
155
23
  const t = await createToolGuideTranslator(locale, "toolsPages.emailTemplateGenerator.guide");
156
- const outputItems = t.raw("outputItems") as readonly TextCard[];
157
- const standardsItems = t.raw("standardsItems") as readonly TextCard[];
158
- const boundaryGroups = t.raw("boundaryGroups") as readonly BoundaryGroup[];
24
+ const outputItems = t.raw("outputItems") as readonly ToolTextCard[];
25
+ const standardsItems = t.raw("standardsItems") as readonly ToolTextCard[];
26
+ const boundaryGroups = t.raw("boundaryGroups") as readonly ToolBoundaryGroup[];
159
27
  const guidance = t.raw("guidanceItems") as readonly string[];
160
- const useCases = t.raw("useCaseItems") as readonly TextCard[];
161
- const faqItems = t.raw("faqItems") as readonly FaqItem[];
28
+ const useCases = t.raw("useCaseItems") as readonly ToolTextCard[];
29
+ const faqItems = t.raw("faqItems") as readonly ToolFaqItem[];
162
30
 
163
31
  return (
164
- <ToolPageShell
165
- locale={locale}
166
- spec={spec}
167
- heroVariant="immersive"
168
- heroBackgroundImage="/tools/email-template-generator-hero.webp"
169
- heroBackgroundAlt={t("heroImageAlt")}
170
- >
32
+ <ToolPageShell locale={locale} spec={spec} showDefaultHeader={false}>
171
33
  <ToolWorkspaceSection
172
34
  heading={t("workspaceHeading")}
173
35
  hint={t("workspaceHint")}
36
+ headingLevel="h1"
174
37
  >
175
38
  <EmailTemplateGenerator />
176
39
  </ToolWorkspaceSection>
177
40
 
178
- <OutputSection
41
+ <ToolValueSection
179
42
  heading={t("outputs")}
180
43
  intro={t("outputsIntro")}
181
44
  items={outputItems}
182
- visualAlt={t("valueVisualAlt")}
45
+ visual={
46
+ <ToolImageVisual
47
+ src="/tools/email-template-generator-value.webp"
48
+ alt={t("valueVisualAlt")}
49
+ wide
50
+ />
51
+ }
183
52
  />
184
53
 
185
- <StandardsSection
54
+ <ToolStandardsSection
186
55
  heading={t("standards")}
187
56
  intro={t("standardsIntro")}
188
57
  items={standardsItems}
189
58
  />
190
59
 
191
- <BoundarySection
60
+ <ToolBoundarySection
192
61
  heading={t("boundary")}
193
62
  intro={t("boundaryIntro")}
194
63
  groups={boundaryGroups}
195
- visualAlt={t("supportVisualAlt")}
64
+ reverse
65
+ visual={
66
+ <ToolImageVisual
67
+ src="/tools/email-template-generator-support.webp"
68
+ alt={t("supportVisualAlt")}
69
+ />
70
+ }
196
71
  />
197
72
 
198
- <GuidanceSection
73
+ <ToolSection
199
74
  heading={t("guidance")}
200
75
  intro={t("guidanceIntro")}
201
- items={guidance}
202
- visualAlt={t("exportVisualAlt")}
203
- />
204
-
205
- <section className="border-t border-[#E1DDD3] py-14 md:py-16">
206
- <h2 className="text-3xl font-semibold leading-tight text-[#111111] md:text-4xl">
207
- {t("useCases")}
208
- </h2>
209
- <div className="mt-7">
210
- <ToolCardGrid items={useCases} />
211
- </div>
212
- </section>
76
+ visual={
77
+ <ToolImageVisual
78
+ src="/tools/email-template-generator-export.webp"
79
+ alt={t("exportVisualAlt")}
80
+ />
81
+ }
82
+ >
83
+ <ToolTextRows items={guidance} />
84
+ </ToolSection>
213
85
 
86
+ <ToolUseCasesSection heading={t("useCases")} items={useCases} />
214
87
  <ToolFaqSection heading={t("faq")} items={faqItems} />
215
-
216
88
  <ToolCta
217
89
  heading={t("ctaTitle")}
218
90
  hint={t("ctaHint")}
@@ -5,7 +5,7 @@ export const emailTemplateGeneratorSpec: ToolSpec = {
5
5
  toolType: "generator",
6
6
  seo: {
7
7
  "publishedAt": "2026-06-29",
8
- "updatedAt": "2026-06-29",
8
+ "updatedAt": "2026-08-30",
9
9
  "readingTime": "6 min",
10
10
  "featured": false,
11
11
  "ogImage": "/tools/email-template-generator-hero.webp"
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { ChevronDown, Loader2 } from "lucide-react";
4
- import { useRef, useState } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
 
6
6
  import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
7
+ import {
8
+ persistToolTaskRecovery,
9
+ readToolTaskRecovery,
10
+ } from "../../../../components/tools/engine/task-recovery";
7
11
  import { copyTextToClipboard } from "../../../../components/tools/workspace/tool-result-file-actions";
8
12
  import { Button } from "@tomako/ui/button";
9
13
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@tomako/ui/collapsible";
@@ -38,6 +42,8 @@ import { defaultForm, fileBase, isPlaceholder, wait } from "./helpers";
38
42
  import { EmailPreviewPanel } from "./preview-panel";
39
43
  import type { CopyMap, ErrorCategory, FormState, WidgetState } from "./types";
40
44
 
45
+ const RECOVERY_SLUG = "email-template-generator";
46
+
41
47
  export function EmailTemplateGenerator() {
42
48
  const t = useTranslations("toolsPages.emailTemplateGenerator.widget");
43
49
  const locale = useLocale() as AppLocale;
@@ -76,8 +82,17 @@ export function EmailTemplateGenerator() {
76
82
  setState({ phase: "error", category, taskId, previous });
77
83
  }
78
84
 
79
- function startWatch(taskId: string, previous?: EmailTemplateResult) {
80
- setState({ phase: "running", status: "PENDING", taskId, previous });
85
+ function startWatch(
86
+ taskId: string,
87
+ previous?: EmailTemplateResult,
88
+ startedAtMs?: number,
89
+ ) {
90
+ setState({
91
+ phase: "running",
92
+ status: startedAtMs ? "RESTORING" : "PENDING",
93
+ taskId,
94
+ previous,
95
+ });
81
96
  void wait(80).then(() => {
82
97
  resultRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
83
98
  });
@@ -103,10 +118,26 @@ export function EmailTemplateGenerator() {
103
118
  {
104
119
  pollIntervalMs: 3500,
105
120
  graceWindowMs: 300_000,
121
+ startedAtMs,
106
122
  },
107
123
  );
108
124
  }
109
125
 
126
+ useEffect(() => {
127
+ const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
128
+ parseInput: (value) => {
129
+ const parsed = emailTemplateInputSchema.safeParse(value);
130
+ return parsed.success ? parsed.data : null;
131
+ },
132
+ });
133
+ if (!recovered) return;
134
+ setForm(recovered.input);
135
+ settledRef.current = false;
136
+ startWatch(recovered.taskId, undefined, recovered.startedAtMs);
137
+ // Restore only once for this mounted tool route.
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [taskWatch]);
140
+
110
141
  function validateInput(): EmailTemplateInput | null {
111
142
  const parsed = emailTemplateInputSchema.safeParse(form);
112
143
  if (!parsed.success || isPlaceholder(form.productName) || isPlaceholder(form.brief)) {
@@ -126,7 +157,9 @@ export function EmailTemplateGenerator() {
126
157
 
127
158
  try {
128
159
  const submitted = await submitEmailTemplate(parsed, locale);
129
- startWatch(submitted.taskId);
160
+ const startedAtMs = Date.now();
161
+ persistToolTaskRecovery(RECOVERY_SLUG, submitted.taskId, parsed, startedAtMs);
162
+ startWatch(submitted.taskId, undefined, startedAtMs);
130
163
  } catch {
131
164
  finishError("submit");
132
165
  }
@@ -1,4 +1,6 @@
1
1
  import type { ToolModule } from "../types";
2
+ import type { AppLocale } from "../../../i18n/locale";
3
+ import { pickL10n } from "./l10n";
2
4
  import { loadToolPackage } from "./package-loaders";
3
5
  import { createPackageContainer } from "./package-container";
4
6
  import { createPackageToolSpec } from "./to-tool-spec";
@@ -18,3 +20,16 @@ export async function loadPackageToolModule(
18
20
  const pkg = await loadToolPackage(slug);
19
21
  return pkg ? createPackageToolModule(pkg) : undefined;
20
22
  }
23
+
24
+ /** Package FAQ data lives beside the package page, not in legacy toolsPages messages. */
25
+ export async function loadPackageToolFaqItems(
26
+ slug: string,
27
+ locale: AppLocale,
28
+ ): Promise<Array<{ question: string; answer: string }> | undefined> {
29
+ const pkg = await loadToolPackage(slug);
30
+ if (!pkg) return undefined;
31
+ return pkg.page.faq.map((item) => ({
32
+ question: pickL10n(item.question, locale),
33
+ answer: pickL10n(item.answer, locale),
34
+ }));
35
+ }
@@ -5,7 +5,7 @@ export const productNameGeneratorSpec: ToolSpec = {
5
5
  toolType: "generator",
6
6
  seo: {
7
7
  "publishedAt": "2026-06-25",
8
- "updatedAt": "2026-08-04",
8
+ "updatedAt": "2026-08-30",
9
9
  "readingTime": "6 min",
10
10
  "featured": true,
11
11
  "ogImage": "/tools/product-name-generator-section-primary-8bit-v2.webp",
@@ -39,6 +39,11 @@ import { ChevronDown, Lightbulb } from "lucide-react";
39
39
  import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
40
40
 
41
41
  import { useLegacyLlmTaskWatch } from "../../../../components/tools/engine/use-legacy-llm-task";
42
+ import {
43
+ clearToolTaskRecovery,
44
+ persistToolTaskRecovery,
45
+ readToolTaskRecovery,
46
+ } from "../../../../components/tools/engine/task-recovery";
42
47
  import {
43
48
  ToolActionBar,
44
49
  ToolEmptyPanel,
@@ -80,6 +85,8 @@ import type {
80
85
  ScreenState,
81
86
  } from "./types";
82
87
 
88
+ const RECOVERY_SLUG = "product-name-generator";
89
+
83
90
  export function ProductNameGenerator() {
84
91
  const locale = useLocale();
85
92
  const t = useTranslations("toolsPages.productNameGenerator.widget");
@@ -111,6 +118,21 @@ export function ProductNameGenerator() {
111
118
  };
112
119
  }, [screenTaskWatch]);
113
120
 
121
+ useEffect(() => {
122
+ const recovered = readToolTaskRecovery(RECOVERY_SLUG, {
123
+ parseInput: (value) => {
124
+ const parsed = productNameInputSchema.safeParse(value);
125
+ return parsed.success ? parsed.data : null;
126
+ },
127
+ });
128
+ if (!recovered) return;
129
+ setForm(recovered.input);
130
+ activeScreenTaskIdRef.current = recovered.taskId;
131
+ watchScreenTask(recovered.taskId, "RESTORING", recovered.startedAtMs);
132
+ // Restore only once for this mounted tool route.
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [screenTaskWatch]);
135
+
114
136
  const copy = useMemo(
115
137
  () =>
116
138
  ({
@@ -320,6 +342,40 @@ export function ProductNameGenerator() {
320
342
  };
321
343
  }
322
344
 
345
+ function watchScreenTask(
346
+ taskId: string,
347
+ status: string,
348
+ startedAtMs?: number,
349
+ ) {
350
+ activeScreenTaskIdRef.current = taskId;
351
+ setScreenState({ phase: "loading", status, taskId });
352
+ screenTaskWatch.start(
353
+ taskId,
354
+ pollScreenOnce,
355
+ {
356
+ onStatus: (nextStatus) => {
357
+ setScreenState({ phase: "loading", status: nextStatus, taskId });
358
+ },
359
+ onSuccess: (data) => {
360
+ setScreenState({ phase: "done", data });
361
+ setDetailStates({});
362
+ },
363
+ onError: (category) => {
364
+ setScreenState({
365
+ phase: "error",
366
+ message:
367
+ category === "schema"
368
+ ? t("errors.invalidResult")
369
+ : category === "timeout"
370
+ ? t("errors.noResult")
371
+ : t("errors.agent"),
372
+ });
373
+ },
374
+ },
375
+ { graceWindowMs: 300_000, startedAtMs },
376
+ );
377
+ }
378
+
323
379
  async function generateWithAgent() {
324
380
  const validatedForm = validateForm();
325
381
  if (!validatedForm || !canSubmit) return;
@@ -337,33 +393,9 @@ export function ProductNameGenerator() {
337
393
  locale,
338
394
  );
339
395
  console.info("[workspace-tool-qa] product-name-generator", taskId);
340
- activeScreenTaskIdRef.current = taskId;
341
- setScreenState({ phase: "loading", status, taskId });
342
- screenTaskWatch.start(
343
- taskId,
344
- pollScreenOnce,
345
- {
346
- onStatus: (nextStatus) => {
347
- setScreenState({ phase: "loading", status: nextStatus, taskId });
348
- },
349
- onSuccess: (data) => {
350
- setScreenState({ phase: "done", data });
351
- setDetailStates({});
352
- },
353
- onError: (category) => {
354
- setScreenState({
355
- phase: "error",
356
- message:
357
- category === "schema"
358
- ? t("errors.invalidResult")
359
- : category === "timeout"
360
- ? t("errors.noResult")
361
- : t("errors.agent"),
362
- });
363
- },
364
- },
365
- { graceWindowMs: 300_000 },
366
- );
396
+ const startedAtMs = Date.now();
397
+ persistToolTaskRecovery(RECOVERY_SLUG, taskId, validatedForm, startedAtMs);
398
+ watchScreenTask(taskId, status, startedAtMs);
367
399
  } catch (err) {
368
400
  setScreenState({ phase: "error", message: formatAgentError(err) });
369
401
  }
@@ -460,6 +492,7 @@ export function ProductNameGenerator() {
460
492
  function reset() {
461
493
  screenTaskWatch.stop();
462
494
  activeScreenTaskIdRef.current = null;
495
+ clearToolTaskRecovery(RECOVERY_SLUG);
463
496
  setForm(defaultForm);
464
497
  setScreenState({ phase: "idle" });
465
498
  setDetailStates({});
@@ -6,7 +6,7 @@ export const productPosterGeneratorSpec: ToolSpec = {
6
6
  riskLevel: "medium",
7
7
  seo: {
8
8
  "publishedAt": "2026-06-29",
9
- "updatedAt": "2026-07-13",
9
+ "updatedAt": "2026-08-30",
10
10
  "readingTime": "6 min",
11
11
  "featured": false,
12
12
  "ogImage": "/tools/product-poster-generator-section-primary-8bit-v2.webp"
@@ -122,4 +122,6 @@ export const BRAND_COLOR_PRESETS: readonly BrandColorPreset[] = [
122
122
  ] as const;
123
123
 
124
124
  export const TASK_POLL_INTERVAL_MS = 2500;
125
- export const MAX_TASK_POLL_ATTEMPTS = 120;
125
+ // Match the Portal STREAMING timeout. The previous 5-minute client deadline
126
+ // abandoned valid image tasks while the server still allowed them to finish.
127
+ export const MAX_TASK_POLL_ATTEMPTS = 240;
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { fetchProductPosterPng } from "./helpers";
5
+
6
+ test("fetchProductPosterPng fetches through the image proxy and returns a .png download", async () => {
7
+ let requestedUrl = "";
8
+ let requestedInit: RequestInit | undefined;
9
+ const fetcher = async (input: RequestInfo | URL, init?: RequestInit) => {
10
+ requestedUrl = String(input);
11
+ requestedInit = init;
12
+ return new Response(new Uint8Array([137, 80, 78, 71]), {
13
+ status: 200,
14
+ headers: { "content-type": "image/png" },
15
+ });
16
+ };
17
+
18
+ const file = await fetchProductPosterPng(
19
+ "https://cdn.example.com/generated/poster.png",
20
+ "example-product",
21
+ fetcher,
22
+ );
23
+
24
+ assert.equal(
25
+ requestedUrl,
26
+ "/tools-api/image-download?url=https%3A%2F%2Fcdn.example.com%2Fgenerated%2Fposter.png",
27
+ );
28
+ assert.equal(requestedInit?.cache, "no-store");
29
+ assert.equal(file.fileName, "example-product-poster.png");
30
+ assert.equal(file.blob.type, "image/png");
31
+ assert.equal(file.blob.size, 4);
32
+ });
33
+
34
+ test("fetchProductPosterPng rejects failed or non-PNG responses", async () => {
35
+ await assert.rejects(
36
+ fetchProductPosterPng("https://cdn.example.com/poster.png", "poster", async () =>
37
+ new Response(null, { status: 502 }),
38
+ ),
39
+ /Poster download failed: 502/,
40
+ );
41
+
42
+ await assert.rejects(
43
+ fetchProductPosterPng("https://cdn.example.com/poster.webp", "poster", async () =>
44
+ new Response(new Uint8Array([1]), {
45
+ status: 200,
46
+ headers: { "content-type": "image/webp" },
47
+ }),
48
+ ),
49
+ /Poster download returned image\/webp/,
50
+ );
51
+ });
@@ -5,6 +5,7 @@ import type {
5
5
  ProductPosterGeneratorInput,
6
6
  ProductPosterScene,
7
7
  } from "../../../../lib/tools/product-poster-generator-schema";
8
+ import { imageDownloadUrl } from "../../../../lib/tools/image-download";
8
9
 
9
10
  import { BRAND_COLOR_PRESETS, POSTER_INTENT_OPTIONS } from "./constants";
10
11
  import type {
@@ -65,6 +66,32 @@ export function slugifyFileName(value: string) {
65
66
  );
66
67
  }
67
68
 
69
+ export async function fetchProductPosterPng(
70
+ imageUrl: string,
71
+ fileBaseName: string,
72
+ fetcher: typeof fetch = fetch,
73
+ ) {
74
+ const response = await fetcher(imageDownloadUrl(imageUrl), { cache: "no-store" });
75
+ if (!response.ok) {
76
+ throw new Error(`Poster download failed: ${response.status}`);
77
+ }
78
+
79
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
80
+ if (!contentType.startsWith("image/png")) {
81
+ throw new Error(`Poster download returned ${contentType || "an unknown content type"}`);
82
+ }
83
+
84
+ const blob = await response.blob();
85
+ if (blob.size === 0) {
86
+ throw new Error("Poster download returned an empty PNG");
87
+ }
88
+
89
+ return {
90
+ blob,
91
+ fileName: `${fileBaseName}-poster.png`,
92
+ };
93
+ }
94
+
68
95
  export function sizeToDimensions(size: string) {
69
96
  const [width, height] = size.split("x").map((part) => Number(part));
70
97
  return {