diff --git a/apps/opik-frontend/src/api/prompts/getPromptVersionsById.ts b/apps/opik-frontend/src/api/prompts/getPromptVersionsById.ts new file mode 100644 index 00000000000..e2bd6658351 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/getPromptVersionsById.ts @@ -0,0 +1,52 @@ +import { QueryFunctionContext } 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"; + +export type GetPromptVersionsByIdParams = { + promptId: string; + page: number; + size: number; + sorting?: Sorting; + filters?: Filter[]; + search?: string; +}; + +export type PromptVersionsByIdResponse = { + content: PromptVersion[]; + page: number; + size: number; + total: number; + sortable_by: string[]; +}; + +export const getPromptVersionsById = async ( + { signal }: QueryFunctionContext, + { + promptId, + size, + page, + sorting, + filters, + search, + }: GetPromptVersionsByIdParams, +): Promise => { + const { data } = await api.get( + `${PROMPTS_REST_ENDPOINT}${promptId}/versions`, + { + signal, + params: { + ...processFilters(filters), + ...processSorting(sorting), + size, + page, + ...(search && { search }), + }, + }, + ); + + return data; +}; diff --git a/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts b/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts index 496e20a238a..f39ec274c08 100644 --- a/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts +++ b/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts @@ -1,59 +1,14 @@ -import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; -import api, { PROMPTS_REST_ENDPOINT, QueryConfig } 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"; - -type UsePromptVersionsByIdParams = { - promptId: string; - page: number; - size: number; - sorting?: Sorting; - filters?: Filter[]; - search?: string; -}; - -type UsePromptsVersionsByIdResponse = { - content: PromptVersion[]; - page: number; - size: number; - total: number; - sortable_by: string[]; -}; - -const getPromptVersionsById = async ( - { signal }: QueryFunctionContext, - { - promptId, - size, - page, - sorting, - filters, - search, - }: UsePromptVersionsByIdParams, -) => { - const { data } = await api.get( - `${PROMPTS_REST_ENDPOINT}${promptId}/versions`, - { - signal, - params: { - ...processFilters(filters), - ...processSorting(sorting), - size, - page, - ...(search && { search }), - }, - }, - ); - - return data; -}; +import { useQuery } from "@tanstack/react-query"; +import { QueryConfig } from "@/api/api"; +import { + getPromptVersionsById, + GetPromptVersionsByIdParams, + PromptVersionsByIdResponse, +} from "./getPromptVersionsById"; export default function usePromptVersionsById( - params: UsePromptVersionsByIdParams, - options?: QueryConfig, + params: GetPromptVersionsByIdParams, + options?: QueryConfig, ) { return useQuery({ queryKey: ["prompt-versions", params], 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..a0441f8d684 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptVersionsByIdInfinite.ts @@ -0,0 +1,61 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Sorting } from "@/types/sorting"; +import { Filter } from "@/types/filters"; +import { + getPromptVersionsById, + PromptVersionsByIdResponse, +} from "./getPromptVersionsById"; + +const PAGE_SIZE = 25; + +type UsePromptVersionsByIdInfiniteParams = { + promptId: string; + sorting?: Sorting; + filters?: Filter[]; + search?: string; +}; + +type UsePromptVersionsByIdInfiniteOptions = { + enabled?: boolean; + refetchInterval?: number; + refetchOnWindowFocus?: boolean; +}; + +export default function usePromptVersionsByIdInfinite( + params: UsePromptVersionsByIdInfiniteParams, + options?: UsePromptVersionsByIdInfiniteOptions, +) { + return useInfiniteQuery({ + // Shares the "prompt-versions" key prefix 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. The explicit `view: "infinite"` marker keeps the two + // hooks' cache entries apart without relying on the fragile "this one + // never has page/size" invariant, which a future change to either hook + // could silently break. + queryKey: ["prompt-versions", { ...params, view: "infinite" as const }], + queryFn: (context) => + getPromptVersionsById(context, { + ...params, + size: PAGE_SIZE, + 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. An empty page + // always ends pagination outright: trusting `total` past that point can + // loop forever if it's out of sync with the actual row count (e.g. a + // concurrent delete between the count and the page query). + getNextPageParam: (lastPage, allPages) => { + if (lastPage.content.length === 0) return undefined; + 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/hooks/usePromptVersionLabel.ts b/apps/opik-frontend/src/hooks/usePromptVersionLabel.ts index 145a72af658..b41df9832b5 100644 --- a/apps/opik-frontend/src/hooks/usePromptVersionLabel.ts +++ b/apps/opik-frontend/src/hooks/usePromptVersionLabel.ts @@ -3,12 +3,9 @@ import { useMemo } from "react"; import usePromptVersionsById from "@/api/prompts/usePromptVersionsById"; /** - * Compute the human-facing "v{n}" label for a specific prompt version. - * - * Versions are labeled by their position when sorted by created_at desc: - * the oldest is v1, the newest is v{total}. We need the versions list to - * find that position — version_count on the Prompt object only tells us - * the total (i.e., the latest's label). + * Compute the human-facing label for a specific prompt version, using the + * backend-persisted version_number so it stays correct even after older + * versions are deleted (positional "v{n}" labels shift when that happens). */ const usePromptVersionLabel = ( promptId: string | undefined, @@ -27,9 +24,8 @@ const usePromptVersionLabel = ( return useMemo(() => { if (versionId && data?.content) { - const idx = data.content.findIndex((v) => v.id === versionId); - const total = data.total ?? data.content.length; - if (idx >= 0 && total > 0) return `v${total - idx}`; + const version = data.content.find((v) => v.id === versionId); + if (version) return version.version_number ?? version.commit; } return fallbackVersionCount && fallbackVersionCount > 0 ? `v${fallbackVersionCount}` diff --git a/apps/opik-frontend/src/v2/pages-shared/llm/PromptLibraryMenu/PromptLibraryMenu.tsx b/apps/opik-frontend/src/v2/pages-shared/llm/PromptLibraryMenu/PromptLibraryMenu.tsx index afcdb7dd80b..c6903260086 100644 --- a/apps/opik-frontend/src/v2/pages-shared/llm/PromptLibraryMenu/PromptLibraryMenu.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/llm/PromptLibraryMenu/PromptLibraryMenu.tsx @@ -241,7 +241,6 @@ const PromptVersionsList: React.FC = ({ ); const versions = data?.content ?? []; - const total = data?.total ?? versions.length; if (isLoading) { return ( @@ -259,8 +258,8 @@ const PromptVersionsList: React.FC = ({ return (
- {versions.map((version, idx) => { - const label = `v${total - idx}`; + {versions.map((version) => { + const label = version.version_number ?? version.commit; const isActive = version.id === activeVersionId; const stage = pickHighestStage(version.tags); return ( 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 ( - +
diff --git a/apps/opik-frontend/src/v2/pages-shared/version-history/VersionHistoryTimeline.tsx b/apps/opik-frontend/src/v2/pages-shared/version-history/VersionHistoryTimeline.tsx index a9dc5c2bb3a..1279c25661a 100644 --- a/apps/opik-frontend/src/v2/pages-shared/version-history/VersionHistoryTimeline.tsx +++ b/apps/opik-frontend/src/v2/pages-shared/version-history/VersionHistoryTimeline.tsx @@ -25,6 +25,16 @@ interface VersionHistoryTimelineProps { onSelect: (item: VersionHistoryItem) => void; hasNextPage?: boolean; isFetchingNextPage?: boolean; + // Broader than isFetchingNextPage — also true during a background refetch + // (e.g. the 30s poll, or a mutation's invalidation of already-loaded + // pages). Used only to gate the auto-load trigger, not the spinner: firing + // onLoadMore while an unrelated fetch is in flight races it and can + // produce a duplicate/overlapping row once both resolve. + isFetching?: boolean; + // True once a page fetch has failed. hasNextPage still reflects the last + // *successful* page, so without this the sentinel retries a permanently + // failing request forever, the moment isFetching settles back to false. + hasError?: boolean; onLoadMore?: () => void; emptyTitle?: string; } @@ -35,16 +45,18 @@ const VersionHistoryTimeline: React.FC = ({ onSelect, hasNextPage = false, isFetchingNextPage = false, + isFetching = false, + hasError = false, onLoadMore, emptyTitle = "No version history", }) => { const { ref: sentinelRef, inView } = useInView(); useEffect(() => { - if (inView && hasNextPage && !isFetchingNextPage && onLoadMore) { + if (inView && hasNextPage && !isFetching && !hasError && onLoadMore) { onLoadMore(); } - }, [inView, hasNextPage, isFetchingNextPage, onLoadMore]); + }, [inView, hasNextPage, isFetching, hasError, onLoadMore]); if (items.length === 0) { return ; diff --git a/apps/opik-frontend/src/v2/pages-shared/version-history/usePromptVersionsWithLabels.ts b/apps/opik-frontend/src/v2/pages-shared/version-history/usePromptVersionsWithLabels.ts index 885a652453b..aef7ca870e7 100644 --- a/apps/opik-frontend/src/v2/pages-shared/version-history/usePromptVersionsWithLabels.ts +++ b/apps/opik-frontend/src/v2/pages-shared/version-history/usePromptVersionsWithLabels.ts @@ -46,10 +46,10 @@ export function usePromptVersionsWithLabels( versions.map((version, index) => ({ version, index, - label: `v${total - index}`, + label: version.version_number ?? version.commit, stage: pickHighestStage(version.tags), })), - [versions, total], + [versions], ); const getDescriptor = useMemo(() => { diff --git a/apps/opik-frontend/src/v2/pages/PromptPage/CommitsTab/ComparePromptVersionDialog.tsx b/apps/opik-frontend/src/v2/pages/PromptPage/CommitsTab/ComparePromptVersionDialog.tsx index 874c59bcfa3..28d0b126f6c 100644 --- a/apps/opik-frontend/src/v2/pages/PromptPage/CommitsTab/ComparePromptVersionDialog.tsx +++ b/apps/opik-frontend/src/v2/pages/PromptPage/CommitsTab/ComparePromptVersionDialog.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import last from "lodash/last"; import first from "lodash/first"; import isEqual from "fast-deep-equal"; @@ -235,14 +241,28 @@ const ComparePromptVersionDialog: React.FunctionComponent< initialBaseVersionId, initialDiffVersionId, }) => { - const [baseVersion, setBaseVersion] = useState( - last(versions), + // Only the *choice* of which version is selected is snapshotted state — + // the version data itself is always re-resolved live from `versions` + // below, so a background refetch (tag edit, environment change) keeps + // rendered content current, and a version deleted out from under an open + // dialog resolves to undefined (renders nothing) instead of stale content. + const [baseVersionId, setBaseVersionId] = useState( + last(versions)?.id, ); - const [diffVersion, setDiffVersion] = useState( - first(versions), + const [diffVersionId, setDiffVersionId] = useState( + first(versions)?.id, ); const [viewMode, setViewMode] = useState("pretty"); + const baseVersion = useMemo( + () => versions.find((v) => v.id === baseVersionId), + [versions, baseVersionId], + ); + const diffVersion = useMemo( + () => versions.find((v) => v.id === diffVersionId), + [versions, diffVersionId], + ); + const baseText = useMemo( () => normalizeChatTemplate(baseVersion?.template || ""), [baseVersion?.template], @@ -312,12 +332,8 @@ const ComparePromptVersionDialog: React.FunctionComponent< const anyMediaChanged = mediaChanges.some((m) => m.changed); const versionLabelByCommit = useMemo(() => { - const sortedDesc = [...versions].sort((a, b) => - b.created_at.localeCompare(a.created_at), - ); - const total = sortedDesc.length; const map = new Map(); - sortedDesc.forEach((v, idx) => map.set(v.commit, `v${total - idx}`)); + versions.forEach((v) => map.set(v.commit, v.version_number ?? v.commit)); return map; }, [versions]); @@ -334,9 +350,32 @@ const ComparePromptVersionDialog: React.FunctionComponent< [versions, versionLabelByCommit], ); - // Reset selection and view mode each time the sheet reopens. + // Reset selection and view mode only on the reopen transition — reading + // the rest via a ref (not as effect deps) so a background refetch that + // changes `versions`/`versionOptions` while the sheet is already open + // (pagination continuing, a version_count-triggered invalidation) doesn't + // stomp on whatever the user has manually picked in the selectors. + const latestRef = useRef({ + versions, + versionOptions, + initialBaseVersionId, + initialDiffVersionId, + }); + latestRef.current = { + versions, + versionOptions, + initialBaseVersionId, + initialDiffVersionId, + }; + useEffect(() => { if (!open) return; + const { + versions, + versionOptions, + initialBaseVersionId, + initialDiffVersionId, + } = latestRef.current; const requestedBase = (initialBaseVersionId ? versions.find((v) => v.id === initialBaseVersionId) @@ -347,8 +386,8 @@ const ComparePromptVersionDialog: React.FunctionComponent< ? versions.find((v) => v.id === initialDiffVersionId) : undefined) ?? versions.find((v) => v.commit === last(versionOptions)?.value); - setBaseVersion(requestedBase); - setDiffVersion(requestedDiff); + setBaseVersionId(requestedBase?.id); + setDiffVersionId(requestedDiff?.id); // Compute viewMode from the requested versions, not the stale state-derived // `isChatDiff`, so we don't briefly render the wrong mode on reopen. const requestedIsChatDiff = @@ -359,13 +398,7 @@ const ComparePromptVersionDialog: React.FunctionComponent< normalizeChatTemplate(requestedDiff?.template || ""), ) !== null; setViewMode(requestedIsChatDiff ? "pretty" : "json"); - }, [ - open, - versionOptions, - versions, - initialBaseVersionId, - initialDiffVersionId, - ]); + }, [open]); const baseLabel = baseVersion ? versionLabelByCommit.get(baseVersion.commit) ?? "" @@ -476,7 +509,7 @@ const ComparePromptVersionDialog: React.FunctionComponent< )} - {anyMediaChanged && ( + {baseVersion && diffVersion && anyMediaChanged && ( <> {mediaChanges .filter((m) => m.changed) diff --git a/apps/opik-frontend/src/v2/pages/PromptPage/PromptPage.tsx b/apps/opik-frontend/src/v2/pages/PromptPage/PromptPage.tsx index 29fd2a9a177..1afeba34d37 100644 --- a/apps/opik-frontend/src/v2/pages/PromptPage/PromptPage.tsx +++ b/apps/opik-frontend/src/v2/pages/PromptPage/PromptPage.tsx @@ -27,7 +27,13 @@ const PromptPage: React.FunctionComponent = () => { const promptId = usePromptIdFromURL(); - const { data: prompt } = usePromptById({ promptId }, { enabled: !!promptId }); + // Cheap poll so the (unbounded) paginated version list can detect other + // users' changes without itself refetching every loaded page on a timer — + // see usePromptVersionHistory's version_count watcher. + const { data: prompt } = usePromptById( + { promptId }, + { enabled: !!promptId, refetchInterval: 30000 }, + ); const promptName = prompt?.name || ""; const setBreadcrumbParam = useBreadcrumbsStore((state) => state.setParam); diff --git a/apps/opik-frontend/src/v2/pages/PromptPage/PromptTab/DeployToEnvironmentMenu.tsx b/apps/opik-frontend/src/v2/pages/PromptPage/PromptTab/DeployToEnvironmentMenu.tsx index b1c5e2899b0..86091e56167 100644 --- a/apps/opik-frontend/src/v2/pages/PromptPage/PromptTab/DeployToEnvironmentMenu.tsx +++ b/apps/opik-frontend/src/v2/pages/PromptPage/PromptTab/DeployToEnvironmentMenu.tsx @@ -3,6 +3,7 @@ import { ChevronDown, Check, CircleFadingArrowUp, + Loader2, Settings2, X, } from "lucide-react"; @@ -30,8 +31,9 @@ type DeployToEnvironmentMenuProps = { versionId: string; versionLabel: string; versions: PromptVersion[] | undefined; - totalVersions: number; activeEnvironments: string[]; + onOpenChange?: (open: boolean) => void; + isLoadingMore?: boolean; }; const DeployToEnvironmentMenu: React.FC = ({ @@ -39,8 +41,9 @@ const DeployToEnvironmentMenu: React.FC = ({ versionId, versionLabel, versions, - totalVersions, activeEnvironments, + onOpenChange, + isLoadingMore = false, }) => { const { toast } = useToast(); const workspaceName = useAppStore((state) => state.activeWorkspaceName); @@ -64,13 +67,13 @@ const DeployToEnvironmentMenu: React.FC = ({ ); const environmentOwners = useMemo(() => { - const map = new Map(); + const map = new Map(); // `versions` is newest-first; only keep the first writer per environment so // the "Currently vN" label reflects the newest version assigned to that env, // not whichever historical version was iterated last. - versions?.forEach((v, index) => { + versions?.forEach((v) => { v.environments?.forEach((env) => { - if (!map.has(env)) map.set(env, { version: v, index }); + if (!map.has(env)) map.set(env, v); }); }); return map; @@ -120,7 +123,7 @@ const DeployToEnvironmentMenu: React.FC = ({ if (!canEditPrompts) return null; return ( - +