Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions app/src/components/EditableRecommendation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<EditableRecommendation {...defaultProps} />);
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(<EditableRecommendation {...defaultProps} onRegenerate={onRegenerate} />);

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(<EditableRecommendation {...defaultProps} onRegenerate={onRegenerate} />);

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(<EditableRecommendation {...defaultProps} onRegenerate={onRegenerate} />);

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(
<EditableRecommendation {...defaultProps} onRegenerate={onRegenerate} />,
);

await user.click(screen.getByTitle("Regenerate"));
expect(screen.getByRole("alert")).toBeInTheDocument();

rerender(
<EditableRecommendation
{...defaultProps}
onRegenerate={onRegenerate}
initialValue="Fresh suggestion"
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveValue("Fresh suggestion");
});
});
15 changes: 14 additions & 1 deletion app/src/components/EditableRecommendation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

// Sync state when initialValue changes (e.g., when AI suggestions complete)
useEffect(() => {
setText(initialValue);
setError(null);
}, [initialValue]);

// Auto-resize textarea to fit content
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 && (
<p role="alert" className="mt-2 text-body-12 text-poor">
{error}
</p>
)}
{aiDisabled && (
<p className="mt-2 text-body-12 text-muted opacity-70">
Activate Optia Pro or add your own Anthropic key in options to use AI suggestions.
Expand Down
97 changes: 97 additions & 0 deletions app/src/components/H2SelectionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<H2SelectionList {...defaultProps} />);
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(<H2SelectionList {...defaultProps} onRegenerateOne={onRegenerateOne} />);

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(<H2SelectionList {...defaultProps} onRegenerateOne={onRegenerateOne} />);

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(<H2SelectionList {...defaultProps} onRegenerateOne={onRegenerateOne} />);

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(<H2SelectionList {...defaultProps} onRegenerateAll={onRegenerateAll} />);

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(<H2SelectionList {...defaultProps} onRegenerateAll={onRegenerateAll} />);

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(<H2SelectionList {...defaultProps} onRegenerateOne={onRegenerateOne} />);

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();
});
});
35 changes: 32 additions & 3 deletions app/src/components/H2SelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ interface H2SelectionListProps {
className?: string;
}

function omitIndex(errors: Record<number, string>, index: number): Record<number, string> {
if (!(index in errors)) return errors;
const next = { ...errors };
delete next[index];
return next;
}

export function H2SelectionList({
items,
onRegenerateOne,
Expand Down Expand Up @@ -69,16 +76,22 @@ export function H2SelectionList({
const [loadingItems, setLoadingItems] = useState<Set<number>>(new Set());
const [copiedItems, setCopiedItems] = useState<Set<number>>(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<Record<number, string>>({});

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);
Expand All @@ -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) => {
Expand All @@ -104,14 +118,24 @@ export function H2SelectionList({
});
return next;
});
setErrors(() => {
const next: Record<number, string> = {};
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
? "All H2 suggestions generated"
: `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);
}
Expand Down Expand Up @@ -215,6 +239,11 @@ export function H2SelectionList({
</button>
</div>
</div>
{errors[item.index] && (
<p role="alert" className="mt-1.5 text-body-12 text-poor">
{errors[item.index]}
</p>
)}
</div>
);
})}
Expand Down
55 changes: 55 additions & 0 deletions app/src/lib/entitlement-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading