Skip to content

Commit 09e6c4e

Browse files
natagh23claude
andauthored
[OPIK-8050] [FE] fix: paginate prompt version history and fix version labeling (#8140)
* [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 <noreply@anthropic.com> * fix(prompts): guard version-history pagination, dedupe fetcher, extract orchestration hook - Gate the deep-link, diff-menu, and scroll-sentinel pagination triggers on isFetching/isError (not just isFetchingNextPage) to stop a permanently failing page fetch from hammering the backend in an infinite retry loop. - Reject version_type=mask when resolving the active version by id, both for rendering and for the playground-load fetch, since the by-id lookup isn't scoped the way the paginated list is. - Fix the mobile version dropdown to highlight the resolved active version instead of the raw query param. - Share one fetcher between usePromptVersionsById and usePromptVersionsByIdInfinite instead of duplicating the request logic. - Reuse an already-loaded version from the paginated list instead of issuing a redundant by-id fetch when switching between loaded versions. - Extract version-history orchestration (pagination, selection, active version resolution) out of PromptTab into usePromptVersionHistory. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): paginate Deploy menu owners, unify positional labels, dedupe pagination triggers - DeployToEnvironmentMenu now auto-paginates while open (mirroring the Diff menu) so "Currently vN" resolves for owners on unloaded pages instead of silently omitting them. - Replace remaining positional v{total-idx} labels with the backend's version_number/commit in usePromptVersionLabel, usePromptVersionsWithLabels, and PromptLibraryMenu, so labels stay correct across Playground, trace details, prompt select boxes, Optimizations, and Agent Runner even after older versions are deleted. - Merge the deep-link, Diff-menu, and Deploy-menu pagination triggers into a single effect so at most one fetchNextPage() fires per render — two separate effects both firing in the same render could cancel each other via TanStack Query's default cancelRefetch behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): keep compare dialog target correct during deep-link pagination Inject the already-resolved activeVersion into the versions array passed to ComparePromptVersionDialog when it isn't yet in the paginated list. Without this, opening Diff while a deep-linked version's page was still loading made the dialog silently fall back to the newest loaded version instead of the one actually requested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): bound version-history refetch cost to actual changes useInfiniteQuery refetches every already-loaded page sequentially on any trigger, and the Diff/Deploy menus can load many pages for large prompts — so the prior refetchInterval/refetchOnWindowFocus multiplied request volume by however many pages a session had loaded. Drop those from the infinite query and instead poll the cheap prompt object (version_count) on the same interval, invalidating the versions list only when that count actually changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): disable window-focus refetch on version-history query The prior fix's intent comment claimed refetchOnWindowFocus was off, but never actually set it — the default true still refetched every loaded page on window focus, defeating the point of bounding refetch cost to actual version_count changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): guard pagination, dedupe scan cost, and stop compare-dialog resets - usePromptVersionsByIdInfinite: getNextPageParam now treats an empty page as terminal instead of trusting `total`, which could loop forever if the two go out of sync (e.g. a concurrent delete between the count and the page query). Also gives the infinite-query cache key an explicit `view: "infinite"` marker instead of relying on the implicit (and breakable) "this hook never has page/size" invariant to stay distinct from usePromptVersionsById's key. - usePromptVersionHistory: memoize isChasingDeepLink so a stale/nonexistent activeVersionId doesn't re-scan the whole (growing) version list on every unrelated render while it chases pages. - ComparePromptVersionDialog: the selection-reset effect now fires only on the open transition (reading the rest via a ref) instead of on every versions/versionOptions change while already open, so a background refetch (pagination continuing, a version_count invalidation) no longer silently resets the comparison the user is looking at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): keep compare dialog target correct during deep-link pagination ComparePromptVersionDialog previously stored the selected base/diff PromptVersion objects as state, snapshotted on open. Combined with the open-only reconciliation effect (added to stop background refetches from resetting the user's selection), this meant a background refetch could leave the dialog rendering stale content, or a removed version rendering content that no longer exists. Store only the selected version id in state and re-resolve the actual version live from `versions` on every render instead: a background refetch now keeps rendered content current, and a version that disappears from the list resolves to undefined (renders nothing) rather than stale data — while still not resetting the selection on harmless background changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(prompts): guard compare-dialog media diff on missing versions anyMediaChanged alone gated the media section, unlike the Prompt/Metadata sections which already require both baseVersion and diffVersion. Since collectMedia falls back to empty media arrays when a version is undefined (e.g. deleted out from under an open dialog), any media on the surviving version read as "changed" and rendered — a one-sided comparison against a version that no longer exists. Add the same baseVersion && diffVersion guard already used by the other two sections. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e5eec6b commit 09e6c4e

13 files changed

Lines changed: 527 additions & 179 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { QueryFunctionContext } from "@tanstack/react-query";
2+
import api, { PROMPTS_REST_ENDPOINT } from "@/api/api";
3+
import { PromptVersion } from "@/types/prompts";
4+
import { Sorting } from "@/types/sorting";
5+
import { processSorting } from "@/lib/sorting";
6+
import { Filter } from "@/types/filters";
7+
import { processFilters } from "@/lib/filters";
8+
9+
export type GetPromptVersionsByIdParams = {
10+
promptId: string;
11+
page: number;
12+
size: number;
13+
sorting?: Sorting;
14+
filters?: Filter[];
15+
search?: string;
16+
};
17+
18+
export type PromptVersionsByIdResponse = {
19+
content: PromptVersion[];
20+
page: number;
21+
size: number;
22+
total: number;
23+
sortable_by: string[];
24+
};
25+
26+
export const getPromptVersionsById = async (
27+
{ signal }: QueryFunctionContext,
28+
{
29+
promptId,
30+
size,
31+
page,
32+
sorting,
33+
filters,
34+
search,
35+
}: GetPromptVersionsByIdParams,
36+
): Promise<PromptVersionsByIdResponse> => {
37+
const { data } = await api.get(
38+
`${PROMPTS_REST_ENDPOINT}${promptId}/versions`,
39+
{
40+
signal,
41+
params: {
42+
...processFilters(filters),
43+
...processSorting(sorting),
44+
size,
45+
page,
46+
...(search && { search }),
47+
},
48+
},
49+
);
50+
51+
return data;
52+
};

apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts

Lines changed: 9 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,14 @@
1-
import { QueryFunctionContext, useQuery } from "@tanstack/react-query";
2-
import api, { PROMPTS_REST_ENDPOINT, QueryConfig } from "@/api/api";
3-
import { PromptVersion } from "@/types/prompts";
4-
import { Sorting } from "@/types/sorting";
5-
import { processSorting } from "@/lib/sorting";
6-
import { Filter } from "@/types/filters";
7-
import { processFilters } from "@/lib/filters";
8-
9-
type UsePromptVersionsByIdParams = {
10-
promptId: string;
11-
page: number;
12-
size: number;
13-
sorting?: Sorting;
14-
filters?: Filter[];
15-
search?: string;
16-
};
17-
18-
type UsePromptsVersionsByIdResponse = {
19-
content: PromptVersion[];
20-
page: number;
21-
size: number;
22-
total: number;
23-
sortable_by: string[];
24-
};
25-
26-
const getPromptVersionsById = async (
27-
{ signal }: QueryFunctionContext,
28-
{
29-
promptId,
30-
size,
31-
page,
32-
sorting,
33-
filters,
34-
search,
35-
}: UsePromptVersionsByIdParams,
36-
) => {
37-
const { data } = await api.get(
38-
`${PROMPTS_REST_ENDPOINT}${promptId}/versions`,
39-
{
40-
signal,
41-
params: {
42-
...processFilters(filters),
43-
...processSorting(sorting),
44-
size,
45-
page,
46-
...(search && { search }),
47-
},
48-
},
49-
);
50-
51-
return data;
52-
};
1+
import { useQuery } from "@tanstack/react-query";
2+
import { QueryConfig } from "@/api/api";
3+
import {
4+
getPromptVersionsById,
5+
GetPromptVersionsByIdParams,
6+
PromptVersionsByIdResponse,
7+
} from "./getPromptVersionsById";
538

549
export default function usePromptVersionsById(
55-
params: UsePromptVersionsByIdParams,
56-
options?: QueryConfig<UsePromptsVersionsByIdResponse>,
10+
params: GetPromptVersionsByIdParams,
11+
options?: QueryConfig<PromptVersionsByIdResponse>,
5712
) {
5813
return useQuery({
5914
queryKey: ["prompt-versions", params],
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useInfiniteQuery } from "@tanstack/react-query";
2+
import { Sorting } from "@/types/sorting";
3+
import { Filter } from "@/types/filters";
4+
import {
5+
getPromptVersionsById,
6+
PromptVersionsByIdResponse,
7+
} from "./getPromptVersionsById";
8+
9+
const PAGE_SIZE = 25;
10+
11+
type UsePromptVersionsByIdInfiniteParams = {
12+
promptId: string;
13+
sorting?: Sorting;
14+
filters?: Filter[];
15+
search?: string;
16+
};
17+
18+
type UsePromptVersionsByIdInfiniteOptions = {
19+
enabled?: boolean;
20+
refetchInterval?: number;
21+
refetchOnWindowFocus?: boolean;
22+
};
23+
24+
export default function usePromptVersionsByIdInfinite(
25+
params: UsePromptVersionsByIdInfiniteParams,
26+
options?: UsePromptVersionsByIdInfiniteOptions,
27+
) {
28+
return useInfiniteQuery<PromptVersionsByIdResponse>({
29+
// Shares the "prompt-versions" key prefix with usePromptVersionsById so
30+
// the mutation hooks that invalidate that prefix (create/delete/deploy a
31+
// version) also invalidate this list — otherwise the sidebar goes stale
32+
// after every write. The explicit `view: "infinite"` marker keeps the two
33+
// hooks' cache entries apart without relying on the fragile "this one
34+
// never has page/size" invariant, which a future change to either hook
35+
// could silently break.
36+
queryKey: ["prompt-versions", { ...params, view: "infinite" as const }],
37+
queryFn: (context) =>
38+
getPromptVersionsById(context, {
39+
...params,
40+
size: PAGE_SIZE,
41+
page: context.pageParam as number,
42+
}),
43+
// `size` on the response is the actual item count returned (not the
44+
// requested page size), so it shrinks on the last page and hits 0 past
45+
// it — `page * size` is not a valid "items seen so far" once that
46+
// happens. Sum each page's real content length instead. An empty page
47+
// always ends pagination outright: trusting `total` past that point can
48+
// loop forever if it's out of sync with the actual row count (e.g. a
49+
// concurrent delete between the count and the page query).
50+
getNextPageParam: (lastPage, allPages) => {
51+
if (lastPage.content.length === 0) return undefined;
52+
const fetchedCount = allPages.reduce(
53+
(sum, p) => sum + p.content.length,
54+
0,
55+
);
56+
return fetchedCount < lastPage.total ? allPages.length + 1 : undefined;
57+
},
58+
initialPageParam: 1,
59+
...options,
60+
});
61+
}

apps/opik-frontend/src/hooks/usePromptVersionLabel.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ import { useMemo } from "react";
33
import usePromptVersionsById from "@/api/prompts/usePromptVersionsById";
44

55
/**
6-
* Compute the human-facing "v{n}" label for a specific prompt version.
7-
*
8-
* Versions are labeled by their position when sorted by created_at desc:
9-
* the oldest is v1, the newest is v{total}. We need the versions list to
10-
* find that position — version_count on the Prompt object only tells us
11-
* the total (i.e., the latest's label).
6+
* Compute the human-facing label for a specific prompt version, using the
7+
* backend-persisted version_number so it stays correct even after older
8+
* versions are deleted (positional "v{n}" labels shift when that happens).
129
*/
1310
const usePromptVersionLabel = (
1411
promptId: string | undefined,
@@ -27,9 +24,8 @@ const usePromptVersionLabel = (
2724

2825
return useMemo(() => {
2926
if (versionId && data?.content) {
30-
const idx = data.content.findIndex((v) => v.id === versionId);
31-
const total = data.total ?? data.content.length;
32-
if (idx >= 0 && total > 0) return `v${total - idx}`;
27+
const version = data.content.find((v) => v.id === versionId);
28+
if (version) return version.version_number ?? version.commit;
3329
}
3430
return fallbackVersionCount && fallbackVersionCount > 0
3531
? `v${fallbackVersionCount}`

apps/opik-frontend/src/v2/pages-shared/llm/PromptLibraryMenu/PromptLibraryMenu.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,6 @@ const PromptVersionsList: React.FC<PromptVersionsListProps> = ({
241241
);
242242

243243
const versions = data?.content ?? [];
244-
const total = data?.total ?? versions.length;
245244

246245
if (isLoading) {
247246
return (
@@ -259,8 +258,8 @@ const PromptVersionsList: React.FC<PromptVersionsListProps> = ({
259258

260259
return (
261260
<div className="max-h-[40vh] overflow-y-auto">
262-
{versions.map((version, idx) => {
263-
const label = `v${total - idx}`;
261+
{versions.map((version) => {
262+
const label = version.version_number ?? version.commit;
264263
const isActive = version.id === activeVersionId;
265264
const stage = pickHighestStage(version.tags);
266265
return (

apps/opik-frontend/src/v2/pages-shared/version-history/DiffVersionMenu.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { Clock, GitCompareArrows } from "lucide-react";
2+
import { Clock, GitCompareArrows, Loader2 } from "lucide-react";
33

44
import { getTimeFromNow } from "@/lib/date";
55
import { Button } from "@/ui/button";
@@ -20,18 +20,22 @@ interface DiffVersionMenuProps {
2020
versions: VersionHistoryItem[];
2121
onSelectVersion: (item: VersionHistoryItem) => void;
2222
triggerLabel?: string;
23+
onOpenChange?: (open: boolean) => void;
24+
isLoadingMore?: boolean;
2325
}
2426

2527
const DiffVersionMenu: React.FC<DiffVersionMenuProps> = ({
2628
currentItemId,
2729
versions,
2830
onSelectVersion,
2931
triggerLabel = "Show diff",
32+
onOpenChange,
33+
isLoadingMore = false,
3034
}) => {
3135
const selectableVersions = versions.filter((v) => v.id !== currentItemId);
3236

3337
return (
34-
<DropdownMenu>
38+
<DropdownMenu onOpenChange={onOpenChange}>
3539
<DropdownMenuTrigger asChild>
3640
<Button
3741
size="sm"
@@ -73,6 +77,11 @@ const DiffVersionMenu: React.FC<DiffVersionMenuProps> = ({
7377
</span>
7478
</DropdownMenuItem>
7579
))}
80+
{isLoadingMore && (
81+
<div className="flex justify-center py-2">
82+
<Loader2 className="size-4 animate-spin text-light-slate" />
83+
</div>
84+
)}
7685
</div>
7786
</DropdownMenuContent>
7887
</DropdownMenu>

apps/opik-frontend/src/v2/pages-shared/version-history/VersionHistoryTimeline.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ interface VersionHistoryTimelineProps {
2525
onSelect: (item: VersionHistoryItem) => void;
2626
hasNextPage?: boolean;
2727
isFetchingNextPage?: boolean;
28+
// Broader than isFetchingNextPage — also true during a background refetch
29+
// (e.g. the 30s poll, or a mutation's invalidation of already-loaded
30+
// pages). Used only to gate the auto-load trigger, not the spinner: firing
31+
// onLoadMore while an unrelated fetch is in flight races it and can
32+
// produce a duplicate/overlapping row once both resolve.
33+
isFetching?: boolean;
34+
// True once a page fetch has failed. hasNextPage still reflects the last
35+
// *successful* page, so without this the sentinel retries a permanently
36+
// failing request forever, the moment isFetching settles back to false.
37+
hasError?: boolean;
2838
onLoadMore?: () => void;
2939
emptyTitle?: string;
3040
}
@@ -35,16 +45,18 @@ const VersionHistoryTimeline: React.FC<VersionHistoryTimelineProps> = ({
3545
onSelect,
3646
hasNextPage = false,
3747
isFetchingNextPage = false,
48+
isFetching = false,
49+
hasError = false,
3850
onLoadMore,
3951
emptyTitle = "No version history",
4052
}) => {
4153
const { ref: sentinelRef, inView } = useInView();
4254

4355
useEffect(() => {
44-
if (inView && hasNextPage && !isFetchingNextPage && onLoadMore) {
56+
if (inView && hasNextPage && !isFetching && !hasError && onLoadMore) {
4557
onLoadMore();
4658
}
47-
}, [inView, hasNextPage, isFetchingNextPage, onLoadMore]);
59+
}, [inView, hasNextPage, isFetching, hasError, onLoadMore]);
4860

4961
if (items.length === 0) {
5062
return <DataTableNoData title={emptyTitle} />;

apps/opik-frontend/src/v2/pages-shared/version-history/usePromptVersionsWithLabels.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ export function usePromptVersionsWithLabels(
4646
versions.map((version, index) => ({
4747
version,
4848
index,
49-
label: `v${total - index}`,
49+
label: version.version_number ?? version.commit,
5050
stage: pickHighestStage(version.tags),
5151
})),
52-
[versions, total],
52+
[versions],
5353
);
5454

5555
const getDescriptor = useMemo(() => {

0 commit comments

Comments
 (0)