-
Notifications
You must be signed in to change notification settings - Fork 7
feat(ai-image): history tab so delivered images are findable after an error #1688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| )} | ||
|
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")} | ||
|
qodo-code-review[bot] marked this conversation as resolved.
Outdated
|
||
| </Button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
| 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; | ||
|
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(); | ||
| }); | ||
|
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(); | ||
| }); | ||
| }); | ||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Sdk history query untested 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }); | ||
| } | ||
| 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"; |
Uh oh!
There was an error while loading. Please reload this page.