From 6475a6a94b2a29a7f25d482f69a8064980f69768 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Thu, 24 Sep 2026 19:28:03 -0400 Subject: [PATCH 1/2] fix(dev): route the dev harness's backend calls through a vite proxy The dev preview harness (dev.html on http://localhost:5173) resolved BACKEND_BASE_URL to the staging worker, whose CORS allowlist admits only the extension origin and the staging site. The browser blocked every hosted-AI, license and billing response before the app saw it, so the "AI suggested" boxes never filled and the only feedback was a brief toast. Add a /api/backend proxy to vite.config.dev.ts, mirroring the existing /api/anthropic proxy, and make BACKEND_BASE_URL resolve to it only when a development-mode page is served over http(s). The unpacked dev extension (chrome-extension:// pages), the service worker, unit tests and production builds keep their previous targets. No backend change; the staging allowlist stays as tight as before. Refs #67 Co-Authored-By: Claude Fable 5.1 --- app/src/lib/entitlement-keys.test.ts | 55 +++++++++ app/src/lib/entitlement-keys.ts | 36 +++++- app/src/test/dev-backend-proxy.test.ts | 165 +++++++++++++++++++++++++ app/vite.config.dev.ts | 83 ++++++++++++- 4 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 app/src/lib/entitlement-keys.test.ts create mode 100644 app/src/test/dev-backend-proxy.test.ts diff --git a/app/src/lib/entitlement-keys.test.ts b/app/src/lib/entitlement-keys.test.ts new file mode 100644 index 0000000..03d792c --- /dev/null +++ b/app/src/lib/entitlement-keys.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { + BACKEND_BASE_URL, + DEV_BACKEND_PROXY_PATH, + resolveBackendBaseUrl, +} from "@/lib/entitlement-keys"; + +const STAGING = "https://optia-backend-staging.paul-130.workers.dev"; +const PRODUCTION = "https://api.optia-api.com"; + +// window.location shapes for the three places the app's modules load. +const harnessPage = { protocol: "http:", origin: "http://localhost:5173" }; +const extensionPage = { + protocol: "chrome-extension:", + origin: "chrome-extension://gnlidlpidaoalbbmekofjednjkhhmehn", +}; + +describe("resolveBackendBaseUrl", () => { + it("routes the dev preview harness through the vite /api/backend proxy (#67)", () => { + expect(resolveBackendBaseUrl("development", harnessPage)).toBe( + `http://localhost:5173${DEV_BACKEND_PROXY_PATH}`, + ); + }); + + it("keeps the proxy on the page origin when the harness is served elsewhere", () => { + expect( + resolveBackendBaseUrl("development", { protocol: "http:", origin: "http://192.168.1.20:4173" }), + ).toBe("http://192.168.1.20:4173/api/backend"); + }); + + it("sends the unpacked dev extension (chrome-extension:// pages) to staging", () => { + expect(resolveBackendBaseUrl("development", extensionPage)).toBe(STAGING); + }); + + it("sends the service worker (no document location) to staging in development", () => { + expect(resolveBackendBaseUrl("development", undefined)).toBe(STAGING); + }); + + it("sends production builds to the production API regardless of location", () => { + expect(resolveBackendBaseUrl("production", harnessPage)).toBe(PRODUCTION); + expect(resolveBackendBaseUrl("production", extensionPage)).toBe(PRODUCTION); + expect(resolveBackendBaseUrl("production", undefined)).toBe(PRODUCTION); + }); + + it("sends every other mode (test, staging) to staging even on localhost", () => { + expect(resolveBackendBaseUrl("test", harnessPage)).toBe(STAGING); + expect(resolveBackendBaseUrl("staging", harnessPage)).toBe(STAGING); + }); +}); + +describe("BACKEND_BASE_URL under vitest", () => { + it("is the staging URL, so unit tests never resolve to the harness proxy", () => { + expect(BACKEND_BASE_URL).toBe(STAGING); + }); +}); diff --git a/app/src/lib/entitlement-keys.ts b/app/src/lib/entitlement-keys.ts index 6c65408..d6c64ba 100644 --- a/app/src/lib/entitlement-keys.ts +++ b/app/src/lib/entitlement-keys.ts @@ -32,9 +32,39 @@ const PRODUCTION_JWK: EntitlementJwk = { const isProduction = import.meta.env.MODE === "production"; -export const BACKEND_BASE_URL: string = isProduction - ? "https://api.optia-api.com" - : "https://optia-backend-staging.paul-130.workers.dev"; +const PRODUCTION_BACKEND_URL = "https://api.optia-api.com"; +const STAGING_BACKEND_URL = "https://optia-backend-staging.paul-130.workers.dev"; + +/** Mount path of the vite dev server's backend proxy (see vite.config.dev.ts). */ +export const DEV_BACKEND_PROXY_PATH = "/api/backend"; + +/** + * Picks the backend origin for a build. Production builds use the production + * API and everything else uses staging, with one exception: the dev preview + * harness (dev.html served by vite over http://localhost) cannot call staging + * directly, because staging's CORS allowlist admits only the extension origin + * and the staging site (#67). The harness talks to staging through the dev + * server's /api/backend proxy instead, the same way BYOK calls go through + * /api/anthropic. `location` is the page's window.location, or undefined where + * there is no document (the service worker). + */ +export function resolveBackendBaseUrl( + mode: string, + location: Pick | undefined, +): string { + if (mode === "production") return PRODUCTION_BACKEND_URL; + // Only the harness is served over http(s) in development; the unpacked dev + // extension's pages are chrome-extension:// and keep going to staging. + if (mode === "development" && location && /^https?:$/.test(location.protocol)) { + return `${location.origin}${DEV_BACKEND_PROXY_PATH}`; + } + return STAGING_BACKEND_URL; +} + +export const BACKEND_BASE_URL: string = resolveBackendBaseUrl( + import.meta.env.MODE, + typeof window === "undefined" ? undefined : window.location, +); // Array to accommodate rotation overlap (multi-entry JWKS) later export const ENTITLEMENT_JWKS: EntitlementJwk[] = isProduction diff --git a/app/src/test/dev-backend-proxy.test.ts b/app/src/test/dev-backend-proxy.test.ts new file mode 100644 index 0000000..a96006a --- /dev/null +++ b/app/src/test/dev-backend-proxy.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Readable } from "node:stream"; +import type { ServerResponse } from "node:http"; +import type { Connect, ViteDevServer } from "vite"; +import { backendProxy, DEV_BACKEND_TARGET } from "../../vite.config.dev"; + +// Drives the /api/backend middleware the dev preview harness relies on (#67) +// with hand-built request/response objects and a stubbed global fetch, so the +// proxy's forwarding rules are pinned without starting a vite server. + +type Handler = Connect.NextHandleFunction; + +async function mountBackendProxy(): Promise { + const plugin = backendProxy(); + const mounted = new Map(); + const server = { + middlewares: { + use: (path: string, handler: Handler) => { + mounted.set(path, handler); + }, + }, + } as unknown as ViteDevServer; + + const hook = plugin.configureServer; + if (!hook) throw new Error("backendProxy has no configureServer hook"); + await (typeof hook === "function" ? hook : hook.handler)(server); + + const handler = mounted.get("/api/backend"); + if (!handler) throw new Error("backendProxy did not mount /api/backend"); + return handler; +} + +interface FakeRequestInit { + method: string; + /** The path as connect hands it to a mounted middleware: mount prefix already stripped. */ + url: string; + headers?: Record; + body?: string; +} + +function fakeRequest({ method, url, headers = {}, body }: FakeRequestInit) { + const stream = Readable.from(body === undefined ? [] : [Buffer.from(body)]); + return Object.assign(stream, { method, url, headers }) as unknown as Connect.IncomingMessage; +} + +function fakeResponse() { + const res = { writeHead: vi.fn(), end: vi.fn() }; + res.writeHead.mockReturnValue(res); + return res as unknown as ServerResponse & typeof res; +} + +function upstream(status: number, body: string, contentType = "application/json") { + return new Response(body, { status, headers: { "content-type": contentType } }); +} + +async function proxy(req: Connect.IncomingMessage) { + const handler = await mountBackendProxy(); + const res = fakeResponse(); + await handler(req, res, vi.fn()); + return res; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("backendProxy (dev harness → staging backend)", () => { + it("forwards a GET to the staging backend and relays status, content type and body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(upstream(200, '{"models":["claude-x"]}', "application/json; charset=utf-8")); + vi.stubGlobal("fetch", fetchMock); + + const res = await proxy(fakeRequest({ method: "GET", url: "/ai/models?installId=abc" })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/models?installId=abc`, { + method: "GET", + headers: {}, + body: undefined, + }); + expect(res.writeHead).toHaveBeenCalledWith(200, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + expect(res.end).toHaveBeenCalledWith('{"models":["claude-x"]}'); + }); + + it("forwards a POST body with only the headers the backend accepts", async () => { + const fetchMock = vi.fn().mockResolvedValue(upstream(200, '{"recommendation":"ok"}')); + vi.stubGlobal("fetch", fetchMock); + const body = JSON.stringify({ checkId: "title-keyword", keyword: "seo", installId: "install-1" }); + + await proxy( + fakeRequest({ + method: "POST", + url: "/ai/generate", + body, + headers: { + "content-type": "application/json", + "x-optia-entitlement": "tok-123", + "x-optia-install-id": "install-1", + authorization: "Bearer abc", + // Browser-supplied headers that must not reach staging. + origin: "http://localhost:5173", + host: "localhost:5173", + referer: "http://localhost:5173/dev.html", + cookie: "session=secret", + "sec-fetch-mode": "cors", + }, + }), + ); + + expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/generate`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-optia-entitlement": "tok-123", + "x-optia-install-id": "install-1", + authorization: "Bearer abc", + }, + body, + }); + }); + + it("relays upstream error statuses and bodies unchanged so the app's error mapping still applies", async () => { + const errorBody = '{"error":{"code":"QUOTA_EXCEEDED","message":"AI quota reached."}}'; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(upstream(429, errorBody))); + + const res = await proxy(fakeRequest({ method: "POST", url: "/ai/generate", body: "{}" })); + + expect(res.writeHead).toHaveBeenCalledWith(429, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + expect(res.end).toHaveBeenCalledWith(errorBody); + }); + + it("answers CORS preflight itself without contacting the backend", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const res = await proxy(fakeRequest({ method: "OPTIONS", url: "/ai/generate" })); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith( + 204, + expect.objectContaining({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": expect.stringContaining("POST"), + }), + ); + expect(res.end).toHaveBeenCalledWith(); + }); + + it("responds 502 when the backend cannot be reached", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed"))); + + const res = await proxy(fakeRequest({ method: "GET", url: "/health" })); + + expect(res.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/plain" }); + expect(res.end).toHaveBeenCalledWith(expect.stringContaining("Backend proxy error")); + }); +}); diff --git a/app/vite.config.dev.ts b/app/vite.config.dev.ts index 221dae2..75003a2 100644 --- a/app/vite.config.dev.ts +++ b/app/vite.config.dev.ts @@ -169,9 +169,90 @@ function anthropicProxy(): Plugin { }; } +/** The backend the dev preview harness reaches through /api/backend. */ +export const DEV_BACKEND_TARGET = "https://optia-backend-staging.paul-130.workers.dev"; + +/** + * Vite plugin that proxies /api/backend/* to the staging Optia backend. + * + * The harness page is served from http://localhost, which staging's CORS + * allowlist rejects (it admits only the extension origin and the staging + * site), so hosted-AI, license and billing calls made straight from the page + * never get past the browser (#67). Routing them through the dev server + * sidesteps CORS the same way /api/anthropic does for BYOK calls. The app opts + * in via BACKEND_BASE_URL (src/lib/entitlement-keys.ts), which resolves to + * this path only in the harness — the extension itself never uses it. + */ +export function backendProxy(): Plugin { + // Only the request headers the backend's CORS policy accepts. Browser-added + // headers (Origin, Host, cookies, Sec-Fetch-*) stay on this side. + const forwarded = [ + "content-type", + "authorization", + "x-optia-entitlement", + "x-optia-install-id", + ]; + return { + name: "backend-proxy", + configureServer(server) { + server.middlewares.use("/api/backend", async (req, res) => { + // Handle CORS preflight (same-origin callers never send one; kept for parity) + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": forwarded.join(", "), + }); + res.end(); + return; + } + + // connect strips the mount path, so req.url is the backend route with + // its query string intact (e.g. /ai/generate, /billing/session/cs_1?installId=...). + const method = req.method ?? "GET"; + const targetUrl = `${DEV_BACKEND_TARGET}${req.url ?? ""}`; + + try { + const headers: Record = {}; + for (const name of forwarded) { + const value = req.headers[name]; + if (typeof value === "string") headers[name] = value; + } + + let body: string | undefined; + if (method !== "GET" && method !== "HEAD") { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + body = Buffer.concat(chunks).toString(); + } + + const response = await fetch(targetUrl, { method, headers, body }); + const responseBody = await response.text(); + console.log(`[Backend Proxy] ${method} ${req.url} -> ${response.status}`); + if (!response.ok) { + console.log("[Backend Proxy] Error response:", responseBody.slice(0, 500)); + } + // Status and body pass through untouched so ai-proxy.ts / backend.ts + // map errors exactly as they would against staging directly. + res.writeHead(response.status, { + "Content-Type": response.headers.get("content-type") ?? "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(responseBody); + } catch (err) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end(`Backend proxy error: ${err}`); + } + }); + }, + }; +} + // Dev preview config — no CRXJS, just serves the side panel UI in a browser tab export default defineConfig({ - plugins: [react(), fetchPageProxy(), anthropicProxy()], + plugins: [react(), fetchPageProxy(), anthropicProxy(), backendProxy()], resolve: { alias: { "@": resolve(__dirname, "src"), From 4b7b2c2072212575fc85c0f028169e247bed5c13 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Thu, 24 Sep 2026 19:30:54 -0400 Subject: [PATCH 2/2] fix(ui): keep AI generation failures visible inside the suggestion box When a generation failed, the "AI suggested" box stayed on its placeholder and the only feedback was a toast that vanished within seconds, so a user who looked away saw an empty box and no explanation. Show the failure inside the box (role="alert") until the next attempt or a new suggestion arrives, using the same message as the toast so quota and availability errors from the proxy surface verbatim. H2 headings get the message per item, including the slots Generate All could not fill. Refs #67 Co-Authored-By: Claude Fable 5.1 --- .../EditableRecommendation.test.tsx | 74 ++++++++++++++ app/src/components/EditableRecommendation.tsx | 15 ++- app/src/components/H2SelectionList.test.tsx | 97 +++++++++++++++++++ app/src/components/H2SelectionList.tsx | 35 ++++++- 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/app/src/components/EditableRecommendation.test.tsx b/app/src/components/EditableRecommendation.test.tsx index 6070889..8a5d47e 100644 --- a/app/src/components/EditableRecommendation.test.tsx +++ b/app/src/components/EditableRecommendation.test.tsx @@ -102,3 +102,77 @@ describe("EditableRecommendation", () => { }); }); }); + +describe("EditableRecommendation inline failure message", () => { + const defaultProps = { + label: "Meta Title", + initialValue: "My SEO Title", + onRegenerate: vi.fn().mockResolvedValue("Regenerated title"), + onToast: vi.fn(), + }; + + it("shows no failure message initially", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the failure visible in the box after a failed regenerate", async () => { + const user = userEvent.setup(); + const onRegenerate = vi.fn().mockRejectedValue(new Error("API error")); + render(); + + await user.click(screen.getByTitle("Regenerate")); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to regenerate"); + // The box keeps whatever text it had; the message is what signals the failure. + expect(screen.getByRole("textbox")).toHaveValue("My SEO Title"); + }); + + it("surfaces the AI service's own message when it explains the failure", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerate = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getByTitle("Regenerate")); + + expect(screen.getByRole("alert")).toHaveTextContent("AI quota reached."); + }); + + it("clears the failure once a later regenerate succeeds", async () => { + const user = userEvent.setup(); + const onRegenerate = vi + .fn() + .mockRejectedValueOnce(new Error("API error")) + .mockResolvedValueOnce("Regenerated title"); + render(); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("Regenerated title"); + }); + + it("clears the failure when a new suggestion arrives from the parent", async () => { + const user = userEvent.setup(); + const onRegenerate = vi.fn().mockRejectedValue(new Error("API error")); + const { rerender } = render( + , + ); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("Fresh suggestion"); + }); +}); diff --git a/app/src/components/EditableRecommendation.tsx b/app/src/components/EditableRecommendation.tsx index 7c8d17f..262739a 100644 --- a/app/src/components/EditableRecommendation.tsx +++ b/app/src/components/EditableRecommendation.tsx @@ -21,11 +21,16 @@ export function EditableRecommendation({ className, }: EditableRecommendationProps) { const [text, setText] = useState(initialValue); + // Last generation failure, shown inside the box until the next attempt or a + // new suggestion. The toast alone disappears within seconds and leaves an + // empty box that looks like nothing happened (#67). + const [error, setError] = useState(null); const textareaRef = useRef(null); // Sync state when initialValue changes (e.g., when AI suggestions complete) useEffect(() => { setText(initialValue); + setError(null); }, [initialValue]); // Auto-resize textarea to fit content @@ -52,12 +57,15 @@ export function EditableRecommendation({ const handleRegenerate = useCallback(async () => { setLoading(true); + setError(null); try { const newText = await onRegenerate(); setText(newText); onToast("Recommendation regenerated"); } catch (err) { - onToast(aiErrorMessage(err, "Failed to regenerate")); + const message = aiErrorMessage(err, "Failed to regenerate"); + onToast(message); + setError(message); } finally { setLoading(false); } @@ -108,6 +116,11 @@ export function EditableRecommendation({ rows={1} className="w-full rounded-input bg-surface px-3 py-2 text-body-16 text-ink placeholder:text-faint outline-none focus:ring-1 focus:ring-brand transition-shadow resize-none overflow-hidden" /> + {error && ( +

+ {error} +

+ )} {aiDisabled && (

Activate Optia Pro or add your own Anthropic key in options to use AI suggestions. diff --git a/app/src/components/H2SelectionList.test.tsx b/app/src/components/H2SelectionList.test.tsx index 1344cb2..6d68523 100644 --- a/app/src/components/H2SelectionList.test.tsx +++ b/app/src/components/H2SelectionList.test.tsx @@ -145,3 +145,100 @@ describe("H2SelectionList", () => { }); }); }); + +describe("H2SelectionList inline failure messages", () => { + const items = [ + { index: 0, text: "Introduction", suggestion: "" }, + { index: 1, text: "Features", suggestion: "Top Features" }, + ]; + + const defaultProps = { + items, + onRegenerateOne: vi.fn().mockResolvedValue("Better heading"), + onRegenerateAll: vi.fn().mockResolvedValue(["New Intro", "New Features"]), + onToast: vi.fn(), + }; + + it("shows no failure messages initially", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("marks only the failed item after a per-item regenerate fails", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail")); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toHaveTextContent("Failed to regenerate"); + }); + + it("surfaces the AI service's own message on a per-item failure", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerateOne = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[1]); + + expect(screen.getByRole("alert")).toHaveTextContent("AI quota reached."); + }); + + it("clears an item's failure once it regenerates successfully", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce("Better heading"); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getAllByRole("textbox")[0]).toHaveValue("Better heading"); + }); + + it("marks the slots that Generate All could not fill", async () => { + const user = userEvent.setup(); + const onRegenerateAll = vi.fn().mockResolvedValue([null, "New Features"]); + render(); + + await user.click(screen.getByText("Generate All")); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toHaveTextContent("Failed to generate"); + expect(screen.getByDisplayValue("New Features")).toBeInTheDocument(); + }); + + it("marks every item when Generate All fails outright", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerateAll = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getByText("Generate All")); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(2); + for (const alert of alerts) expect(alert).toHaveTextContent("AI quota reached."); + }); + + it("clears earlier failures when Generate All later fills every slot", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail")); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getByText("Generate All")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("New Intro")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/H2SelectionList.tsx b/app/src/components/H2SelectionList.tsx index 7b3938c..81d7401 100644 --- a/app/src/components/H2SelectionList.tsx +++ b/app/src/components/H2SelectionList.tsx @@ -19,6 +19,13 @@ interface H2SelectionListProps { className?: string; } +function omitIndex(errors: Record, index: number): Record { + if (!(index in errors)) return errors; + const next = { ...errors }; + delete next[index]; + return next; +} + export function H2SelectionList({ items, onRegenerateOne, @@ -69,16 +76,22 @@ export function H2SelectionList({ const [loadingItems, setLoadingItems] = useState>(new Set()); const [copiedItems, setCopiedItems] = useState>(new Set()); const [loadingAll, setLoadingAll] = useState(false); + // Per-item generation failures, shown inside each item's card until that + // item is retried or a later Generate All fills it (#67). + const [errors, setErrors] = useState>({}); const handleRegenerateOne = useCallback( async (index: number, h2Text: string) => { setLoadingItems((prev) => new Set(prev).add(index)); + setErrors((prev) => omitIndex(prev, index)); try { const result = await onRegenerateOne(index, h2Text); setSuggestions((prev) => ({ ...prev, [index]: result })); onToast("H2 suggestion regenerated"); } catch (err) { - onToast(aiErrorMessage(err, "Failed to regenerate")); + const message = aiErrorMessage(err, "Failed to regenerate"); + onToast(message); + setErrors((prev) => ({ ...prev, [index]: message })); } finally { setLoadingItems((prev) => { const next = new Set(prev); @@ -92,10 +105,11 @@ export function H2SelectionList({ const handleRegenerateAll = useCallback(async () => { setLoadingAll(true); + setErrors({}); try { const results = await onRegenerateAll(); // Keep every suggestion that succeeded; failed slots stay empty for - // per-item retry (their quota was refunded server-side). + // per-item retry (their quota was refunded server-side) and say so. setSuggestions((prev) => { const next = { ...prev }; items.forEach((item, i) => { @@ -104,6 +118,13 @@ export function H2SelectionList({ }); return next; }); + setErrors(() => { + const next: Record = {}; + items.forEach((item, i) => { + if (results[i] == null) next[item.index] = "Failed to generate"; + }); + return next; + }); const failed = items.filter((_, i) => results[i] == null).length; onToast( failed === 0 @@ -111,7 +132,10 @@ export function H2SelectionList({ : `Generated ${items.length - failed} of ${items.length} — retry the rest individually`, ); } catch (err) { - onToast(aiErrorMessage(err, "Failed to generate suggestions")); + // onRegenerateAll rejects only when every generation failed. + const message = aiErrorMessage(err, "Failed to generate suggestions"); + onToast(message); + setErrors(Object.fromEntries(items.map((item) => [item.index, message]))); } finally { setLoadingAll(false); } @@ -215,6 +239,11 @@ export function H2SelectionList({ + {errors[item.index] && ( +

+ {errors[item.index]} +

+ )} ); })}