diff --git a/website/public/locales/en/tasks.json b/website/public/locales/en/tasks.json index ea19bc062d..b7285ffac1 100644 --- a/website/public/locales/en/tasks.json +++ b/website/public/locales/en/tasks.json @@ -95,5 +95,12 @@ "tab_preview": "Preview", "writing_wrong_langauge_a_b": "You appear to be writing in {{detected_lang}} but this will be submitted as {{submit_lang}}.", "not_rankable": "All answers are factually incorrect and cannot be ranked", - "moderator_edit_explain": "Modify the highlighted message while keeping changes to a minimum. This action can cause the conversation to not make sense and cause ratings to no longer be accurate. Please use carefully." + "moderator_edit_explain": "Modify the highlighted message while keeping changes to a minimum. This action can cause the conversation to not make sense and cause ratings to no longer be accurate. Please use carefully.", + "ranking_display_preferences": "Display Preferences", + "display_mode": "Display Mode", + "vertical_display": "Vertical", + "horizontal_display": "Horizontal", + "remove_content_limit": "Remove message content limit", + "messages_per_row": "Messages per row", + "messages_per_row_description": "Number of messages to display per row in horizontal mode" } diff --git a/website/src/components/Chat/ChatConfigForm.tsx b/website/src/components/Chat/ChatConfigForm.tsx index aad64b053b..c57b58ade9 100644 --- a/website/src/components/Chat/ChatConfigForm.tsx +++ b/website/src/components/Chat/ChatConfigForm.tsx @@ -22,19 +22,19 @@ import { Controller, useFormContext, UseFormSetValue } from "react-hook-form"; import SimpleBar from "simplebar-react"; import { ChatConfigFormData, + CustomInstructionsType, ModelParameterConfig, PluginEntry, SamplingParameters, - CustomInstructionsType, } from "src/types/Chat"; import { CustomPreset, getConfigCache } from "src/utils/chat"; import { useIsomorphicLayoutEffect } from "usehooks-ts"; import { ChatConfigSaver } from "./ChatConfigSaver"; import { useChatInitialData } from "./ChatInitialDataContext"; +import CustomInstructions from "./CustomInstructions"; import { DeletePresetButton } from "./DeletePresetButton"; import { PluginsChooser } from "./PluginsChooser"; -import CustomInstructions from "./CustomInstructions"; import { SavePresetButton } from "./SavePresetButton"; import { areParametersEqual } from "./WorkParameters"; diff --git a/website/src/components/Chat/ChatConfigSaver.tsx b/website/src/components/Chat/ChatConfigSaver.tsx index 747f594255..0274cdd0dd 100644 --- a/website/src/components/Chat/ChatConfigSaver.tsx +++ b/website/src/components/Chat/ChatConfigSaver.tsx @@ -1,6 +1,6 @@ import { MutableRefObject, useEffect } from "react"; import { useFormContext } from "react-hook-form"; -import { ChatConfigFormData, PluginEntry, CustomInstructionsType } from "src/types/Chat"; +import { ChatConfigFormData, CustomInstructionsType,PluginEntry } from "src/types/Chat"; import { CachedChatConfig, CustomPreset, setConfigCache } from "src/utils/chat"; export const ChatConfigSaver = ({ diff --git a/website/src/components/Chat/CustomInstructions.tsx b/website/src/components/Chat/CustomInstructions.tsx index 8b360c649f..961134673b 100644 --- a/website/src/components/Chat/CustomInstructions.tsx +++ b/website/src/components/Chat/CustomInstructions.tsx @@ -1,20 +1,19 @@ -import React, { useState } from "react"; import { Button, + FormControl, + FormLabel, Modal, - ModalOverlay, - ModalContent, - ModalHeader, ModalBody, + ModalContent, ModalFooter, - FormControl, - FormLabel, - Input, + ModalHeader, + ModalOverlay, Text, Textarea, } from "@chakra-ui/react"; import { BookOpen } from "lucide-react"; import { useTranslation } from "next-i18next"; +import React, { useState } from "react"; import { CustomInstructionsType } from "src/types/Chat"; const CHAR_LIMIT = 256; diff --git a/website/src/components/Chat/PluginsChooser.tsx b/website/src/components/Chat/PluginsChooser.tsx index 542d0d213f..f57fe0a01a 100644 --- a/website/src/components/Chat/PluginsChooser.tsx +++ b/website/src/components/Chat/PluginsChooser.tsx @@ -24,6 +24,7 @@ import { } from "@chakra-ui/react"; import { AlertCircle, CheckCircle2, Edit, Eye, Paperclip, Plus } from "lucide-react"; import { X } from "lucide-react"; +import Link from "next/link"; import { useTranslation } from "next-i18next"; import { Dispatch, SetStateAction, useCallback, useRef, useState } from "react"; import { MouseEvent } from "react"; @@ -33,7 +34,6 @@ import { post } from "src/lib/api"; import { OasstError } from "src/lib/oasst_api_client"; import { API_ROUTES } from "src/lib/routes"; import { ChatConfigFormData, PluginEntry } from "src/types/Chat"; -import Link from "next/link"; import { JsonCard } from "../JsonCard"; diff --git a/website/src/components/CollapsableText.tsx b/website/src/components/CollapsableText.tsx index 3f6db47771..50cd45adae 100644 --- a/website/src/components/CollapsableText.tsx +++ b/website/src/components/CollapsableText.tsx @@ -1,8 +1,14 @@ import { useBreakpointValue } from "@chakra-ui/react"; -export const CollapsableText = ({ text }: { text: string }) => { +interface CollapsableTextProps { + text: string; + disableCollapse?: boolean; +} + +export const CollapsableText = ({ text, disableCollapse = false }: CollapsableTextProps) => { const maxLength = useBreakpointValue({ base: 220, md: 500, lg: 700, xl: 1000 }); - if (typeof text !== "string" || text.length <= maxLength) { + + if (disableCollapse || typeof text !== "string" || text.length <= maxLength) { return <>{text}; } else { const visibleText = text.substring(0, maxLength - 3); diff --git a/website/src/components/Sortable/Sortable.tsx b/website/src/components/Sortable/Sortable.tsx index 35ea9ec779..0d64164a1f 100644 --- a/website/src/components/Sortable/Sortable.tsx +++ b/website/src/components/Sortable/Sortable.tsx @@ -1,5 +1,6 @@ import { Flex, + Grid, Modal, ModalBody, ModalCloseButton, @@ -21,6 +22,7 @@ import type { DragEndEvent } from "@dnd-kit/core/dist/types/events"; import { restrictToVerticalAxis, restrictToWindowEdges } from "@dnd-kit/modifiers"; import { arrayMove, + rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, @@ -40,6 +42,9 @@ export interface SortableProps { isDisabled?: boolean; className?: string; revealSynthetic?: boolean; + displayMode?: "vertical" | "horizontal"; + messagesPerRow?: number; + removeContentLimit?: boolean; } interface SortableItem { @@ -48,7 +53,14 @@ interface SortableItem { item: Message; } -export const Sortable = ({ onChange, revealSynthetic, ...props }: SortableProps) => { +export const Sortable = ({ + onChange, + revealSynthetic, + displayMode = "vertical", + messagesPerRow = 3, + removeContentLimit = false, + ...props +}: SortableProps) => { const [itemsWithIds, setItemsWithIds] = useState([]); const [modalText, setModalText] = useState(null); useEffect(() => { @@ -90,42 +102,82 @@ export const Sortable = ({ onChange, revealSynthetic, ...props }: SortableProps) [onChange] ); + const isHorizontal = displayMode === "horizontal"; + const sortingStrategy = isHorizontal ? rectSortingStrategy : verticalListSortingStrategy; + const modifiers = isHorizontal + ? [restrictToWindowEdges] + : [restrictToWindowEdges, restrictToVerticalAxis]; + return ( <> - - - {itemsWithIds.map(({ id, item }, index) => ( - { - setModalText(item.text); - onOpen(); - }} - key={id} - id={id} - index={index} - isEditable={props.isEditable} - isDisabled={!!props.isDisabled} - synthetic={item.synthetic && !!revealSynthetic} - > - + + ))} + + ) : ( + + {itemsWithIds.map(({ id, item }, index) => ( + { setModalText(item.text); onOpen(); }} + key={id} + id={id} + index={index} + isEditable={props.isEditable} + isDisabled={!!props.isDisabled} + synthetic={item.synthetic && !!revealSynthetic} > - - - - ))} - + + + ))} + + )} (null); const [notRankable, setNotRankable] = useState(false); + const { preferences } = useRankingDisplayPreferences(); + let messages: Message[] = []; if (task.type !== TaskType.rank_initial_prompts) { messages = task.conversation.messages; @@ -49,6 +53,10 @@ export const EvaluateTask = ({ // @notmd: I haven't test `rank_initial_prompts` type yet const sortableItems = task.type === TaskType.rank_initial_prompts ? (task.prompts as unknown as Message[]) : task.reply_messages; + + // Calculate max messages per row based on the number of items + const maxMessagesPerRow = Math.min(sortableItems.length, 6); + return (
@@ -57,13 +65,22 @@ export const EvaluateTask = ({ - + + {/* Display Preferences Controls */} + + + + + { + const { t } = useTranslation("tasks"); + const { + preferences, + setDisplayMode, + setRemoveContentLimit, + setMessagesPerRow, + } = useRankingDisplayPreferences(); + + const cardColor = useColorModeValue("gray.50", "gray.800"); + + const handleDisplayModeChange = useCallback((value: string) => { + setDisplayMode(value as "vertical" | "horizontal"); + }, [setDisplayMode]); + + const handleContentLimitToggle = useCallback((checked: boolean) => { + setRemoveContentLimit(checked); + }, [setRemoveContentLimit]); + + const handleMessagesPerRowChange = useCallback((value: number) => { + setMessagesPerRow(value); + }, [setMessagesPerRow]); + + const handleSliderChange = useCallback((value: number) => { + setMessagesPerRow(value); + }, [setMessagesPerRow]); + + return ( + + + {t("ranking_display_preferences")} + + + + {/* Display Mode Toggle */} + + {t("display_mode")} + + + {t("vertical_display")} + {t("horizontal_display")} + + + + + {/* Remove Content Limit Toggle */} + + + {t("remove_content_limit")} + handleContentLimitToggle(e.target.checked)} + /> + + + + {/* Messages Per Row Slider (only show in horizontal mode) */} + {preferences.displayMode === "horizontal" && ( + + + + {t("messages_per_row")} + + + + + + + + + + handleMessagesPerRowChange(value)} + size="sm" + maxW="80px" + min={1} + max={maxMessagesPerRow} + step={1} + > + + + + + + + + + )} + + + ); +}; diff --git a/website/src/components/Tasks/__tests__/RankingDisplayPreferences.test.tsx b/website/src/components/Tasks/__tests__/RankingDisplayPreferences.test.tsx new file mode 100644 index 0000000000..2c7318db54 --- /dev/null +++ b/website/src/components/Tasks/__tests__/RankingDisplayPreferences.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { RankingDisplayPreferences } from "../RankingDisplayPreferences"; + +// Mock the hook +jest.mock("src/hooks/tasks/useRankingDisplayPreferences", () => ({ + useRankingDisplayPreferences: () => ({ + preferences: { + displayMode: "vertical", + removeContentLimit: false, + messagesPerRow: 3, + }, + setDisplayMode: jest.fn(), + setRemoveContentLimit: jest.fn(), + setMessagesPerRow: jest.fn(), + }), +})); + +// Mock next-i18next +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const renderWithChakra = (component: React.ReactElement) => { + return render({component}); +}; + +describe("RankingDisplayPreferences", () => { + it("renders display preferences controls", () => { + renderWithChakra(); + + expect(screen.getByText("ranking_display_preferences")).toBeInTheDocument(); + expect(screen.getByText("display_mode")).toBeInTheDocument(); + expect(screen.getByText("vertical_display")).toBeInTheDocument(); + expect(screen.getByText("horizontal_display")).toBeInTheDocument(); + expect(screen.getByText("remove_content_limit")).toBeInTheDocument(); + }); + + it("shows messages per row slider only in horizontal mode", () => { + const { rerender } = renderWithChakra(); + + // Should not show slider in vertical mode + expect(screen.queryByText("messages_per_row")).not.toBeInTheDocument(); + + // Mock horizontal mode + jest.doMock("src/hooks/tasks/useRankingDisplayPreferences", () => ({ + useRankingDisplayPreferences: () => ({ + preferences: { + displayMode: "horizontal", + removeContentLimit: false, + messagesPerRow: 3, + }, + setDisplayMode: jest.fn(), + setRemoveContentLimit: jest.fn(), + setMessagesPerRow: jest.fn(), + }), + })); + + rerender(); + // Note: This test would need to be updated to properly test the conditional rendering + }); +}); diff --git a/website/src/hooks/tasks/useRankingDisplayPreferences.ts b/website/src/hooks/tasks/useRankingDisplayPreferences.ts new file mode 100644 index 0000000000..0ce9eb738e --- /dev/null +++ b/website/src/hooks/tasks/useRankingDisplayPreferences.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from "react"; + +export interface RankingDisplayPreferences { + displayMode: "vertical" | "horizontal"; + removeContentLimit: boolean; + messagesPerRow: number; +} + +const RANKING_PREFERENCES_KEY = "RANKING_DISPLAY_PREFERENCES"; + +const defaultPreferences: RankingDisplayPreferences = { + displayMode: "vertical", + removeContentLimit: false, + messagesPerRow: 3, // Default to 3 messages per row for horizontal mode +}; + +export const useRankingDisplayPreferences = () => { + const [preferences, setPreferences] = useState(defaultPreferences); + const [isLoaded, setIsLoaded] = useState(false); + + // Load preferences from localStorage on mount + useEffect(() => { + if (typeof localStorage !== "undefined") { + const stored = localStorage.getItem(RANKING_PREFERENCES_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as RankingDisplayPreferences; + setPreferences({ ...defaultPreferences, ...parsed }); + } catch (error) { + console.warn("Failed to parse ranking preferences from localStorage:", error); + } + } + } + setIsLoaded(true); + }, []); + + // Save preferences to localStorage whenever they change + useEffect(() => { + if (isLoaded && typeof localStorage !== "undefined") { + localStorage.setItem(RANKING_PREFERENCES_KEY, JSON.stringify(preferences)); + } + }, [preferences, isLoaded]); + + const updatePreferences = useCallback((updates: Partial) => { + setPreferences((prev) => ({ ...prev, ...updates })); + }, []); + + const setDisplayMode = useCallback((displayMode: "vertical" | "horizontal") => { + updatePreferences({ displayMode }); + }, [updatePreferences]); + + const setRemoveContentLimit = useCallback((removeContentLimit: boolean) => { + updatePreferences({ removeContentLimit }); + }, [updatePreferences]); + + const setMessagesPerRow = useCallback((messagesPerRow: number) => { + updatePreferences({ messagesPerRow }); + }, [updatePreferences]); + + return { + preferences, + isLoaded, + updatePreferences, + setDisplayMode, + setRemoveContentLimit, + setMessagesPerRow, + }; +}; diff --git a/website/src/pages/account/index.tsx b/website/src/pages/account/index.tsx index dff78ef0d3..936cc199b8 100644 --- a/website/src/pages/account/index.tsx +++ b/website/src/pages/account/index.tsx @@ -1,4 +1,4 @@ -import { Divider, Flex, Grid, Icon, Text, Button } from "@chakra-ui/react"; +import { Button,Divider, Flex, Grid, Icon, Text } from "@chakra-ui/react"; import Head from "next/head"; import Link from "next/link"; import { useSession } from "next-auth/react"; diff --git a/website/src/utils/chat.ts b/website/src/utils/chat.ts index 712a597b99..25779a446c 100644 --- a/website/src/utils/chat.ts +++ b/website/src/utils/chat.ts @@ -1,5 +1,5 @@ import { PluginEntry } from "src/types/Chat"; -import { SamplingParameters, CustomInstructionsType } from "src/types/Chat"; +import { CustomInstructionsType,SamplingParameters } from "src/types/Chat"; const CHAT_CONFIG_KEY = "CHAT_CONFIG_V2";