From a7d83e155e80771153615761d24609b51ed33b7d Mon Sep 17 00:00:00 2001 From: natagh23 Date: Thu, 3 Sep 2026 22:06:20 +0200 Subject: [PATCH 1/9] [OPIK-8050] [FE] fix: paginate prompt version history and fix version labeling Version history on the Prompt tab only ever loaded the first 25 versions (single page, client-computed vN labels), so older versions were unreachable and a deep link to one silently fell back to the latest version while still showing a stale label. Switches to a paginated usePromptVersionsByIdInfinite hook, wires the lazy-load already built into VersionHistoryTimeline, and replaces index-derived labels with the backend's persistent version_number (falling back to commit when a pre-migration row has none). Also fixes OPIK-8189: the Compare sheet recomputed labels from whatever was locally loaded instead of the true total, mislabeling versions past the first page. Guards a stale/crafted activeVersionId from rendering a different prompt's content, aligns the new hook's cache key with existing mutation invalidations so the sidebar refreshes after writes, and extends the Diff dropdown to auto-paginate so "Compare against" isn't capped at the first loaded page either. Co-Authored-By: Claude Sonnet 5 --- .../prompts/usePromptVersionsByIdInfinite.ts | 89 ++++++++++++++ .../version-history/DiffVersionMenu.tsx | 13 +- .../CommitsTab/ComparePromptVersionDialog.tsx | 6 +- .../PromptTab/DeployToEnvironmentMenu.tsx | 12 +- .../pages/PromptPage/PromptTab/PromptTab.tsx | 113 ++++++++++++++---- 5 files changed, 193 insertions(+), 40 deletions(-) create mode 100644 apps/opik-frontend/src/api/prompts/usePromptVersionsByIdInfinite.ts diff --git a/apps/opik-frontend/src/api/prompts/usePromptVersionsByIdInfinite.ts b/apps/opik-frontend/src/api/prompts/usePromptVersionsByIdInfinite.ts new file mode 100644 index 00000000000..67ee8d49774 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptVersionsByIdInfinite.ts @@ -0,0 +1,89 @@ +import { QueryFunctionContext, useInfiniteQuery } from "@tanstack/react-query"; +import api, { PROMPTS_REST_ENDPOINT } from "@/api/api"; +import { PromptVersion } from "@/types/prompts"; +import { Sorting } from "@/types/sorting"; +import { processSorting } from "@/lib/sorting"; +import { Filter } from "@/types/filters"; +import { processFilters } from "@/lib/filters"; + +const PAGE_SIZE = 25; + +type UsePromptVersionsByIdInfiniteParams = { + promptId: string; + sorting?: Sorting; + filters?: Filter[]; + search?: string; +}; + +type UsePromptVersionsByIdInfiniteOptions = { + enabled?: boolean; + refetchInterval?: number; +}; + +type UsePromptVersionsByIdInfiniteResponse = { + content: PromptVersion[]; + page: number; + size: number; + total: number; + sortable_by: string[]; +}; + +const getPromptVersionsById = async ( + { signal }: QueryFunctionContext, + { + promptId, + page, + sorting, + filters, + search, + }: UsePromptVersionsByIdInfiniteParams & { page: number }, +): Promise => { + const { data } = await api.get( + `${PROMPTS_REST_ENDPOINT}${promptId}/versions`, + { + signal, + params: { + ...processFilters(filters), + ...processSorting(sorting), + size: PAGE_SIZE, + page, + ...(search && { search }), + }, + }, + ); + + return data; +}; + +export default function usePromptVersionsByIdInfinite( + params: UsePromptVersionsByIdInfiniteParams, + options?: UsePromptVersionsByIdInfiniteOptions, +) { + return useInfiniteQuery({ + // Shares the "prompt-versions" key prefix (and the same + // `{ promptId, ... }` params shape) with usePromptVersionsById so the + // mutation hooks that invalidate that prefix (create/delete/deploy a + // version) also invalidate this list — otherwise the sidebar goes stale + // after every write. Doesn't collide in the cache: this hook's params + // never include `page`/`size`, which usePromptVersionsById always does. + queryKey: ["prompt-versions", params], + queryFn: (context) => + getPromptVersionsById(context, { + ...params, + page: context.pageParam as number, + }), + // `size` on the response is the actual item count returned (not the + // requested page size), so it shrinks on the last page and hits 0 past + // it — `page * size` is not a valid "items seen so far" once that + // happens. Sum each page's real content length instead. + getNextPageParam: (lastPage, allPages) => { + const fetchedCount = allPages.reduce( + (sum, p) => sum + p.content.length, + 0, + ); + return fetchedCount < lastPage.total ? allPages.length + 1 : undefined; + }, + initialPageParam: 1, + ...options, + }); +} diff --git a/apps/opik-frontend/src/v2/pages-shared/version-history/DiffVersionMenu.tsx b/apps/opik-frontend/src/v2/pages-shared/version-history/DiffVersionMenu.tsx index ded3868274d..28575ce746d 100644 --- a/apps/opik-frontend/src/v2/pages-shared/version-history/DiffVersionMenu.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/version-history/DiffVersionMenu.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Clock, GitCompareArrows } from "lucide-react"; +import { Clock, GitCompareArrows, Loader2 } from "lucide-react"; import { getTimeFromNow } from "@/lib/date"; import { Button } from "@/ui/button"; @@ -20,6 +20,8 @@ interface DiffVersionMenuProps { versions: VersionHistoryItem[]; onSelectVersion: (item: VersionHistoryItem) => void; triggerLabel?: string; + onOpenChange?: (open: boolean) => void; + isLoadingMore?: boolean; } const DiffVersionMenu: React.FC = ({ @@ -27,11 +29,13 @@ const DiffVersionMenu: React.FC = ({ versions, onSelectVersion, triggerLabel = "Show diff", + onOpenChange, + isLoadingMore = false, }) => { const selectableVersions = versions.filter((v) => v.id !== currentItemId); return ( - +