From 2fc31e96d05a73871e1617e64fd71295784e02c9 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:03:25 +0400 Subject: [PATCH] chore(frontend): Add V1 git service API with unified hooks for git changes and diffs (#11565) --- .../src/api/git-service/v1-git-service.api.ts | 89 +++++++++++++++ frontend/src/api/open-hands.types.ts | 5 + .../features/diff-viewer/file-diff-viewer.tsx | 4 +- .../query/use-unified-get-git-changes.ts | 107 ++++++++++++++++++ .../src/hooks/query/use-unified-git-diff.ts | 67 +++++++++++ frontend/src/routes/changes-tab.tsx | 4 +- frontend/src/utils/cache-utils.ts | 7 +- frontend/src/utils/get-git-path.ts | 22 ++++ frontend/src/utils/git-status-mapper.ts | 27 +++++ 9 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 frontend/src/api/git-service/v1-git-service.api.ts create mode 100644 frontend/src/hooks/query/use-unified-get-git-changes.ts create mode 100644 frontend/src/hooks/query/use-unified-git-diff.ts create mode 100644 frontend/src/utils/get-git-path.ts create mode 100644 frontend/src/utils/git-status-mapper.ts diff --git a/frontend/src/api/git-service/v1-git-service.api.ts b/frontend/src/api/git-service/v1-git-service.api.ts new file mode 100644 index 0000000000..ce8f2030fd --- /dev/null +++ b/frontend/src/api/git-service/v1-git-service.api.ts @@ -0,0 +1,89 @@ +import axios from "axios"; +import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { buildSessionHeaders } from "#/utils/utils"; +import { mapV1ToV0Status } from "#/utils/git-status-mapper"; +import type { + GitChange, + GitChangeDiff, + V1GitChangeStatus, +} from "../open-hands.types"; + +interface V1GitChange { + status: V1GitChangeStatus; + path: string; +} + +class V1GitService { + /** + * Build the full URL for V1 runtime-specific endpoints + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param path The API path (e.g., "/api/git/changes") + * @returns Full URL to the runtime endpoint + */ + private static buildRuntimeUrl( + conversationUrl: string | null | undefined, + path: string, + ): string { + const baseUrl = buildHttpBaseUrl(conversationUrl); + return `${baseUrl}${path}`; + } + + /** + * Get git changes for a V1 conversation + * Uses the agent server endpoint: GET /api/git/changes/{path} + * Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.) + * + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param sessionApiKey Session API key for authentication (required for V1) + * @param path The git repository path (e.g., /workspace/project or /workspace/project/OpenHands) + * @returns List of git changes with V0-compatible status types + */ + static async getGitChanges( + conversationUrl: string | null | undefined, + sessionApiKey: string | null | undefined, + path: string, + ): Promise { + const encodedPath = encodeURIComponent(path); + const url = this.buildRuntimeUrl( + conversationUrl, + `/api/git/changes/${encodedPath}`, + ); + const headers = buildSessionHeaders(sessionApiKey); + + // V1 API returns V1GitChangeStatus types, we need to map them to V0 format + const { data } = await axios.get(url, { headers }); + + // Map V1 statuses to V0 format for compatibility + return data.map((change) => ({ + status: mapV1ToV0Status(change.status), + path: change.path, + })); + } + + /** + * Get git change diff for a specific file in a V1 conversation + * Uses the agent server endpoint: GET /api/git/diff/{path} + * + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param sessionApiKey Session API key for authentication (required for V1) + * @param path The file path to get diff for + * @returns Git change diff + */ + static async getGitChangeDiff( + conversationUrl: string | null | undefined, + sessionApiKey: string | null | undefined, + path: string, + ): Promise { + const encodedPath = encodeURIComponent(path); + const url = this.buildRuntimeUrl( + conversationUrl, + `/api/git/diff/${encodedPath}`, + ); + const headers = buildSessionHeaders(sessionApiKey); + + const { data } = await axios.get(url, { headers }); + return data; + } +} + +export default V1GitService; diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts index 48b5eef736..9a30e46027 100644 --- a/frontend/src/api/open-hands.types.ts +++ b/frontend/src/api/open-hands.types.ts @@ -84,8 +84,13 @@ export interface ResultSet { next_page_id: string | null; } +/** + * @deprecated Use V1GitChangeStatus for new code. This type is maintained for backward compatibility with V0 API. + */ export type GitChangeStatus = "M" | "A" | "D" | "R" | "U"; +export type V1GitChangeStatus = "MOVED" | "ADDED" | "DELETED" | "UPDATED"; + export interface GitChange { status: GitChangeStatus; path: string; diff --git a/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx b/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx index 3bcd4af109..80ef2fbed5 100644 --- a/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx +++ b/frontend/src/components/features/diff-viewer/file-diff-viewer.tsx @@ -7,7 +7,7 @@ import { GitChangeStatus } from "#/api/open-hands.types"; import { getLanguageFromPath } from "#/utils/get-language-from-path"; import { cn } from "#/utils/utils"; import ChevronUp from "#/icons/chveron-up.svg?react"; -import { useGitDiff } from "#/hooks/query/use-get-diff"; +import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff"; interface LoadingSpinnerProps { className?: string; @@ -64,7 +64,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) { isLoading, isSuccess, isRefetching, - } = useGitDiff({ + } = useUnifiedGitDiff({ filePath, type, enabled: !isCollapsed, diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts new file mode 100644 index 0000000000..ae5600469a --- /dev/null +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -0,0 +1,107 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import GitService from "#/api/git-service/git-service.api"; +import V1GitService from "#/api/git-service/v1-git-service.api"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import { getGitPath } from "#/utils/get-git-path"; +import type { GitChange } from "#/api/open-hands.types"; + +/** + * Unified hook to get git changes for both legacy (V0) and V1 conversations + * - V0: Uses the legacy GitService.getGitChanges API endpoint + * - V1: Uses the V1GitService.getGitChanges API endpoint with runtime URL + */ +export const useUnifiedGetGitChanges = () => { + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + const [orderedChanges, setOrderedChanges] = React.useState([]); + const previousDataRef = React.useRef(null); + const runtimeIsReady = useRuntimeIsReady(); + + const isV1Conversation = conversation?.conversation_version === "V1"; + const conversationUrl = conversation?.url; + const sessionApiKey = conversation?.session_api_key; + const selectedRepository = conversation?.selected_repository; + + // Calculate git path based on selected repository + const gitPath = React.useMemo( + () => getGitPath(selectedRepository), + [selectedRepository], + ); + + const result = useQuery({ + queryKey: [ + "file_changes", + conversationId, + isV1Conversation, + conversationUrl, + gitPath, + ], + queryFn: async () => { + if (!conversationId) throw new Error("No conversation ID"); + + // V1: Use the V1 API endpoint with runtime URL + if (isV1Conversation) { + return V1GitService.getGitChanges( + conversationUrl, + sessionApiKey, + gitPath, + ); + } + + // V0 (Legacy): Use the legacy API endpoint + return GitService.getGitChanges(conversationId); + }, + retry: false, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + enabled: runtimeIsReady && !!conversationId, + meta: { + disableToast: true, + }, + }); + + // Latest changes should be on top + React.useEffect(() => { + if (!result.isFetching && result.isSuccess && result.data) { + const currentData = result.data; + + // If this is new data (not the same reference as before) + if (currentData !== previousDataRef.current) { + previousDataRef.current = currentData; + + // Figure out new items by comparing with what we already have + if (Array.isArray(currentData)) { + const currentIds = new Set(currentData.map((item) => item.path)); + const existingIds = new Set(orderedChanges.map((item) => item.path)); + + // Filter out items that already exist in orderedChanges + const newItems = currentData.filter( + (item) => !existingIds.has(item.path), + ); + + // Filter out items that no longer exist in the API response + const existingItems = orderedChanges.filter((item) => + currentIds.has(item.path), + ); + + // Add new items to the beginning + setOrderedChanges([...newItems, ...existingItems]); + } else { + // If not an array, just use the data directly + setOrderedChanges([currentData]); + } + } + } + }, [result.isFetching, result.isSuccess, result.data]); + + return { + data: orderedChanges, + isLoading: result.isLoading, + isSuccess: result.isSuccess, + isError: result.isError, + error: result.error, + }; +}; diff --git a/frontend/src/hooks/query/use-unified-git-diff.ts b/frontend/src/hooks/query/use-unified-git-diff.ts new file mode 100644 index 0000000000..33fedb497b --- /dev/null +++ b/frontend/src/hooks/query/use-unified-git-diff.ts @@ -0,0 +1,67 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import GitService from "#/api/git-service/git-service.api"; +import V1GitService from "#/api/git-service/v1-git-service.api"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { getGitPath } from "#/utils/get-git-path"; +import type { GitChangeStatus } from "#/api/open-hands.types"; + +type UseUnifiedGitDiffConfig = { + filePath: string; + type: GitChangeStatus; + enabled: boolean; +}; + +/** + * Unified hook to get git diff for both legacy (V0) and V1 conversations + * - V0: Uses the legacy GitService.getGitChangeDiff API endpoint + * - V1: Uses the V1GitService.getGitChangeDiff API endpoint with runtime URL + */ +export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => { + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + + const isV1Conversation = conversation?.conversation_version === "V1"; + const conversationUrl = conversation?.url; + const sessionApiKey = conversation?.session_api_key; + const selectedRepository = conversation?.selected_repository; + + // For V1, we need to convert the relative file path to an absolute path + // The diff endpoint expects: /workspace/project/RepoName/relative/path + const absoluteFilePath = React.useMemo(() => { + if (!isV1Conversation) return config.filePath; + + const gitPath = getGitPath(selectedRepository); + return `${gitPath}/${config.filePath}`; + }, [isV1Conversation, selectedRepository, config.filePath]); + + return useQuery({ + queryKey: [ + "file_diff", + conversationId, + config.filePath, + config.type, + isV1Conversation, + conversationUrl, + ], + queryFn: async () => { + if (!conversationId) throw new Error("No conversation ID"); + + // V1: Use the V1 API endpoint with runtime URL and absolute path + if (isV1Conversation) { + return V1GitService.getGitChangeDiff( + conversationUrl, + sessionApiKey, + absoluteFilePath, + ); + } + + // V0 (Legacy): Use the legacy API endpoint with relative path + return GitService.getGitChangeDiff(conversationId, config.filePath); + }, + enabled: config.enabled, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); +}; diff --git a/frontend/src/routes/changes-tab.tsx b/frontend/src/routes/changes-tab.tsx index 620e179389..7e56d0ab0c 100644 --- a/frontend/src/routes/changes-tab.tsx +++ b/frontend/src/routes/changes-tab.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import React from "react"; import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer"; import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; -import { useGetGitChanges } from "#/hooks/query/use-get-git-changes"; +import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes"; import { I18nKey } from "#/i18n/declaration"; import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; import { RandomTip } from "#/components/features/tips/random-tip"; @@ -27,7 +27,7 @@ function GitChanges() { isError, error, isLoading: loadingGitChanges, - } = useGetGitChanges(); + } = useUnifiedGetGitChanges(); const [statusMessage, setStatusMessage] = React.useState( null, diff --git a/frontend/src/utils/cache-utils.ts b/frontend/src/utils/cache-utils.ts index c2285fea71..aa16c6c653 100644 --- a/frontend/src/utils/cache-utils.ts +++ b/frontend/src/utils/cache-utils.ts @@ -24,6 +24,7 @@ export const handleActionEventCacheInvalidation = ( // Invalidate file_changes cache for file-related actions if ( action.kind === "StrReplaceEditorAction" || + action.kind === "FileEditorAction" || action.kind === "ExecuteBashAction" ) { queryClient.invalidateQueries( @@ -35,7 +36,11 @@ export const handleActionEventCacheInvalidation = ( } // Invalidate specific file diff cache for file modifications - if (action.kind === "StrReplaceEditorAction" && action.path) { + if ( + (action.kind === "StrReplaceEditorAction" || + action.kind === "FileEditorAction") && + action.path + ) { const strippedPath = stripWorkspacePrefix(action.path); queryClient.invalidateQueries({ queryKey: ["file_diff", conversationId, strippedPath], diff --git a/frontend/src/utils/get-git-path.ts b/frontend/src/utils/get-git-path.ts new file mode 100644 index 0000000000..157dbeb271 --- /dev/null +++ b/frontend/src/utils/get-git-path.ts @@ -0,0 +1,22 @@ +/** + * Get the git repository path for a conversation + * If a repository is selected, returns /workspace/project/{repo-name} + * Otherwise, returns /workspace/project + * + * @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo") + * @returns The git path to use + */ +export function getGitPath( + selectedRepository: string | null | undefined, +): string { + if (!selectedRepository) { + return "/workspace/project"; + } + + // Extract the repository name from "owner/repo" format + // The folder name is the second part after "/" + const parts = selectedRepository.split("/"); + const repoName = parts.length > 1 ? parts[1] : parts[0]; + + return `/workspace/project/${repoName}`; +} diff --git a/frontend/src/utils/git-status-mapper.ts b/frontend/src/utils/git-status-mapper.ts new file mode 100644 index 0000000000..c2877c04b4 --- /dev/null +++ b/frontend/src/utils/git-status-mapper.ts @@ -0,0 +1,27 @@ +import type { + GitChangeStatus, + V1GitChangeStatus, +} from "#/api/open-hands.types"; + +/** + * Maps V1 git change status to legacy V0 status format + * + * V1 -> V0 mapping: + * - ADDED -> A (Added) + * - DELETED -> D (Deleted) + * - UPDATED -> M (Modified) + * - MOVED -> R (Renamed) + * + * @param v1Status The V1 git change status + * @returns The equivalent V0 git change status + */ +export function mapV1ToV0Status(v1Status: V1GitChangeStatus): GitChangeStatus { + const statusMap: Record = { + ADDED: "A", + DELETED: "D", + UPDATED: "M", + MOVED: "R", + }; + + return statusMap[v1Status]; +}