diff --git a/frontend/src/components/features/home/git-branch-dropdown/branch-dropdown-menu.tsx b/frontend/src/components/features/home/git-branch-dropdown/branch-dropdown-menu.tsx index b3618d675f..f1f024d2ba 100644 --- a/frontend/src/components/features/home/git-branch-dropdown/branch-dropdown-menu.tsx +++ b/frontend/src/components/features/home/git-branch-dropdown/branch-dropdown-menu.tsx @@ -47,7 +47,6 @@ export function BranchDropdownMenu({ key={branch.name} item={branch} index={index} - isHighlighted={currentHighlightedIndex === index} isSelected={currentSelectedItem?.name === branch.name} getItemProps={currentGetItemProps} getDisplayText={(branchItem) => branchItem.name} diff --git a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx index 4e53eda512..a9f979f9cb 100644 --- a/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx +++ b/frontend/src/components/features/home/git-provider-dropdown/git-provider-dropdown.tsx @@ -134,7 +134,6 @@ export function GitProviderDropdown({ key={item} item={item} index={index} - isHighlighted={index === currentHighlightedIndex} isSelected={item === currentSelectedItem} getItemProps={currentGetItemProps} getDisplayText={formatProviderName} diff --git a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx index af480ce7ab..2262b750ea 100644 --- a/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx +++ b/frontend/src/components/features/home/git-repo-dropdown/git-repo-dropdown.tsx @@ -23,6 +23,8 @@ import { GenericDropdownMenu } from "../shared/generic-dropdown-menu"; import { useConfig } from "#/hooks/query/use-config"; import { I18nKey } from "#/i18n/declaration"; import RepoIcon from "#/icons/repo.svg?react"; +import { useHomeStore } from "#/stores/home-store"; +import { Typography } from "#/ui/typography"; export interface GitRepoDropdownProps { provider: Provider; @@ -45,6 +47,7 @@ export function GitRepoDropdown({ }: GitRepoDropdownProps) { const { t } = useTranslation(); const { data: config } = useConfig(); + const { recentRepositories: storedRecentRepositories } = useHomeStore(); const [inputValue, setInputValue] = useState(""); const [localSelectedItem, setLocalSelectedItem] = useState(null); @@ -88,37 +91,78 @@ export function GitRepoDropdown({ repositoryName, ); - // Filter repositories based on input value - const filteredRepositories = useMemo(() => { - // If we have URL search results, show them directly (no filtering needed) - if (urlSearchResults.length > 0) { - return repositories; - } + // Get recent repositories filtered by provider and input keyword + const recentRepositories = useMemo(() => { + const allRecentRepos = storedRecentRepositories; + const providerFilteredRepos = allRecentRepos.filter( + (repo) => repo.git_provider === provider, + ); - // If we have a selected repository and the input matches it exactly, show all repositories - if (selectedRepository && inputValue === selectedRepository.full_name) { - return repositories; - } - - // If no input value, show all repositories + // If no input value, return all recent repos for this provider if (!inputValue || !inputValue.trim()) { - return repositories; + return providerFilteredRepos; } - // For URL inputs, use the processed search input for filtering + // Filter by input keyword const filterText = inputValue.startsWith("https://") ? processedSearchInput : inputValue; - return repositories.filter((repo) => + return providerFilteredRepos.filter((repo) => repo.full_name.toLowerCase().includes(filterText.toLowerCase()), ); + }, [storedRecentRepositories, provider, inputValue, processedSearchInput]); + + // Helper function to prioritize recent repositories at the top + const prioritizeRecentRepositories = useCallback( + (repoList: GitRepository[]) => { + const recentRepoIds = new Set(recentRepositories.map((repo) => repo.id)); + const recentRepos = repoList.filter((repo) => recentRepoIds.has(repo.id)); + const otherRepos = repoList.filter((repo) => !recentRepoIds.has(repo.id)); + return [...recentRepos, ...otherRepos]; + }, + [recentRepositories], + ); + + // Filter repositories based on input value + const filteredRepositories = useMemo(() => { + let baseRepositories: GitRepository[]; + + // If we have URL search results, show them directly (no filtering needed) + if (urlSearchResults.length > 0) { + baseRepositories = repositories; + } + // If we have a selected repository and the input matches it exactly, show all repositories + else if ( + selectedRepository && + inputValue === selectedRepository.full_name + ) { + baseRepositories = repositories; + } + // If no input value, show all repositories + else if (!inputValue || !inputValue.trim()) { + baseRepositories = repositories; + } + // For URL inputs, use the processed search input for filtering + else { + const filterText = inputValue.startsWith("https://") + ? processedSearchInput + : inputValue; + + baseRepositories = repositories.filter((repo) => + repo.full_name.toLowerCase().includes(filterText.toLowerCase()), + ); + } + + // Prioritize recent repositories at the top + return prioritizeRecentRepositories(baseRepositories); }, [ repositories, inputValue, selectedRepository, urlSearchResults, processedSearchInput, + prioritizeRecentRepositories, ]); // Handle selection @@ -240,7 +284,6 @@ export function GitRepoDropdown({ key={item.id} item={item} index={index} - isHighlighted={itemHighlightedIndex === index} isSelected={itemSelectedItem?.id === item.id} getItemProps={itemGetItemProps} getDisplayText={(repo) => repo.full_name} @@ -257,6 +300,21 @@ export function GitRepoDropdown({ /> ); + // Create sticky top item for recent repositories + const stickyTopItem = useMemo(() => { + if (recentRepositories.length === 0) { + return null; + } + + return ( +
+ + {t(I18nKey.COMMON$MOST_RECENT)} + +
+ ); + }, [recentRepositories, localSelectedItem, getItemProps, t]); + return (
@@ -309,8 +367,10 @@ export function GitRepoDropdown({ menuRef={menuRef} renderItem={renderItem} renderEmptyState={renderEmptyState} + stickyTopItem={stickyTopItem} stickyFooterItem={stickyFooterItem} testId="git-repo-dropdown-menu" + numberOfRecentItems={recentRepositories.length} /> diff --git a/frontend/src/components/features/home/repo-selection-form.tsx b/frontend/src/components/features/home/repo-selection-form.tsx index 70d7f70ee4..676ae54c0c 100644 --- a/frontend/src/components/features/home/repo-selection-form.tsx +++ b/frontend/src/components/features/home/repo-selection-form.tsx @@ -13,6 +13,7 @@ import RepoForkedIcon from "#/icons/repo-forked.svg?react"; import { GitProviderDropdown } from "./git-provider-dropdown"; import { GitBranchDropdown } from "./git-branch-dropdown"; import { GitRepoDropdown } from "./git-repo-dropdown"; +import { useHomeStore } from "#/stores/home-store"; interface RepositorySelectionFormProps { onRepoSelection: (repo: GitRepository | null) => void; @@ -34,6 +35,7 @@ export function RepositorySelectionForm({ React.useState(null); const { providers } = useUserProviders(); + const { addRecentRepository } = useHomeStore(); const { mutate: createConversation, isPending, @@ -168,7 +170,12 @@ export function RepositorySelectionForm({ (providers.length > 1 && !selectedProvider) || isLoadingSettings } - onClick={() => + onClick={() => { + // Persist the repository to recent repositories when launching + if (selectedRepository) { + addRecentRepository(selectedRepository); + } + createConversation( { repository: { @@ -181,8 +188,8 @@ export function RepositorySelectionForm({ onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`), }, - ) - } + ); + }} className="w-full font-semibold" > {!isCreatingConversation && "Launch"} diff --git a/frontend/src/components/features/home/shared/dropdown-item.tsx b/frontend/src/components/features/home/shared/dropdown-item.tsx index ef9b609cfd..08e22dc12b 100644 --- a/frontend/src/components/features/home/shared/dropdown-item.tsx +++ b/frontend/src/components/features/home/shared/dropdown-item.tsx @@ -4,7 +4,6 @@ import { cn } from "#/utils/utils"; interface DropdownItemProps { item: T; index: number; - isHighlighted: boolean; isSelected: boolean; getItemProps: (options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any getDisplayText: (item: T) => string; @@ -17,7 +16,6 @@ interface DropdownItemProps { export function DropdownItem({ item, index, - isHighlighted, isSelected, getItemProps, getDisplayText, @@ -35,7 +33,6 @@ export function DropdownItem({ : "px-2 py-2 cursor-pointer text-sm rounded-md mx-0 my-0.5", "text-white focus:outline-none font-normal", { - "bg-[#5C5D62]": isHighlighted && !isSelected, "bg-[#C9B974] text-black": isSelected, "hover:bg-[#5C5D62]": !isSelected, "hover:bg-[#C9B974] hover:text-black": isSelected, diff --git a/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx b/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx index 902e0773da..0da9ee4f5d 100644 --- a/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx +++ b/frontend/src/components/features/home/shared/generic-dropdown-menu.tsx @@ -29,8 +29,10 @@ export interface GenericDropdownMenuProps { ) => any, // eslint-disable-line @typescript-eslint/no-explicit-any ) => React.ReactNode; renderEmptyState: (inputValue: string) => React.ReactNode; + stickyTopItem?: React.ReactNode; stickyFooterItem?: React.ReactNode; testId?: string; + numberOfRecentItems?: number; } export function GenericDropdownMenu({ @@ -45,13 +47,15 @@ export function GenericDropdownMenu({ menuRef, renderItem, renderEmptyState, + stickyTopItem, stickyFooterItem, testId, + numberOfRecentItems = 0, }: GenericDropdownMenuProps) { if (!isOpen) return null; const hasItems = filteredItems.length > 0; - const showEmptyState = !hasItems && !stickyFooterItem; + const showEmptyState = !hasItems && !stickyTopItem && !stickyFooterItem; return (
@@ -59,7 +63,7 @@ export function GenericDropdownMenu({ className={cn( "absolute z-10 w-full bg-[#454545] border border-[#727987] rounded-lg shadow-none", "focus:outline-none mt-1 z-[9999]", - stickyFooterItem ? "max-h-60" : "max-h-60", + stickyTopItem || stickyFooterItem ? "max-h-60" : "max-h-60", )} >
    ({ ref: menuRef, className: cn( "w-full overflow-auto p-1", - stickyFooterItem ? "max-h-[calc(15rem-3rem)]" : "max-h-60", // Reserve space for sticky footer + stickyTopItem || stickyFooterItem + ? "max-h-[calc(15rem-3rem)]" + : "max-h-60", // Reserve space for sticky items ), onScroll, "data-testid": testId, })} > - {showEmptyState - ? renderEmptyState(inputValue) - : filteredItems.map((item, index) => - renderItem( - item, - index, - highlightedIndex, - selectedItem, - getItemProps, - ), - )} + {showEmptyState ? ( + renderEmptyState(inputValue) + ) : ( + <> + {stickyTopItem} + {filteredItems.map((item, index) => ( + <> + {renderItem( + item, + index, + highlightedIndex, + selectedItem, + getItemProps, + )} + {numberOfRecentItems > 0 && + index === numberOfRecentItems - 1 && ( +
    + )} + + ))} + + )}
{stickyFooterItem && (
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 345216b5a7..500b329a10 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -916,5 +916,6 @@ export enum I18nKey { COMMON$START_RUNTIME = "COMMON$START_RUNTIME", COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE", COMMON$CONFIRMATION_MODE_ENABLED = "COMMON$CONFIRMATION_MODE_ENABLED", + COMMON$MOST_RECENT = "COMMON$MOST_RECENT", HOME$NO_REPOSITORY_FOUND = "HOME$NO_REPOSITORY_FOUND", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 76e5538117..16b9bfe29f 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -14655,6 +14655,22 @@ "de": "Bestätigungsmodus aktiviert", "uk": "Режим підтвердження увімкнено" }, + "COMMON$MOST_RECENT": { + "en": "Most Recent", + "ja": "最新", + "zh-CN": "最新", + "zh-TW": "最新", + "ko-KR": "최신", + "no": "Nyeste", + "it": "Più recente", + "pt": "Mais recente", + "es": "Más reciente", + "ar": "الأحدث", + "fr": "Le plus récent", + "tr": "En Son", + "de": "Neueste", + "uk": "Найновіше" + }, "HOME$NO_REPOSITORY_FOUND": { "en": "No repository found to launch conversation", "ja": "会話を開始するためのリポジトリが見つかりません", diff --git a/frontend/src/stores/home-store.ts b/frontend/src/stores/home-store.ts new file mode 100644 index 0000000000..3ec2ed2c26 --- /dev/null +++ b/frontend/src/stores/home-store.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { GitRepository } from "#/types/git"; + +interface HomeState { + recentRepositories: GitRepository[]; +} + +interface HomeActions { + addRecentRepository: (repository: GitRepository) => void; + clearRecentRepositories: () => void; + getRecentRepositories: () => GitRepository[]; +} + +type HomeStore = HomeState & HomeActions; + +const initialState: HomeState = { + recentRepositories: [], +}; + +export const useHomeStore = create()( + persist( + (set, get) => ({ + ...initialState, + + addRecentRepository: (repository: GitRepository) => + set((state) => { + // Remove the repository if it already exists to avoid duplicates + const filteredRepos = state.recentRepositories.filter( + (repo) => repo.id !== repository.id, + ); + + // Add the new repository to the beginning and keep only top 3 + const updatedRepos = [repository, ...filteredRepos].slice(0, 3); + + return { + recentRepositories: updatedRepos, + }; + }), + + clearRecentRepositories: () => + set(() => ({ + recentRepositories: [], + })), + + getRecentRepositories: () => get().recentRepositories, + }), + { + name: "home-store", // unique name for localStorage + storage: createJSONStorage(() => localStorage), + }, + ), +);