Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -4059,7 +4059,10 @@
"select-power": "Quality boost",
"finishing": "Your image is generated and finishing upload. Tap below to fetch it — you won't be charged again.",
"finishing-retry": "Fetch image",
"still-generating": "Your image is still generating. It will be fetched automatically with no extra charge."
"still-generating": "Your image is still generating. It will be fetched automatically with no extra charge.",
"tab-generate": "Generate",
"tab-history": "History",
"history-error": "Could not load your generation history. Please try again later."
},
"ai-usage": {
"menu": "AI usage",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import i18next from "i18next";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { AiImageHistory } from "./ai-image-history";

interface Props {
onInsert?: (url: string) => void;
showInsertAction?: boolean;
Expand Down Expand Up @@ -84,6 +86,7 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP

const { mutateAsync: addToGallery } = useAddImage(username, accessToken);

const [activeTab, setActiveTab] = useState<"generate" | "history">("generate");
const [prompt, setPrompt] = useState("");
const [selectedRatio, setSelectedRatio] = useState<string | null>(null);
const [selectedPower, setSelectedPower] = useState<AiImagePowerTier | null>(null);
Expand Down Expand Up @@ -296,10 +299,44 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP
}
}, [generatedUrl]);

// Switching tabs never touches the generate state: a pending attempt keeps its key
// and timers, and the result view is still there when the user switches back.
const tabBar = (
<div className="flex items-center gap-1 border-b border-[--border-color]">
{(["generate", "history"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={clsx(
"px-3 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
activeTab === tab
? "border-blue-dark-sky text-blue-dark-sky"
: "border-transparent opacity-60 hover:opacity-100"
)}
>
{i18next.t(`ai-image-generator.tab-${tab}`)}
</button>
))}
</div>
);

// History view: where an already-paid image is found again when the client never
// saw the success response (timeout, closed tab, delivery finished later).
if (activeTab === "history") {
return (
<div className="flex flex-col gap-4">
{tabBar}
<AiImageHistory onInsert={onInsert} showInsertAction={showInsertAction} />
</div>
);
}

// Result view
if (generatedUrl) {
return (
<div className="animate-fade-in-up flex flex-col gap-4">
{tabBar}
<div className="font-semibold">{i18next.t("ai-image-generator.result-title")}</div>
<div className="border border-[--border-color] rounded-xl overflow-hidden">
<Image
Expand Down Expand Up @@ -331,6 +368,7 @@ export function AiImageGenerator({ onInsert, showInsertAction = true, suggestedP
// Generator form
return (
<div className="flex flex-col gap-4">
{tabBar}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="opacity-50">{i18next.t("ai-image-generator.balance-label")}:</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { useActiveAccount } from "@/core/hooks/use-active-account";
import { Button } from "@/features/ui";
import { getAccessToken } from "@/utils";
import { getAiImagesQueryOptions } from "@ecency/sdk";
import { useQuery } from "@tanstack/react-query";
import i18next from "i18next";
import Image from "next/image";

interface Props {
onInsert?: (url: string) => void;
showInsertAction?: boolean;
}

/**
* The user's recent successful generations, served by the backend. A generation can
* complete and be billed while the client saw a timeout or an error, so this list is
* where such an already-paid image is found again.
*/
export function AiImageHistory({ onInsert, showInsertAction = true }: Props) {
const { activeUser } = useActiveAccount();
const username = activeUser?.username;
const accessToken = username ? getAccessToken(username) : "";

const { data, isLoading, isError } = useQuery(
getAiImagesQueryOptions(username, accessToken ?? "")
);

if (isLoading) {
return <div className="opacity-50 py-4">...</div>;
}

if (!data || data.length === 0) {
return (
<div className="opacity-50 py-4">
{i18next.t(
isError ? "ai-image-generator.history-error" : "ai-image-generator.history-empty"
)}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
</div>
);
}

return (
<div className="flex flex-col gap-3">
{data.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 border border-[--border-color] rounded-xl p-2"
>
<a href={item.url} target="_blank" rel="noopener noreferrer" className="shrink-0">
<Image
src={item.url}
alt={item.prompt}
width={96}
height={96}
className="w-24 h-24 object-cover rounded-lg"
unoptimized={true}
/>
</a>
<div className="min-w-0 flex-1">
<div className="text-sm truncate" title={item.prompt}>
{item.prompt}
</div>
<div className="text-xs opacity-50 mt-0.5">
{new Date(item.created).toLocaleDateString()}
</div>
<div className="flex items-center gap-2 mt-2">
{showInsertAction && onInsert && (
<Button size="xs" onClick={() => onInsert(item.url)}>
{i18next.t("ai-image-generator.insert-button")}
</Button>
)}
<Button
size="xs"
appearance="gray"
onClick={() => window.open(item.url, "_blank")}
>
{i18next.t("ai-image-generator.download-button")}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
</Button>
</div>
</div>
</div>
))}
</div>
);
}
103 changes: 103 additions & 0 deletions apps/web/src/specs/features/ai-image-generator/history-tab.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createTestQueryClient, renderWithQueryClient } from "@/specs/test-utils";
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";

vi.mock("@/utils", async () => ({
...(await vi.importActual("@/utils")),
random: vi.fn(),
getAccessToken: vi.fn(() => "mock-token"),
ensureValidToken: vi.fn(async () => "mock-token")
}));

import { useActiveAccount } from "@/core/hooks/use-active-account";
import { useGenerateImage } from "@ecency/sdk";
import { AiImageGenerator } from "@/features/shared/ai-image-generator/ai-image-generator";

const HISTORY = [
{
id: 1277,
prompt: "Opportunity follows difficulty",
url: "https://images.test/one.webp",
aspect_ratio: "16:9",
cost: 150,
created: "2026-08-25T17:28:08+02:00"
},
{
id: 386,
prompt: "Claim Ecency points",
url: "https://images.test/two.webp",
aspect_ratio: "16:9",
cost: 150,
created: "2026-04-01T07:55:19+02:00"
}
];

function seededClient(history?: typeof HISTORY) {
const queryClient = createTestQueryClient();
queryClient.setQueryData(["ai", "prices"], {
prices: [{ aspect_ratio: "1:1", cost: 150 }],
power: [{ power: 1, multiplier: 1 }]
});
if (history) {
queryClient.setQueryData(["ai", "images", "alice"], history);
}
return queryClient;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}

describe("AiImageGenerator history tab", () => {
beforeEach(() => {
(useGenerateImage as Mock).mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
(useActiveAccount as Mock).mockReturnValue({
activeUser: { username: "alice" },
username: "alice"
});
});

it("lists the user's delivered generations with insert and download actions", () => {
const onInsert = vi.fn();
renderWithQueryClient(<AiImageGenerator onInsert={onInsert} showInsertAction={true} />, {
queryClient: seededClient(HISTORY)
});

fireEvent.click(screen.getByText("ai-image-generator.tab-history"));

expect(screen.getByText("Opportunity follows difficulty")).toBeTruthy();
expect(screen.getByText("Claim Ecency points")).toBeTruthy();

fireEvent.click(screen.getAllByText("ai-image-generator.insert-button")[0]);
expect(onInsert).toHaveBeenCalledWith("https://images.test/one.webp");
});

it("shows the empty state when there are no generations yet", () => {
renderWithQueryClient(<AiImageGenerator showInsertAction={false} />, {
queryClient: seededClient([])
});

fireEvent.click(screen.getByText("ai-image-generator.tab-history"));

expect(screen.getByText("ai-image-generator.history-empty")).toBeTruthy();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it("hides the insert action when insertion is not offered", () => {
renderWithQueryClient(<AiImageGenerator showInsertAction={false} />, {
queryClient: seededClient(HISTORY)
});

fireEvent.click(screen.getByText("ai-image-generator.tab-history"));

expect(screen.queryByText("ai-image-generator.insert-button")).toBeNull();
expect(screen.getAllByText("ai-image-generator.download-button")).toHaveLength(2);
});

it("returns to the generate form when switching back", () => {
renderWithQueryClient(<AiImageGenerator showInsertAction={false} />, {
queryClient: seededClient(HISTORY)
});

fireEvent.click(screen.getByText("ai-image-generator.tab-history"));
expect(screen.queryByText("ai-image-generator.generate-button")).toBeNull();

fireEvent.click(screen.getByText("ai-image-generator.tab-generate"));
expect(screen.getByText("ai-image-generator.generate-button")).toBeTruthy();
});
});
6 changes: 5 additions & 1 deletion apps/web/src/specs/setup-any-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ vi.mock("@ecency/sdk", async () => ({
})),
getBoostPlusPricesQueryOptions: vi.fn(() => ({ queryKey: ["boost-prices"], queryFn: vi.fn() })),
getPointsQueryOptions: vi.fn(() => ({ queryKey: ["points"], queryFn: vi.fn() })),
// Key shape matches the SDK's QueryKeys.ai.prices().
// Key shapes match the SDK's QueryKeys.ai.* builders.
getAiGeneratePriceQueryOptions: vi.fn(() => ({ queryKey: ["ai", "prices"], queryFn: vi.fn() })),
getAiImagesQueryOptions: vi.fn((username?: string) => ({
queryKey: ["ai", "images", username],
queryFn: vi.fn()
})),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
useGenerateImage: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
useAddImage: vi.fn(() => ({ mutateAsync: vi.fn(async () => ({})) })),
getProMembersQueryOptions: vi.fn(() => ({ queryKey: ["accounts", "pro-members"], queryFn: vi.fn() })),
Expand Down
25 changes: 24 additions & 1 deletion packages/sdk/dist/browser/index.d.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/sdk/dist/browser/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/browser/index.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/sdk/dist/node/index.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/node/index.cjs.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/sdk/dist/node/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/sdk/dist/node/index.mjs.map

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/sdk/src/modules/ai/mutations/use-generate-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export function useGenerateImage(
getQueryClient().invalidateQueries({
queryKey: QueryKeys.points._prefix(username),
});
// The new image belongs in the user's generation history right away.
getQueryClient().invalidateQueries({
queryKey: QueryKeys.ai.images(username),
});
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
}
},
});
Expand Down
32 changes: 32 additions & 0 deletions packages/sdk/src/modules/ai/queries/get-ai-images-query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { queryOptions } from "@tanstack/react-query";
import { CONFIG, getBoundFetch, QueryKeys } from "../../core";
import type { AiImageHistoryItem } from "../types";

/**
* Per-user AI image generation history (the backend's last 20 successful generations).
* The backend resolves the user from the validated code, so no username is sent; the
* key still carries it so each account caches its own history.
*/
export function getAiImagesQueryOptions(username: string | undefined, accessToken: string) {
return queryOptions({
queryKey: QueryKeys.ai.images(username),
queryFn: async () => {
const fetchApi = getBoundFetch();
const response = await fetchApi(CONFIG.privateApiHost + "/private-api/ai-images", {
method: "POST",
Comment on lines +13 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Sdk history query untested 📘 Rule violation ▣ Testability

The new public history query builder has no SDK test for its request, disabled state, success
parsing, or non-OK response path. The web test replaces this builder with a mock, so it cannot
validate the new implementation.
Agent Prompt
## Issue description
`getAiImagesQueryOptions` is new functional SDK code, but no test exercises its query function or parameter/error branches.

## Issue Context
PR Compliance 2667972 requires tests for all new public functions and functional paths. Add SDK tests that verify the query key, missing-parameter guard, POST request and token body, successful JSON result, and non-OK error.

## Fix Focus Areas
- packages/sdk/src/modules/ai/queries/get-ai-images-query-options.ts[10-30]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 4ded388: an SDK spec for getAiImagesQueryOptions covering the shared key builder, the enabled gating, the refetch-on-mount policy, the POST request shape with the code body, the parsed result and the non-OK throw.

headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code: accessToken }),
});

if (!response.ok) {
throw new Error(`Failed to fetch AI image history: ${response.status}`);
}

return (await response.json()) as AiImageHistoryItem[];
},
staleTime: 30_000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh history after unobserved generation completion

When the History tab has already fetched its data and a generation later completes server-side without returning a successful response—the recovery scenario this feature targets—the onSuccess invalidation never runs. An open History tab therefore remains empty or stale indefinitely, while remounting it within this 30-second freshness window also skips fetching. Add polling/manual refresh while History is displayed or invalidate/refetch the history after failed or pending generation attempts so later-delivered images become discoverable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4ded388: the SDK query now sets refetchOnMount "always" (every History activation remounts the view and refetches unconditionally) and the dialog invalidates the history key after failed and pending attempts, so an open History tab also refreshes when a background poll resolves either way.

enabled: !!username && !!accessToken,
});
}
1 change: 1 addition & 0 deletions packages/sdk/src/modules/ai/queries/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./get-ai-generate-price-query-options";
export * from "./get-ai-images-query-options";
export * from "./get-ai-assist-price-query-options";
export * from "./get-ai-transcribe-price-query-options";
12 changes: 12 additions & 0 deletions packages/sdk/src/modules/ai/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export interface AiGenerationResponse {
idempotent_replay?: boolean;
}

// One entry of the per-user generation history (the backend's last 20 successful
// generations). Exists so a delivered image is findable even when the client never
// saw the success response (timeout, closed tab, reconciler-finished delivery).
export interface AiImageHistoryItem {
id: number;
prompt: string;
url: string;
aspect_ratio: string;
cost: number;
created: string;
}

export interface AiAssistPrice {
action: string;
cost: number;
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/modules/core/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ export const QueryKeys = {
prices: () => ["ai", "prices"] as const,
assistPrices: (username?: string) => ["ai", "assist-prices", username] as const,
transcribePrice: (username?: string) => ["ai", "transcribe-price", username] as const,
images: (username?: string) => ["ai", "images", username] as const,
_prefix: ["ai"],
},
} as const;
Loading