From b88c661c7cf5a83f8bde6d2818572cf422a50a8f Mon Sep 17 00:00:00 2001 From: Shrey Gupta Date: Tue, 21 Jul 2026 22:25:54 +0530 Subject: [PATCH 1/8] docs: plan Library PR 27 takeover --- .../2026-07-21-library-ui-pr27-takeover.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md diff --git a/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md b/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md new file mode 100644 index 0000000..85a47cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-library-ui-pr27-takeover.md @@ -0,0 +1,294 @@ +# Library UI PR #27 Takeover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the contributor-authored Library list prototype from PR #27 onto current `main`, resolve its review blockers, and publish a current, tested maintainer replacement PR that preserves contributor attribution. + +**Architecture:** Keep the Library feature as an isolated client-side prototype backed by mock data. Reuse the contributor's dense and preview views, expose only quick actions that have real callbacks, use pressed-button group semantics for the view toggle, localize every user-facing string, and type the UI view model against the merged RFC 0001 file/artifact contract where that contract applies. + +**Tech Stack:** React, TypeScript, Headless UI, React Testing Library, Jest, i18next, Vite 7/PWA runtime smoke. + +## Global Constraints + +- Start from public `main` at `f996b7ed685225d2655b57762fbbeb6ada7e82a9` or a later descendant containing PR #80. +- Preserve the original author metadata by cherry-picking contributor commits; do not squash-copy the feature. +- Do not import PR #27's stale merge commit `c000bf49353f99c07981e54625692c9f86a089f8`. +- Keep this slice mocked and contributor-safe: no production credentials, storage adapters, private endpoints, or deployment configuration. +- All user-facing text in `client/src/**/*` must use `useLocalize()` and new keys must be added only to `client/src/locales/en/translation.json`. +- Write or update a failing focused test before each behavior change, observe the expected failure, then implement the minimum fix. +- Preserve unrelated worktree changes. + +--- + +### Task 1: Replay the contributor implementation and reproduce the reviewed failures + +**Files:** +- Import: `.gitignore` +- Import: `client/src/components/Library/**/*` +- Import: `client/src/data/mockLibraryData.ts` +- Import: `client/src/types/library.ts` +- Test: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Test: `client/src/components/Library/__tests__/libraryUtils.test.ts` + +**Interfaces:** +- Consumes: PR #27 commits `aa888afc664977550ad5efee578b93e99d2aca84`, `857c58384ae4c81262127ded0c0d61bd8ca9ebfe`, `81247c0ad19083b49f2e356b74e01a5eeef5894d`, and `9db1d77d2caf016288d602fd8890bbd3a3a8bb54`. +- Produces: the contributor's Library components and tests on the repaired current toolchain. + +- [ ] **Step 1: Replay only the contributor-authored commits** + +```bash +git cherry-pick aa888afc664977550ad5efee578b93e99d2aca84 +git cherry-pick 857c58384ae4c81262127ded0c0d61bd8ca9ebfe +git cherry-pick 81247c0ad19083b49f2e356b74e01a5eeef5894d +git cherry-pick 9db1d77d2caf016288d602fd8890bbd3a3a8bb54 +``` + +- [ ] **Step 2: Run the focused Library tests and verify the known red state** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: `LibraryList › filters items by type` and `LibraryList › sorts items by name` fail because the actual dropdown buttons are not named “Filter by type” and “Sort library items”. + +### Task 2: Correct accessible control and quick-action semantics + +**Files:** +- Modify: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Modify: `client/src/components/Library/LibraryFilters.tsx` +- Modify: `client/src/components/Library/LibraryViewToggle.tsx` +- Modify: `client/src/components/Library/LibraryItemActions.tsx` +- Modify: `client/src/components/Library/LibraryListItem.tsx` +- Modify: `client/src/components/Library/LibraryPreviewCard.tsx` +- Modify: `client/src/components/Library/LibraryList.tsx` +- Modify: `client/src/types/library.ts` + +**Interfaces:** +- Consumes: `LibraryList` items and optional item-level callbacks. +- Produces: correctly named filter/sort buttons; a `role="group"` pressed-button view toggle; and optional `onDownload`, `onShare`, and `onMore` callbacks that never render dead controls. + +- [ ] **Step 1: Extend the interaction tests before changing components** + +Add assertions equivalent to: + +```tsx +expect(screen.getByRole('group', { name: 'Library view' })).toBeInTheDocument(); +expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument(); + +const onOpen = jest.fn(); +const onDownload = jest.fn(); +render(); +await userEvent.click(screen.getByRole('button', { name: 'Download' })); +expect(onDownload).toHaveBeenCalledWith(mockLibraryItems[0]); +``` + +- [ ] **Step 2: Run the focused component test and verify the new assertions fail** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx --runInBand --coverage=false +``` + +Expected: failures identify the radiogroup/pressed-button mismatch and dead quick-action buttons; the two existing dropdown tests remain red. + +- [ ] **Step 3: Implement the minimum accessible behavior** + +Implement these exact behavior rules: + +```tsx + + +
+``` + +`LibraryItemActions` must return `null` when every callback is absent and render each button only when its corresponding callback exists. `LibraryList` must pass the selected `LibraryItem` to `onItemClick`, `onDownload`, `onShare`, and `onMore` without adding placeholder handlers. + +- [ ] **Step 4: Run the focused component test and verify it passes** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx --runInBand --coverage=false +``` + +Expected: all Library component interaction tests pass. + +- [ ] **Step 5: Commit the behavior fixes** + +```bash +git add client/src/components/Library client/src/types/library.ts +git commit -m "fix: complete Library interaction semantics" +``` + +### Task 3: Align mock records with the RFC 0001 contract + +**Files:** +- Modify: `client/src/components/Library/__tests__/libraryUtils.test.ts` +- Modify: `client/src/components/Library/LibraryPreviewCard.tsx` +- Modify: `client/src/data/mockLibraryData.ts` +- Modify: `client/src/types/library.ts` + +**Interfaces:** +- Consumes: `FileRecord`, `ArtifactRecord`, `SupportedArtifactContentType`, and `isSupportedArtifactContentType` from `librechat-data-provider`. +- Produces: Library mock records whose IDs identify files/artifacts consistently and whose optional `contentType` values are accepted by the shared artifact contract. + +- [ ] **Step 1: Add a failing contract-alignment test** + +```tsx +it('keeps mock records aligned with shared file and artifact identifiers', () => { + for (const item of mockLibraryItems) { + expect(item.id).toMatch(item.kind === 'file' ? /^file_/ : /^artifact_/); + if (item.contentType) { + expect(isSupportedArtifactContentType(item.contentType)).toBe(true); + } + } +}); +``` + +- [ ] **Step 2: Run the utility test and verify it fails on the `lib-*` IDs or unsupported MIME values** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: the new contract-alignment test fails against the imported mock records. + +- [ ] **Step 3: Type and update the mock records** + +Use shared types in the view model: + +```ts +import type { ArtifactRecord, FileRecord, SupportedArtifactContentType } from 'librechat-data-provider'; + +export interface LibraryItem { + id: FileRecord['id'] | ArtifactRecord['id']; + name: FileRecord['name'] | ArtifactRecord['name']; + contentType?: SupportedArtifactContentType; +} +``` + +Rename `mimeType` to `contentType`, change file IDs to `file_*`, change artifact IDs to `artifact_*`, keep `contentType` only for values supported by RFC 0001, and make preview detection read `contentType`. + +- [ ] **Step 4: Run both focused Library suites and verify they pass** + +Run: + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: both suites pass with zero failing tests. + +- [ ] **Step 5: Commit the contract alignment** + +```bash +git add client/src/components/Library client/src/data/mockLibraryData.ts client/src/types/library.ts +git commit -m "refactor: align Library mocks with artifact contracts" +``` + +### Task 4: Localize the Library surface and remove literal-string exemptions + +**Files:** +- Modify: `client/src/components/Library/*.tsx` +- Modify: `client/src/components/Library/libraryUtils.ts` +- Modify: `client/src/components/Library/__tests__/LibraryList.test.tsx` +- Modify: `client/src/components/Library/__tests__/libraryUtils.test.ts` +- Modify: `client/src/locales/en/translation.json` + +**Interfaces:** +- Consumes: `useLocalize()` and `TranslationKeys`. +- Produces: localized labels, descriptions, counts, dates, storage text, action names, and type/sort options with English defaults in the canonical locale file. + +- [ ] **Step 1: Run ESLint against the imported Library files and record the literal-string red state** + +Run: + +```bash +npx eslint 'client/src/components/Library/**/*.{ts,tsx}' 'client/src/data/mockLibraryData.ts' 'client/src/types/library.ts' +``` + +Expected: the imported components report literal user-facing strings or other current-main lint violations. + +- [ ] **Step 2: Add canonical English translation keys** + +Add `com_ui_library_*` keys for search, filter/sort, item types, sort choices, list counts, empty/error/loading states, storage usage, view modes, source labels, and quick actions. Reuse existing `com_ui_download`, `com_ui_share`, `com_ui_retry`, and `com_ui_clear_search` where their wording is exact. + +- [ ] **Step 3: Route every user-facing Library string through `useLocalize()`** + +`libraryUtils.ts` must store translation-key maps and accept a `LocalizeFunction` when building labels/options. Components must call `useLocalize()` and remove all `i18next/no-literal-string` disable comments. Tests must import `render` from `~/test/layout-test-utils` so Recoil/i18n providers are present. + +- [ ] **Step 4: Run lint and focused tests** + +Run: + +```bash +npx eslint 'client/src/components/Library/**/*.{ts,tsx}' 'client/src/data/mockLibraryData.ts' 'client/src/types/library.ts' +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +``` + +Expected: ESLint exits zero and both focused suites pass. + +- [ ] **Step 5: Commit localization** + +```bash +git add client/src/components/Library client/src/locales/en/translation.json +git commit -m "feat: localize Library prototype" +``` + +### Task 5: Verify the current-main integration and publish the replacement PR + +**Files:** +- Verify: all changed files +- Update: PR description only through GitHub after local verification + +**Interfaces:** +- Consumes: completed Library prototype and public CI commands. +- Produces: a pushed maintainer branch and replacement PR referencing #27 and #20. + +- [ ] **Step 1: Run focused, full-client, build, config, and diff checks** + +```bash +cd client +npx jest src/components/Library/__tests__/LibraryList.test.tsx src/components/Library/__tests__/libraryUtils.test.ts --runInBand --coverage=false +npm test -- --runInBand +cd .. +node --test bharatcode/test/config.test.mjs +npm run frontend:ci +git diff --check origin/main...HEAD +git status --short +``` + +Expected: all commands exit zero, no test failures occur, the production runtime smoke passes, and only intended files are changed. + +- [ ] **Step 2: Review the commit range against this plan** + +```bash +git diff --stat origin/main...HEAD +git log --oneline --reverse origin/main..HEAD +``` + +Expected: four contributor-authored commits plus focused maintainer commits; no private deployment files or stale merge commit. + +- [ ] **Step 3: Push and create the replacement PR** + +```bash +git push -u origin codex/takeover-library-ui-pr27 +gh pr create --repo BharatCode-ai/chat --base main --head codex/takeover-library-ui-pr27 --title "feat: complete accessible Library list prototype" --body-file /tmp/pr-body.md +``` + +The PR body must reference PR #27, close issue #20, credit the contributor commits, summarize the accessibility/action/contract/localization work, and list the exact verification commands. + +- [ ] **Step 4: Wait for current CI and perform final maintainer review** + +Expected required checks: BharatCode config tests, Gitleaks, shared packages/API build, shared packages/client build including the production-runtime smoke. From 4d7fc8af91397bf5ee2dd7e01319caff26ca4834 Mon Sep 17 00:00:00 2001 From: manthansingh26 Date: Wed, 10 Jun 2026 21:34:58 +0530 Subject: [PATCH 2/8] feat: Add Library List UI with mocked data for issue #20 --- .../components/Library/LibraryEmptyState.tsx | 65 +++++++ .../components/Library/LibraryErrorState.tsx | 38 ++++ .../src/components/Library/LibraryFilters.tsx | 138 +++++++++++++++ client/src/components/Library/LibraryList.tsx | 104 +++++++++++ .../components/Library/LibraryListItem.tsx | 111 ++++++++++++ .../Library/LibraryListWithMockData.tsx | 29 ++++ .../Library/LibraryLoadingState.tsx | 28 +++ .../components/Library/LibraryStorageBar.tsx | 32 ++++ .../Library/__tests__/LibraryList.test.tsx | 51 ++++++ .../Library/__tests__/libraryUtils.test.ts | 126 ++++++++++++++ client/src/components/Library/index.ts | 10 ++ client/src/components/Library/libraryUtils.ts | 164 ++++++++++++++++++ .../components/Library/useMockLibraryData.ts | 103 +++++++++++ client/src/types/library.ts | 51 ++++++ 14 files changed, 1050 insertions(+) create mode 100644 client/src/components/Library/LibraryEmptyState.tsx create mode 100644 client/src/components/Library/LibraryErrorState.tsx create mode 100644 client/src/components/Library/LibraryFilters.tsx create mode 100644 client/src/components/Library/LibraryList.tsx create mode 100644 client/src/components/Library/LibraryListItem.tsx create mode 100644 client/src/components/Library/LibraryListWithMockData.tsx create mode 100644 client/src/components/Library/LibraryLoadingState.tsx create mode 100644 client/src/components/Library/LibraryStorageBar.tsx create mode 100644 client/src/components/Library/__tests__/LibraryList.test.tsx create mode 100644 client/src/components/Library/__tests__/libraryUtils.test.ts create mode 100644 client/src/components/Library/index.ts create mode 100644 client/src/components/Library/libraryUtils.ts create mode 100644 client/src/components/Library/useMockLibraryData.ts create mode 100644 client/src/types/library.ts diff --git a/client/src/components/Library/LibraryEmptyState.tsx b/client/src/components/Library/LibraryEmptyState.tsx new file mode 100644 index 0000000..54a52be --- /dev/null +++ b/client/src/components/Library/LibraryEmptyState.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { FolderOpen, SearchX } from 'lucide-react'; + +interface LibraryEmptyStateProps { + /** True when filters/search produced no matches but the library has items. */ + isFiltered?: boolean; + onClearFilters?: () => void; +} + +interface EmptyStateLayoutProps { + icon: LucideIcon; + title: string; + description: string; + children?: ReactNode; +} + +function EmptyStateLayout({ icon: Icon, title, description, children }: EmptyStateLayoutProps) { + return ( +
+
+
+

{title}

+

{description}

+ {children} +
+ ); +} + +export default function LibraryEmptyState({ + isFiltered = false, + onClearFilters, +}: LibraryEmptyStateProps) { + if (isFiltered) { + return ( + + {onClearFilters && ( + + )} + + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryErrorState.tsx b/client/src/components/Library/LibraryErrorState.tsx new file mode 100644 index 0000000..434bc68 --- /dev/null +++ b/client/src/components/Library/LibraryErrorState.tsx @@ -0,0 +1,38 @@ +import { AlertCircle } from 'lucide-react'; +import { Button } from '@librechat/client'; + +interface LibraryErrorStateProps { + message?: string; + onRetry?: () => void; +} + +export default function LibraryErrorState({ + message = 'Something went wrong while loading your library.', + onRetry, +}: LibraryErrorStateProps) { + return ( +
+
+
+

Unable to load library

+

{message}

+ {onRetry && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx new file mode 100644 index 0000000..af75885 --- /dev/null +++ b/client/src/components/Library/LibraryFilters.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { Search, X } from 'lucide-react'; +import { Input, SelectDropDown } from '@librechat/client'; +import type { LibraryFiltersState } from '~/types/library'; +import { useDebounce } from '~/hooks'; +import { createDropdownSetter } from '~/utils'; +import { + findLibraryOption, + LIBRARY_SORT_OPTIONS, + LIBRARY_TYPE_FILTER_OPTIONS, + parseLibrarySortOption, + parseLibraryTypeFilter, +} from './libraryUtils'; + +interface LibraryFiltersProps { + filters: LibraryFiltersState; + onFiltersChange: Dispatch>; + resultCount: number; + totalCount: number; +} + +export default function LibraryFilters({ + filters, + onFiltersChange, + resultCount, + totalCount, +}: LibraryFiltersProps) { + const [searchInput, setSearchInput] = useState(filters.search); + const debouncedSearch = useDebounce(searchInput, 300); + + useEffect(() => { + setSearchInput(filters.search); + }, [filters.search]); + + useEffect(() => { + if (debouncedSearch !== filters.search && debouncedSearch === searchInput) { + onFiltersChange((prev) => ({ ...prev, search: debouncedSearch })); + } + }, [debouncedSearch, filters.search, onFiltersChange, searchInput]); + + const handleClearSearch = useCallback(() => { + setSearchInput(''); + onFiltersChange((prev) => ({ ...prev, search: '' })); + }, [onFiltersChange]); + + const selectedTypeOption = findLibraryOption( + LIBRARY_TYPE_FILTER_OPTIONS, + filters.typeFilter, + ); + + const selectedSortOption = findLibraryOption(LIBRARY_SORT_OPTIONS, filters.sortBy); + + const handleTypeChange = useMemo( + () => + createDropdownSetter((value) => { + const typeFilter = parseLibraryTypeFilter(value); + onFiltersChange((prev) => + prev.typeFilter === typeFilter ? prev : { ...prev, typeFilter }, + ); + }), + [onFiltersChange], + ); + + const handleSortChange = useMemo( + () => + createDropdownSetter((value) => { + const sortBy = parseLibrarySortOption(value); + onFiltersChange((prev) => (prev.sortBy === sortBy ? prev : { ...prev, sortBy })); + }), + [onFiltersChange], + ); + + const itemLabel = totalCount === 1 ? 'item' : 'items'; + + return ( +
+
+
+ + setSearchInput(event.target.value)} + placeholder="Search files and artifacts..." + className="h-10 rounded-lg border-border-medium bg-transparent pl-10 pr-10 text-sm text-text-primary placeholder:text-text-secondary focus:ring-0" + aria-label="Search library" + autoComplete="off" + spellCheck={false} + /> +
+ +
+
+ +
+
+ +
+
+
+ +

+ Showing {resultCount} of {totalCount} {itemLabel} +

+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryList.tsx b/client/src/components/Library/LibraryList.tsx new file mode 100644 index 0000000..2058c2c --- /dev/null +++ b/client/src/components/Library/LibraryList.tsx @@ -0,0 +1,104 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryListStatus, + LibraryStorageInfo, +} from '~/types/library'; +import LibraryEmptyState from './LibraryEmptyState'; +import LibraryErrorState from './LibraryErrorState'; +import LibraryFilters from './LibraryFilters'; +import LibraryListItem from './LibraryListItem'; +import LibraryLoadingState from './LibraryLoadingState'; +import LibraryStorageBar from './LibraryStorageBar'; +import { applyLibraryFilters } from './libraryUtils'; + +const DEFAULT_FILTERS: LibraryFiltersState = { + search: '', + typeFilter: 'all', + sortBy: 'newest', +}; + +interface LibraryListProps { + items: LibraryItem[]; + status: LibraryListStatus; + storage?: LibraryStorageInfo | null; + errorMessage?: string; + onRetry?: () => void; + onItemClick?: (item: LibraryItem) => void; + className?: string; +} + +export default function LibraryList({ + items, + status, + storage = null, + errorMessage, + onRetry, + onItemClick, + className = '', +}: LibraryListProps) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + const filteredItems = useMemo( + () => (status === 'populated' ? applyLibraryFilters(items, filters) : []), + [items, filters, status], + ); + + const isFiltered = + filters.search.trim().length > 0 || filters.typeFilter !== 'all'; + + const handleClearFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + }, []); + + if (status === 'loading') { + return ( +
+ +
+ ); + } + + if (status === 'error') { + return ( +
+ +
+ ); + } + + if (status === 'empty') { + return ( +
+ {storage && } +
+ +
+
+ ); + } + + return ( +
+ {storage && } + + + + {filteredItems.length === 0 ? ( + + ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx new file mode 100644 index 0000000..1475f68 --- /dev/null +++ b/client/src/components/Library/LibraryListItem.tsx @@ -0,0 +1,111 @@ +import type { ComponentType } from 'react'; +import { + FileAudio, + FileCode, + FileImage, + FileSpreadsheet, + FileText, + FileVideo, + Sparkles, +} from 'lucide-react'; +import type { LibraryItem, LibraryItemType } from '~/types/library'; +import { cn } from '~/utils'; +import { + formatLibraryDate, + formatLibraryFileSize, + LIBRARY_TYPE_LABELS, +} from './libraryUtils'; + +interface LibraryListItemProps { + item: LibraryItem; + onClick?: (item: LibraryItem) => void; +} + +const TYPE_ICON_MAP: Record> = { + pdf: FileText, + image: FileImage, + video: FileVideo, + document: FileText, + code: FileCode, + spreadsheet: FileSpreadsheet, + audio: FileAudio, + artifact: Sparkles, +}; + +const TYPE_COLOR_MAP: Record = { + pdf: 'text-red-500', + image: 'text-pink-500', + video: 'text-purple-500', + document: 'text-blue-500', + code: 'text-orange-500', + spreadsheet: 'text-emerald-500', + audio: 'text-amber-500', + artifact: 'text-violet-500', +}; + +const itemRowClassName = + 'flex w-full items-center gap-3 rounded-lg border border-border-light bg-surface-primary px-4 py-3 text-left transition-colors'; + +export default function LibraryListItem({ item, onClick }: LibraryListItemProps) { + const Icon = TYPE_ICON_MAP[item.type]; + const iconColor = TYPE_COLOR_MAP[item.type]; + const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; + + const content = ( + <> + + +
+
+

{item.name}

+ + {formatLibraryFileSize(item.sizeBytes)} + +
+
+ + {LIBRARY_TYPE_LABELS[item.type]} + + {item.kind} + + +
+ {item.description && ( +

{item.description}

+ )} +
+ + ); + + if (onClick) { + return ( +
+ +
+ ); + } + + return ( +
+ {content} +
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryListWithMockData.tsx b/client/src/components/Library/LibraryListWithMockData.tsx new file mode 100644 index 0000000..1a08a7c --- /dev/null +++ b/client/src/components/Library/LibraryListWithMockData.tsx @@ -0,0 +1,29 @@ +import LibraryList from './LibraryList'; +import { useMockLibraryData, type MockLibraryScenario } from './useMockLibraryData'; + +interface LibraryListWithMockDataProps { + scenario?: MockLibraryScenario; + className?: string; +} + +/** + * Convenience wrapper that wires LibraryList to the mock data hook. + * Drop-in component for development until the real API is integrated. + */ +export default function LibraryListWithMockData({ + scenario = 'success', + className, +}: LibraryListWithMockDataProps) { + const { items, storage, status, errorMessage, refetch } = useMockLibraryData({ scenario }); + + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryLoadingState.tsx b/client/src/components/Library/LibraryLoadingState.tsx new file mode 100644 index 0000000..e9d81ad --- /dev/null +++ b/client/src/components/Library/LibraryLoadingState.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@librechat/client'; + +const SKELETON_ROWS = 6; + +export default function LibraryLoadingState() { + return ( +
+
+ +
+ + +
+
+ +
+ {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/LibraryStorageBar.tsx b/client/src/components/Library/LibraryStorageBar.tsx new file mode 100644 index 0000000..bfa054e --- /dev/null +++ b/client/src/components/Library/LibraryStorageBar.tsx @@ -0,0 +1,32 @@ +import { Progress } from '@librechat/client'; +import type { LibraryStorageInfo } from '~/types/library'; +import { + formatLibraryFileSize, + getStorageUsagePercent, +} from './libraryUtils'; + +interface LibraryStorageBarProps { + storage: LibraryStorageInfo; +} + +export default function LibraryStorageBar({ storage }: LibraryStorageBarProps) { + const percent = getStorageUsagePercent(storage.usedBytes, storage.totalBytes); + + return ( +
+
+ Storage + + {formatLibraryFileSize(storage.usedBytes)} of {formatLibraryFileSize(storage.totalBytes)}{' '} + used + +
+ +

{percent}%

+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Library/__tests__/LibraryList.test.tsx b/client/src/components/Library/__tests__/LibraryList.test.tsx new file mode 100644 index 0000000..b5e8b95 --- /dev/null +++ b/client/src/components/Library/__tests__/LibraryList.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { mockLibraryItems, mockLibraryStorage } from '~/data/mockLibraryData'; +import LibraryList from '../LibraryList'; + +jest.mock('~/hooks', () => ({ + useDebounce: (value: string) => value, +})); + +describe('LibraryList', () => { + it('renders loading state', () => { + render(); + expect(screen.getByRole('status', { name: 'Loading library' })).toBeInTheDocument(); + }); + + it('renders error state with retry', () => { + const onRetry = jest.fn(); + render( + , + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry loading library' })).toBeInTheDocument(); + }); + + it('renders empty state', () => { + render(); + expect(screen.getByText('Your library is empty')).toBeInTheDocument(); + expect(screen.getByText(/64\.2 MB of 1\.0 GB used/)).toBeInTheDocument(); + }); + + it('renders populated list with items', () => { + render( + , + ); + + expect(screen.getByRole('search')).toBeInTheDocument(); + expect(screen.getByText(`Showing ${mockLibraryItems.length} of ${mockLibraryItems.length} items`)).toBeInTheDocument(); + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/client/src/components/Library/__tests__/libraryUtils.test.ts b/client/src/components/Library/__tests__/libraryUtils.test.ts new file mode 100644 index 0000000..f37ed90 --- /dev/null +++ b/client/src/components/Library/__tests__/libraryUtils.test.ts @@ -0,0 +1,126 @@ +import { mockLibraryItems } from '~/data/mockLibraryData'; +import { + applyLibraryFilters, + filterLibraryItemsBySearch, + filterLibraryItemsByType, + findLibraryOption, + formatLibraryFileSize, + getStorageUsagePercent, + LIBRARY_SORT_OPTIONS, + LIBRARY_TYPE_FILTER_OPTIONS, + parseLibrarySortOption, + parseLibraryTypeFilter, + sortLibraryItems, +} from '../libraryUtils'; + +describe('libraryUtils', () => { + describe('formatLibraryFileSize', () => { + it('formats bytes', () => { + expect(formatLibraryFileSize(512)).toBe('512 B'); + }); + + it('formats kilobytes', () => { + expect(formatLibraryFileSize(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(formatLibraryFileSize(2_621_440)).toBe('2.5 MB'); + }); + }); + + describe('getStorageUsagePercent', () => { + it('calculates percentage', () => { + expect(getStorageUsagePercent(50, 100)).toBe(50); + }); + + it('clamps to 100', () => { + expect(getStorageUsagePercent(150, 100)).toBe(100); + }); + }); + + describe('filterLibraryItemsBySearch', () => { + it('returns all items for empty search', () => { + expect(filterLibraryItemsBySearch(mockLibraryItems, '')).toHaveLength(mockLibraryItems.length); + }); + + it('filters by name case-insensitively', () => { + const results = filterLibraryItemsBySearch(mockLibraryItems, 'pdf'); + expect(results.every((item) => item.name.toLowerCase().includes('pdf') || item.description?.toLowerCase().includes('pdf'))).toBe(true); + }); + }); + + describe('filterLibraryItemsByType', () => { + it('returns all items for "all" filter', () => { + expect(filterLibraryItemsByType(mockLibraryItems, 'all')).toHaveLength(mockLibraryItems.length); + }); + + it('filters by type', () => { + const results = filterLibraryItemsByType(mockLibraryItems, 'image'); + expect(results.every((item) => item.type === 'image')).toBe(true); + }); + }); + + describe('sortLibraryItems', () => { + it('sorts by newest first', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'newest'); + expect(new Date(sorted[0].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(sorted[1].createdAt).getTime(), + ); + }); + + it('sorts by name', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'name'); + expect(sorted[0].name.localeCompare(sorted[1].name)).toBeLessThanOrEqual(0); + }); + + it('sorts by size descending', () => { + const sorted = sortLibraryItems(mockLibraryItems, 'size'); + expect(sorted[0].sizeBytes).toBeGreaterThanOrEqual(sorted[1].sizeBytes); + }); + }); + + describe('applyLibraryFilters', () => { + it('combines search, type filter, and sort', () => { + const results = applyLibraryFilters(mockLibraryItems, { + search: 'api', + typeFilter: 'code', + sortBy: 'name', + }); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('api-client.ts'); + }); + }); + + describe('parseLibraryTypeFilter', () => { + it('returns valid type filters unchanged', () => { + expect(parseLibraryTypeFilter('pdf')).toBe('pdf'); + expect(parseLibraryTypeFilter('all')).toBe('all'); + }); + + it('falls back to all for invalid values', () => { + expect(parseLibraryTypeFilter('invalid')).toBe('all'); + }); + }); + + describe('parseLibrarySortOption', () => { + it('returns valid sort options unchanged', () => { + expect(parseLibrarySortOption('name')).toBe('name'); + expect(parseLibrarySortOption('newest')).toBe('newest'); + }); + + it('falls back to newest for invalid values', () => { + expect(parseLibrarySortOption('invalid')).toBe('newest'); + }); + }); + + describe('findLibraryOption', () => { + it('finds matching option by value', () => { + expect(findLibraryOption(LIBRARY_TYPE_FILTER_OPTIONS, 'pdf').label).toBe('PDF'); + }); + + it('returns fallback option when value is missing', () => { + expect(findLibraryOption(LIBRARY_SORT_OPTIONS, 'missing').value).toBe('newest'); + }); + }); +}); \ No newline at end of file diff --git a/client/src/components/Library/index.ts b/client/src/components/Library/index.ts new file mode 100644 index 0000000..58db893 --- /dev/null +++ b/client/src/components/Library/index.ts @@ -0,0 +1,10 @@ +export { default as LibraryList } from './LibraryList'; +export { default as LibraryListItem } from './LibraryListItem'; +export { default as LibraryFilters } from './LibraryFilters'; +export { default as LibraryEmptyState } from './LibraryEmptyState'; +export { default as LibraryLoadingState } from './LibraryLoadingState'; +export { default as LibraryErrorState } from './LibraryErrorState'; +export { default as LibraryStorageBar } from './LibraryStorageBar'; +export { default as LibraryListWithMockData } from './LibraryListWithMockData'; +export { useMockLibraryData } from './useMockLibraryData'; +export * from './libraryUtils'; \ No newline at end of file diff --git a/client/src/components/Library/libraryUtils.ts b/client/src/components/Library/libraryUtils.ts new file mode 100644 index 0000000..b7bf004 --- /dev/null +++ b/client/src/components/Library/libraryUtils.ts @@ -0,0 +1,164 @@ +import type { Option } from '~/common'; +import type { + LibraryFiltersState, + LibraryItem, + LibraryItemType, + LibrarySortOption, + LibraryTypeFilter, +} from '~/types/library'; + +/** Human-readable labels for each library item type. */ +export const LIBRARY_TYPE_LABELS: Record = { + pdf: 'PDF', + image: 'Image', + video: 'Video', + document: 'Document', + code: 'Code', + spreadsheet: 'Spreadsheet', + audio: 'Audio', + artifact: 'Artifact', +}; + +/** Sort option labels for the sort dropdown. */ +export const LIBRARY_SORT_LABELS: Record = { + newest: 'Newest first', + oldest: 'Oldest first', + name: 'Name (A–Z)', + size: 'Size (largest)', +}; + +/** All filterable types in display order. */ +export const LIBRARY_FILTER_TYPES: LibraryItemType[] = [ + 'pdf', + 'image', + 'video', + 'document', + 'code', + 'spreadsheet', + 'audio', + 'artifact', +]; + +const LIBRARY_TYPE_FILTER_VALUES = new Set(['all', ...LIBRARY_FILTER_TYPES]); +const LIBRARY_SORT_OPTION_VALUES = new Set(Object.keys(LIBRARY_SORT_LABELS)); + +/** Dropdown options for the type filter control. */ +export const LIBRARY_TYPE_FILTER_OPTIONS: Option[] = [ + { value: 'all', label: 'All types' }, + ...LIBRARY_FILTER_TYPES.map((type) => ({ + value: type, + label: LIBRARY_TYPE_LABELS[type], + })), +]; + +/** Dropdown options for the sort control. */ +export const LIBRARY_SORT_OPTIONS: Option[] = ( + Object.entries(LIBRARY_SORT_LABELS) as [LibrarySortOption, string][] +).map(([value, label]) => ({ value, label })); + +/** Resolves a dropdown value to a valid type filter, falling back to `all`. */ +export function parseLibraryTypeFilter(value: string): LibraryTypeFilter { + return LIBRARY_TYPE_FILTER_VALUES.has(value) ? (value as LibraryTypeFilter) : 'all'; +} + +/** Resolves a dropdown value to a valid sort option, falling back to `newest`. */ +export function parseLibrarySortOption(value: string): LibrarySortOption { + return LIBRARY_SORT_OPTION_VALUES.has(value) ? (value as LibrarySortOption) : 'newest'; +} + +/** Finds the matching dropdown option or returns the fallback entry. */ +export function findLibraryOption( + options: Option[], + value: string, + fallbackIndex = 0, +): Option { + return options.find((option) => String(option.value) === value) ?? options[fallbackIndex]; +} + +/** Formats byte count into a readable string with unit suffix. */ +export function formatLibraryFileSize(bytes: number): string { + if (bytes >= 1_073_741_824) { + return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + } + if (bytes >= 1_048_576) { + return `${(bytes / 1_048_576).toFixed(1)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${bytes} B`; +} + +/** Formats an ISO date string for list display. */ +export function formatLibraryDate(isoDate: string): string { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(isoDate)); +} + +/** Returns the storage usage percentage, clamped to 0–100. */ +export function getStorageUsagePercent(usedBytes: number, totalBytes: number): number { + if (totalBytes <= 0) { + return 0; + } + return Math.min(100, Math.round((usedBytes / totalBytes) * 100)); +} + +/** Filters items by case-insensitive search across name and description. */ +export function filterLibraryItemsBySearch(items: LibraryItem[], search: string): LibraryItem[] { + const query = search.trim().toLowerCase(); + if (!query) { + return items; + } + + return items.filter( + (item) => + item.name.toLowerCase().includes(query) || + item.description?.toLowerCase().includes(query), + ); +} + +/** Filters items by content type. */ +export function filterLibraryItemsByType( + items: LibraryItem[], + typeFilter: LibraryTypeFilter, +): LibraryItem[] { + if (typeFilter === 'all') { + return items; + } + return items.filter((item) => item.type === typeFilter); +} + +/** Sorts items by the selected sort option. */ +export function sortLibraryItems(items: LibraryItem[], sortBy: LibrarySortOption): LibraryItem[] { + const sorted = [...items]; + + switch (sortBy) { + case 'newest': + return sorted.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + case 'oldest': + return sorted.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + case 'name': + return sorted.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + case 'size': + return sorted.sort((a, b) => b.sizeBytes - a.sizeBytes); + default: + return sorted; + } +} + +/** Applies search, type filter, and sort in sequence. */ +export function applyLibraryFilters( + items: LibraryItem[], + filters: LibraryFiltersState, +): LibraryItem[] { + const searched = filterLibraryItemsBySearch(items, filters.search); + const filtered = filterLibraryItemsByType(searched, filters.typeFilter); + return sortLibraryItems(filtered, filters.sortBy); +} \ No newline at end of file diff --git a/client/src/components/Library/useMockLibraryData.ts b/client/src/components/Library/useMockLibraryData.ts new file mode 100644 index 0000000..f89498e --- /dev/null +++ b/client/src/components/Library/useMockLibraryData.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react'; +import { mockLibraryResponse } from '~/data/mockLibraryData'; +import type { + LibraryItem, + LibraryListStatus, + LibraryStorageInfo, +} from '~/types/library'; + +const MOCK_LOAD_DELAY_MS = 800; + +export type MockLibraryScenario = 'success' | 'empty' | 'error'; + +interface UseMockLibraryDataOptions { + /** Simulated network scenario. Defaults to `success`. */ + scenario?: MockLibraryScenario; + /** Artificial delay before resolving mock data (ms). */ + delayMs?: number; + /** When true, fetches on mount. Defaults to true. */ + autoFetch?: boolean; +} + +interface UseMockLibraryDataResult { + items: LibraryItem[]; + storage: LibraryStorageInfo | null; + status: LibraryListStatus; + errorMessage: string | null; + isLoading: boolean; + refetch: () => void; +} + +/** + * Simulates an async library API call with mocked data. + * Useful for development and Storybook-style previews before the real API exists. + */ +export function useMockLibraryData( + options: UseMockLibraryDataOptions = {}, +): UseMockLibraryDataResult { + const { scenario = 'success', delayMs = MOCK_LOAD_DELAY_MS, autoFetch = true } = options; + + const [items, setItems] = useState([]); + const [storage, setStorage] = useState(null); + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(null); + const [fetchKey, setFetchKey] = useState(0); + + const refetch = useCallback(() => { + setFetchKey((key) => key + 1); + }, []); + + useEffect(() => { + if (!autoFetch) { + return; + } + + let cancelled = false; + + const load = async () => { + setStatus('loading'); + setErrorMessage(null); + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + if (cancelled) { + return; + } + + if (scenario === 'error') { + setItems([]); + setStorage(null); + setStatus('error'); + setErrorMessage('Failed to connect to the library service. Please try again.'); + return; + } + + if (scenario === 'empty') { + setItems([]); + setStorage(mockLibraryResponse.storage); + setStatus('empty'); + return; + } + + const response = mockLibraryResponse; + setItems(response.items); + setStorage(response.storage); + setStatus(response.items.length > 0 ? 'populated' : 'empty'); + }; + + load(); + + return () => { + cancelled = true; + }; + }, [autoFetch, delayMs, scenario, fetchKey]); + + return { + items, + storage, + status, + errorMessage, + isLoading: status === 'loading', + refetch, + }; +} \ No newline at end of file diff --git a/client/src/types/library.ts b/client/src/types/library.ts new file mode 100644 index 0000000..e03e56e --- /dev/null +++ b/client/src/types/library.ts @@ -0,0 +1,51 @@ +/** Supported library item content types for filtering. */ +export type LibraryItemType = + | 'pdf' + | 'image' + | 'video' + | 'document' + | 'code' + | 'spreadsheet' + | 'audio' + | 'artifact'; + +/** Distinguishes uploaded files from generated artifacts. */ +export type LibraryItemKind = 'file' | 'artifact'; + +/** Available sort options for the library list. */ +export type LibrarySortOption = 'newest' | 'oldest' | 'name' | 'size'; + +/** UI state for the library list container. */ +export type LibraryListStatus = 'loading' | 'empty' | 'populated' | 'error'; + +/** Type filter value — `all` shows every item. */ +export type LibraryTypeFilter = LibraryItemType | 'all'; + +export interface LibraryItem { + id: string; + name: string; + type: LibraryItemType; + kind: LibraryItemKind; + sizeBytes: number; + createdAt: string; + updatedAt?: string; + description?: string; + conversationId?: string; + mimeType?: string; +} + +export interface LibraryStorageInfo { + usedBytes: number; + totalBytes: number; +} + +export interface LibraryFiltersState { + search: string; + typeFilter: LibraryTypeFilter; + sortBy: LibrarySortOption; +} + +export interface LibraryListResponse { + items: LibraryItem[]; + storage: LibraryStorageInfo | null; +} \ No newline at end of file From 67f17f9cafceb2d1a107b7326385ddf16fd9320b Mon Sep 17 00:00:00 2001 From: manthansingh26 Date: Thu, 11 Jun 2026 12:59:39 +0530 Subject: [PATCH 3/8] fix: include mockLibraryData.ts so Library List tests work in CI and fresh clones --- .gitignore | 2 +- client/src/data/mockLibraryData.ts | 147 +++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 client/src/data/mockLibraryData.ts diff --git a/.gitignore b/.gitignore index c4931cd..5aa1bff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # Logs data-node meili_data* -data/ +/data/ logs *.log diff --git a/client/src/data/mockLibraryData.ts b/client/src/data/mockLibraryData.ts new file mode 100644 index 0000000..e8fe8a5 --- /dev/null +++ b/client/src/data/mockLibraryData.ts @@ -0,0 +1,147 @@ +import type { LibraryItem, LibraryListResponse, LibraryStorageInfo } from '~/types/library'; + +/** Mock library items covering all supported types and both kinds. */ +export const mockLibraryItems: LibraryItem[] = [ + { + id: 'lib-001', + name: 'Q4 Financial Report.pdf', + type: 'pdf', + kind: 'file', + sizeBytes: 2_458_880, + createdAt: '2026-05-28T14:22:00.000Z', + description: 'Quarterly financial summary and projections', + mimeType: 'application/pdf', + }, + { + id: 'lib-002', + name: 'product-mockup-v3.png', + type: 'image', + kind: 'file', + sizeBytes: 1_572_864, + createdAt: '2026-06-01T09:15:00.000Z', + description: 'Latest product UI mockup', + mimeType: 'image/png', + }, + { + id: 'lib-003', + name: 'onboarding-demo.mp4', + type: 'video', + kind: 'file', + sizeBytes: 45_875_200, + createdAt: '2026-05-15T11:30:00.000Z', + description: 'Product onboarding walkthrough', + mimeType: 'video/mp4', + }, + { + id: 'lib-004', + name: 'Project Proposal.docx', + type: 'document', + kind: 'file', + sizeBytes: 524_288, + createdAt: '2026-04-20T16:45:00.000Z', + description: 'Client project proposal draft', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + id: 'lib-005', + name: 'api-client.ts', + type: 'code', + kind: 'artifact', + sizeBytes: 12_400, + createdAt: '2026-06-08T08:10:00.000Z', + description: 'Generated TypeScript API client', + conversationId: 'conv-abc123', + mimeType: 'text/typescript', + }, + { + id: 'lib-006', + name: 'sales-data-2026.xlsx', + type: 'spreadsheet', + kind: 'file', + sizeBytes: 892_160, + createdAt: '2026-05-02T13:00:00.000Z', + description: 'Annual sales data export', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + { + id: 'lib-007', + name: 'meeting-recording.mp3', + type: 'audio', + kind: 'file', + sizeBytes: 8_388_608, + createdAt: '2026-03-18T10:20:00.000Z', + description: 'Team standup recording', + mimeType: 'audio/mpeg', + }, + { + id: 'lib-008', + name: 'dashboard-component.tsx', + type: 'artifact', + kind: 'artifact', + sizeBytes: 18_750, + createdAt: '2026-06-09T17:45:00.000Z', + description: 'React dashboard component generated in chat', + conversationId: 'conv-def456', + mimeType: 'text/typescript', + }, + { + id: 'lib-009', + name: 'architecture-diagram.pdf', + type: 'pdf', + kind: 'file', + sizeBytes: 1_048_576, + createdAt: '2026-02-10T12:00:00.000Z', + description: 'System architecture overview', + mimeType: 'application/pdf', + }, + { + id: 'lib-010', + name: 'utils.py', + type: 'code', + kind: 'artifact', + sizeBytes: 6_200, + createdAt: '2026-06-07T14:30:00.000Z', + description: 'Python utility functions', + conversationId: 'conv-ghi789', + mimeType: 'text/x-python', + }, + { + id: 'lib-011', + name: 'team-photo.jpg', + type: 'image', + kind: 'file', + sizeBytes: 3_145_728, + createdAt: '2026-01-25T18:00:00.000Z', + description: 'Company team photo', + mimeType: 'image/jpeg', + }, + { + id: 'lib-012', + name: 'README.md', + type: 'document', + kind: 'artifact', + sizeBytes: 4_800, + createdAt: '2026-06-06T11:00:00.000Z', + description: 'Project documentation generated in chat', + conversationId: 'conv-jkl012', + mimeType: 'text/markdown', + }, +]; + +/** Mock storage quota — 64.2 MB used of 1 GB. */ +export const mockLibraryStorage: LibraryStorageInfo = { + usedBytes: 67_289_344, + totalBytes: 1_073_741_824, +}; + +/** Full mock API response shape. */ +export const mockLibraryResponse: LibraryListResponse = { + items: mockLibraryItems, + storage: mockLibraryStorage, +}; + +/** Empty library response for testing empty state. */ +export const mockEmptyLibraryResponse: LibraryListResponse = { + items: [], + storage: { usedBytes: 0, totalBytes: 1_073_741_824 }, +}; \ No newline at end of file From 80305a072d95c0322ba4ecb224fe41191a4ae85b Mon Sep 17 00:00:00 2001 From: manthansingh26 Date: Fri, 3 Jul 2026 09:26:52 +0000 Subject: [PATCH 4/8] fix: address maintainer feedback for icons and formatting --- .../src/components/Library/LibraryFilters.tsx | 25 ++++++--- .../components/Library/LibraryListItem.tsx | 53 ++++++++++--------- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx index af75885..5645d15 100644 --- a/client/src/components/Library/LibraryFilters.tsx +++ b/client/src/components/Library/LibraryFilters.tsx @@ -1,5 +1,13 @@ -import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; -import { Search, X } from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import { Search } from 'lucide-react'; +import { CrossIcon } from '@librechat/client'; import { Input, SelectDropDown } from '@librechat/client'; import type { LibraryFiltersState } from '~/types/library'; import { useDebounce } from '~/hooks'; @@ -43,10 +51,7 @@ export default function LibraryFilters({ onFiltersChange((prev) => ({ ...prev, search: '' })); }, [onFiltersChange]); - const selectedTypeOption = findLibraryOption( - LIBRARY_TYPE_FILTER_OPTIONS, - filters.typeFilter, - ); + const selectedTypeOption = findLibraryOption(LIBRARY_TYPE_FILTER_OPTIONS, filters.typeFilter); const selectedSortOption = findLibraryOption(LIBRARY_SORT_OPTIONS, filters.sortBy); @@ -76,9 +81,11 @@ export default function LibraryFilters({
+ {/* eslint-disable-next-line i18next/no-literal-string */} + {} -
@@ -130,9 +137,11 @@ export default function LibraryFilters({
+ {/* eslint-disable i18next/no-literal-string */}

Showing {resultCount} of {totalCount} {itemLabel}

+ {/* eslint-enable i18next/no-literal-string */}
); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx index 1475f68..7034c8b 100644 --- a/client/src/components/Library/LibraryListItem.tsx +++ b/client/src/components/Library/LibraryListItem.tsx @@ -1,35 +1,35 @@ -import type { ComponentType } from 'react'; +import { FileIcon, Sparkles } from '@librechat/client'; import { - FileAudio, - FileCode, - FileImage, - FileSpreadsheet, - FileText, - FileVideo, - Sparkles, -} from 'lucide-react'; + AudioPaths, + CodePaths, + FilePaths, + SheetPaths, + TextPaths, + VideoPaths, +} from '@librechat/client'; import type { LibraryItem, LibraryItemType } from '~/types/library'; import { cn } from '~/utils'; -import { - formatLibraryDate, - formatLibraryFileSize, - LIBRARY_TYPE_LABELS, -} from './libraryUtils'; +import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; interface LibraryListItemProps { item: LibraryItem; onClick?: (item: LibraryItem) => void; } -const TYPE_ICON_MAP: Record> = { - pdf: FileText, - image: FileImage, - video: FileVideo, - document: FileText, - code: FileCode, - spreadsheet: FileSpreadsheet, - audio: FileAudio, - artifact: Sparkles, +interface LibraryFileType { + fill: string; + paths: React.FC; + title: string; +} + +const TYPE_FILE_TYPE_MAP: Record, LibraryFileType> = { + pdf: { paths: TextPaths, fill: '#EF4444', title: 'PDF' }, + image: { paths: FilePaths, fill: '#EC4899', title: 'Image' }, + video: { paths: VideoPaths, fill: '#A855F7', title: 'Video' }, + document: { paths: TextPaths, fill: '#3B82F6', title: 'Document' }, + code: { paths: CodePaths, fill: '#F97316', title: 'Code' }, + spreadsheet: { paths: SheetPaths, fill: '#10B981', title: 'Spreadsheet' }, + audio: { paths: AudioPaths, fill: '#F59E0B', title: 'Audio' }, }; const TYPE_COLOR_MAP: Record = { @@ -47,7 +47,8 @@ const itemRowClassName = 'flex w-full items-center gap-3 rounded-lg border border-border-light bg-surface-primary px-4 py-3 text-left transition-colors'; export default function LibraryListItem({ item, onClick }: LibraryListItemProps) { - const Icon = TYPE_ICON_MAP[item.type]; + const fileType = TYPE_FILE_TYPE_MAP[item.type]; + const isArtifact = item.type === 'artifact'; const iconColor = TYPE_COLOR_MAP[item.type]; const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; @@ -60,7 +61,7 @@ export default function LibraryListItem({ item, onClick }: LibraryListItemProps) )} aria-hidden="true" > - + {isArtifact ? : }
@@ -108,4 +109,4 @@ export default function LibraryListItem({ item, onClick }: LibraryListItemProps) {content}
); -} \ No newline at end of file +} From 16788867aa37c73a5ac1be1ddc7a94867405083b Mon Sep 17 00:00:00 2001 From: manthansingh26 Date: Sun, 5 Jul 2026 22:42:54 +0530 Subject: [PATCH 5/8] feat: add dense/preview view toggle with type-aware previews, clamp storage usage to 0, remove stray JSX, add interaction tests --- .../src/components/Library/LibraryFilters.tsx | 9 +- .../components/Library/LibraryItemActions.tsx | 63 +++++++ client/src/components/Library/LibraryList.tsx | 15 ++ .../components/Library/LibraryListItem.tsx | 72 ++----- .../components/Library/LibraryPreviewCard.tsx | 177 ++++++++++++++++++ .../components/Library/LibraryViewToggle.tsx | 55 ++++++ .../Library/__tests__/LibraryList.test.tsx | 105 ++++++++++- .../src/components/Library/libraryTypeMeta.ts | 42 +++++ client/src/components/Library/libraryUtils.ts | 2 +- client/src/types/library.ts | 7 + 10 files changed, 490 insertions(+), 57 deletions(-) create mode 100644 client/src/components/Library/LibraryItemActions.tsx create mode 100644 client/src/components/Library/LibraryPreviewCard.tsx create mode 100644 client/src/components/Library/LibraryViewToggle.tsx create mode 100644 client/src/components/Library/libraryTypeMeta.ts diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx index 5645d15..0165de3 100644 --- a/client/src/components/Library/LibraryFilters.tsx +++ b/client/src/components/Library/LibraryFilters.tsx @@ -9,7 +9,7 @@ import { import { Search } from 'lucide-react'; import { CrossIcon } from '@librechat/client'; import { Input, SelectDropDown } from '@librechat/client'; -import type { LibraryFiltersState } from '~/types/library'; +import type { LibraryFiltersState, LibraryViewMode } from '~/types/library'; import { useDebounce } from '~/hooks'; import { createDropdownSetter } from '~/utils'; import { @@ -19,12 +19,15 @@ import { parseLibrarySortOption, parseLibraryTypeFilter, } from './libraryUtils'; +import LibraryViewToggle from './LibraryViewToggle'; interface LibraryFiltersProps { filters: LibraryFiltersState; onFiltersChange: Dispatch>; resultCount: number; totalCount: number; + viewMode: LibraryViewMode; + onViewModeChange: (mode: LibraryViewMode) => void; } export default function LibraryFilters({ @@ -32,6 +35,8 @@ export default function LibraryFilters({ onFiltersChange, resultCount, totalCount, + viewMode, + onViewModeChange, }: LibraryFiltersProps) { const [searchInput, setSearchInput] = useState(filters.search); const debouncedSearch = useDebounce(searchInput, 300); @@ -85,7 +90,6 @@ export default function LibraryFilters({ - {} + diff --git a/client/src/components/Library/LibraryItemActions.tsx b/client/src/components/Library/LibraryItemActions.tsx new file mode 100644 index 0000000..c5da7b7 --- /dev/null +++ b/client/src/components/Library/LibraryItemActions.tsx @@ -0,0 +1,63 @@ +import { Download, ExternalLink, MoreVertical, Share2 } from 'lucide-react'; +import { cn } from '~/utils'; + +interface LibraryItemActionsProps { + onOpen?: () => void; + onDownload?: () => void; + onShare?: () => void; + onMore?: () => void; + className?: string; +} + +export default function LibraryItemActions({ + onOpen, + onDownload, + onShare, + onMore, + className, +}: LibraryItemActionsProps) { + return ( +
event.stopPropagation()} + role="group" + aria-label="Quick actions" + > + + + + +
+ ); +} diff --git a/client/src/components/Library/LibraryList.tsx b/client/src/components/Library/LibraryList.tsx index 2058c2c..757f2bb 100644 --- a/client/src/components/Library/LibraryList.tsx +++ b/client/src/components/Library/LibraryList.tsx @@ -4,12 +4,14 @@ import type { LibraryItem, LibraryListStatus, LibraryStorageInfo, + LibraryViewMode, } from '~/types/library'; import LibraryEmptyState from './LibraryEmptyState'; import LibraryErrorState from './LibraryErrorState'; import LibraryFilters from './LibraryFilters'; import LibraryListItem from './LibraryListItem'; import LibraryLoadingState from './LibraryLoadingState'; +import LibraryPreviewCard from './LibraryPreviewCard'; import LibraryStorageBar from './LibraryStorageBar'; import { applyLibraryFilters } from './libraryUtils'; @@ -39,6 +41,7 @@ export default function LibraryList({ className = '', }: LibraryListProps) { const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [viewMode, setViewMode] = useState('dense'); const filteredItems = useMemo( () => (status === 'populated' ? applyLibraryFilters(items, filters) : []), @@ -88,10 +91,22 @@ export default function LibraryList({ onFiltersChange={setFilters} resultCount={filteredItems.length} totalCount={items.length} + viewMode={viewMode} + onViewModeChange={setViewMode} /> {filteredItems.length === 0 ? ( + ) : viewMode === 'preview' ? ( +
+ {filteredItems.map((item) => ( + + ))} +
) : (
{filteredItems.map((item) => ( diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx index 7034c8b..297c98f 100644 --- a/client/src/components/Library/LibraryListItem.tsx +++ b/client/src/components/Library/LibraryListItem.tsx @@ -1,53 +1,20 @@ import { FileIcon, Sparkles } from '@librechat/client'; -import { - AudioPaths, - CodePaths, - FilePaths, - SheetPaths, - TextPaths, - VideoPaths, -} from '@librechat/client'; -import type { LibraryItem, LibraryItemType } from '~/types/library'; +import type { LibraryItem } from '~/types/library'; import { cn } from '~/utils'; import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; interface LibraryListItemProps { item: LibraryItem; onClick?: (item: LibraryItem) => void; } -interface LibraryFileType { - fill: string; - paths: React.FC; - title: string; -} - -const TYPE_FILE_TYPE_MAP: Record, LibraryFileType> = { - pdf: { paths: TextPaths, fill: '#EF4444', title: 'PDF' }, - image: { paths: FilePaths, fill: '#EC4899', title: 'Image' }, - video: { paths: VideoPaths, fill: '#A855F7', title: 'Video' }, - document: { paths: TextPaths, fill: '#3B82F6', title: 'Document' }, - code: { paths: CodePaths, fill: '#F97316', title: 'Code' }, - spreadsheet: { paths: SheetPaths, fill: '#10B981', title: 'Spreadsheet' }, - audio: { paths: AudioPaths, fill: '#F59E0B', title: 'Audio' }, -}; - -const TYPE_COLOR_MAP: Record = { - pdf: 'text-red-500', - image: 'text-pink-500', - video: 'text-purple-500', - document: 'text-blue-500', - code: 'text-orange-500', - spreadsheet: 'text-emerald-500', - audio: 'text-amber-500', - artifact: 'text-violet-500', -}; - const itemRowClassName = 'flex w-full items-center gap-3 rounded-lg border border-border-light bg-surface-primary px-4 py-3 text-left transition-colors'; export default function LibraryListItem({ item, onClick }: LibraryListItemProps) { - const fileType = TYPE_FILE_TYPE_MAP[item.type]; + const fileType = getLibraryFileType(item.type); const isArtifact = item.type === 'artifact'; const iconColor = TYPE_COLOR_MAP[item.type]; const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; @@ -86,27 +53,28 @@ export default function LibraryListItem({ item, onClick }: LibraryListItemProps) ); - if (onClick) { - return ( -
+ return ( +
+ {onClick ? ( -
- ); - } - - return ( -
- {content} + ) : ( +
{content}
+ )} +
); } diff --git a/client/src/components/Library/LibraryPreviewCard.tsx b/client/src/components/Library/LibraryPreviewCard.tsx new file mode 100644 index 0000000..45ba5ff --- /dev/null +++ b/client/src/components/Library/LibraryPreviewCard.tsx @@ -0,0 +1,177 @@ +import { FileIcon, Sparkles } from '@librechat/client'; +import type { LibraryItem } from '~/types/library'; +import { cn } from '~/utils'; +import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; +import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; +import LibraryItemActions from './LibraryItemActions'; + +interface LibraryPreviewCardProps { + item: LibraryItem; + onClick?: (item: LibraryItem) => void; +} + +function isPresentation(item: LibraryItem): boolean { + return item.name.toLowerCase().endsWith('.pptx') || item.mimeType?.includes('presentation') === true; +} + +function isWordOrMarkdown(item: LibraryItem): boolean { + const lower = item.name.toLowerCase(); + return lower.endsWith('.docx') || lower.endsWith('.md') || item.mimeType?.includes('wordprocessing') === true; +} + +function DeckPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function DocumentPreview() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +function SpreadsheetPreview() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ {Array.from({ length: 4 }).map((_, row) => ( +
+ {Array.from({ length: 4 }).map((_, col) => ( +
+ ))} +
+ ))} +
+ ); +} + +function CodePreview() { + const bars = [ + 'w-3/4 bg-emerald-600', + 'w-1/2 bg-blue-500', + 'w-5/6 bg-amber-500', + 'w-2/3 bg-emerald-600', + 'w-3/4 bg-blue-500', + ]; + return ( +
+ {bars.map((bar, i) => ( +
+ ))} +
+ ); +} + +function PreviewArea({ item }: { item: LibraryItem }) { + if (item.type === 'pdf' || isWordOrMarkdown(item)) { + return ; + } + if (item.type === 'spreadsheet') { + return ; + } + if (item.type === 'code' || (item.mimeType && item.mimeType.includes('html'))) { + return ; + } + if (isPresentation(item)) { + return ; + } + + const fileType = getLibraryFileType(item.type); + const iconColor = TYPE_COLOR_MAP[item.type]; + const isArtifact = item.type === 'artifact'; + + return ( + + ); +} + +export default function LibraryPreviewCard({ item, onClick }: LibraryPreviewCardProps) { + const fileType = getLibraryFileType(item.type); + const isArtifact = item.type === 'artifact'; + const iconColor = TYPE_COLOR_MAP[item.type]; + const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; + + const sourceLabel = item.conversationId ? `Chat ${item.conversationId}` : item.kind; + + return ( +
+ + +
+
+ +

+ {item.name} +

+
+ +
+ + {LIBRARY_TYPE_LABELS[item.type]} + + {sourceLabel} +
+ +
+ + + {formatLibraryFileSize(item.sizeBytes)} +
+ +
+ Quick actions + +
+
+
+ ); +} diff --git a/client/src/components/Library/LibraryViewToggle.tsx b/client/src/components/Library/LibraryViewToggle.tsx new file mode 100644 index 0000000..f46bf8c --- /dev/null +++ b/client/src/components/Library/LibraryViewToggle.tsx @@ -0,0 +1,55 @@ +import { LayoutGrid, List } from 'lucide-react'; +import type { LibraryViewMode } from '~/types/library'; +import { cn } from '~/utils'; + +interface LibraryViewToggleProps { + viewMode: LibraryViewMode; + onViewModeChange: (mode: LibraryViewMode) => void; + className?: string; +} + +export default function LibraryViewToggle({ + viewMode, + onViewModeChange, + className, +}: LibraryViewToggleProps) { + return ( +
+ + +
+ ); +} diff --git a/client/src/components/Library/__tests__/LibraryList.test.tsx b/client/src/components/Library/__tests__/LibraryList.test.tsx index b5e8b95..7cc6351 100644 --- a/client/src/components/Library/__tests__/LibraryList.test.tsx +++ b/client/src/components/Library/__tests__/LibraryList.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { mockLibraryItems, mockLibraryStorage } from '~/data/mockLibraryData'; import LibraryList from '../LibraryList'; @@ -48,4 +49,104 @@ describe('LibraryList', () => { expect(screen.getByText(`Showing ${mockLibraryItems.length} of ${mockLibraryItems.length} items`)).toBeInTheDocument(); expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); }); -}); \ No newline at end of file + + it('filters items by search query', async () => { + render( + , + ); + + const searchInput = screen.getByRole('searchbox', { name: 'Search library' }); + await userEvent.type(searchInput, 'architecture'); + + expect(screen.getByText('architecture-diagram.pdf')).toBeInTheDocument(); + expect(screen.queryByText('Q4 Financial Report.pdf')).not.toBeInTheDocument(); + expect(screen.getByText(/Showing 1 of \d+ items/)).toBeInTheDocument(); + }); + + it('filters items by type', async () => { + render( + , + ); + + const typeDropdown = screen.getByRole('button', { name: /Filter by type/i }); + await userEvent.click(typeDropdown); + + const pdfOption = await screen.findByRole('option', { name: 'PDF' }); + await userEvent.click(pdfOption); + + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + expect(screen.getByText('architecture-diagram.pdf')).toBeInTheDocument(); + expect(screen.queryByText('product-mockup-v3.png')).not.toBeInTheDocument(); + }); + + it('sorts items by name', async () => { + render( + , + ); + + const sortDropdown = screen.getByRole('button', { name: /Sort library items/i }); + await userEvent.click(sortDropdown); + + const nameOption = await screen.findByRole('option', { name: 'Name (A–Z)' }); + await userEvent.click(nameOption); + + const list = screen.getByRole('list', { name: 'Library items' }); + const firstItem = within(list).getAllByRole('listitem')[0]; + expect(firstItem).toHaveTextContent('README.md'); + }); + + it('toggles between dense and preview views', async () => { + render( + , + ); + + const list = screen.getByRole('list', { name: 'Library items' }); + expect(list).toHaveClass('flex-col'); + + const previewButton = screen.getByRole('button', { name: 'Preview grid view' }); + await userEvent.click(previewButton); + + const grid = screen.getByRole('list', { name: 'Library items' }); + expect(grid).toHaveClass('grid'); + expect(screen.getByText('Q4 Financial Report.pdf')).toBeInTheDocument(); + + const denseButton = screen.getByRole('button', { name: 'Dense list view' }); + await userEvent.click(denseButton); + + expect(screen.getByRole('list', { name: 'Library items' })).toHaveClass('flex-col'); + }); + + it('renders quick actions in dense view', () => { + render( + , + ); + + const list = screen.getByRole('list', { name: 'Library items' }); + const firstItem = within(list).getAllByRole('listitem')[0]; + + expect(within(firstItem).getByRole('button', { name: 'Open' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'Download' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'Share' })).toBeInTheDocument(); + expect(within(firstItem).getByRole('button', { name: 'More options' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Library/libraryTypeMeta.ts b/client/src/components/Library/libraryTypeMeta.ts new file mode 100644 index 0000000..d491984 --- /dev/null +++ b/client/src/components/Library/libraryTypeMeta.ts @@ -0,0 +1,42 @@ +import { + AudioPaths, + CodePaths, + FilePaths, + SheetPaths, + TextPaths, + VideoPaths, +} from '@librechat/client'; +import type { LibraryItemType } from '~/types/library'; + +export interface LibraryFileType { + fill: string; + paths: React.FC; + title: string; +} + +export const TYPE_FILE_TYPE_MAP: Record, LibraryFileType> = { + pdf: { paths: TextPaths, fill: '#EF4444', title: 'PDF' }, + image: { paths: FilePaths, fill: '#EC4899', title: 'Image' }, + video: { paths: VideoPaths, fill: '#A855F7', title: 'Video' }, + document: { paths: TextPaths, fill: '#3B82F6', title: 'Document' }, + code: { paths: CodePaths, fill: '#F97316', title: 'Code' }, + spreadsheet: { paths: SheetPaths, fill: '#10B981', title: 'Spreadsheet' }, + audio: { paths: AudioPaths, fill: '#F59E0B', title: 'Audio' }, +}; + +export const TYPE_COLOR_MAP: Record = { + pdf: 'text-red-500', + image: 'text-pink-500', + video: 'text-purple-500', + document: 'text-blue-500', + code: 'text-orange-500', + spreadsheet: 'text-emerald-500', + audio: 'text-amber-500', + artifact: 'text-violet-500', +}; + +export function getLibraryFileType( + type: LibraryItemType, +): LibraryFileType | null { + return type === 'artifact' ? null : TYPE_FILE_TYPE_MAP[type]; +} diff --git a/client/src/components/Library/libraryUtils.ts b/client/src/components/Library/libraryUtils.ts index b7bf004..0b284c5 100644 --- a/client/src/components/Library/libraryUtils.ts +++ b/client/src/components/Library/libraryUtils.ts @@ -103,7 +103,7 @@ export function getStorageUsagePercent(usedBytes: number, totalBytes: number): n if (totalBytes <= 0) { return 0; } - return Math.min(100, Math.round((usedBytes / totalBytes) * 100)); + return Math.max(0, Math.min(100, Math.round((usedBytes / totalBytes) * 100))); } /** Filters items by case-insensitive search across name and description. */ diff --git a/client/src/types/library.ts b/client/src/types/library.ts index e03e56e..743a53b 100644 --- a/client/src/types/library.ts +++ b/client/src/types/library.ts @@ -15,6 +15,9 @@ export type LibraryItemKind = 'file' | 'artifact'; /** Available sort options for the library list. */ export type LibrarySortOption = 'newest' | 'oldest' | 'name' | 'size'; +/** View modes for the library artifact list. */ +export type LibraryViewMode = 'dense' | 'preview'; + /** UI state for the library list container. */ export type LibraryListStatus = 'loading' | 'empty' | 'populated' | 'error'; @@ -45,6 +48,10 @@ export interface LibraryFiltersState { sortBy: LibrarySortOption; } +export interface LibraryViewState { + viewMode: LibraryViewMode; +} + export interface LibraryListResponse { items: LibraryItem[]; storage: LibraryStorageInfo | null; From 2b23bf0f89c58128bdc613242c1991d04424364f Mon Sep 17 00:00:00 2001 From: Shrey Gupta Date: Tue, 21 Jul 2026 22:29:19 +0530 Subject: [PATCH 6/8] fix: complete Library interaction semantics --- .../src/components/Library/LibraryFilters.tsx | 2 + .../components/Library/LibraryItemActions.tsx | 76 +++++++++++-------- client/src/components/Library/LibraryList.tsx | 26 ++++++- .../components/Library/LibraryListItem.tsx | 19 ++++- .../components/Library/LibraryPreviewCard.tsx | 40 +++++++--- .../components/Library/LibraryViewToggle.tsx | 2 +- .../Library/__tests__/LibraryList.test.tsx | 47 ++++++++++-- 7 files changed, 156 insertions(+), 56 deletions(-) diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx index 0165de3..852bd80 100644 --- a/client/src/components/Library/LibraryFilters.tsx +++ b/client/src/components/Library/LibraryFilters.tsx @@ -120,6 +120,7 @@ export default function LibraryFilters({
- - - - + {onOpen && ( + + )} + {onDownload && ( + + )} + {onShare && ( + + )} + {onMore && ( + + )}
); } diff --git a/client/src/components/Library/LibraryList.tsx b/client/src/components/Library/LibraryList.tsx index 757f2bb..417c8c9 100644 --- a/client/src/components/Library/LibraryList.tsx +++ b/client/src/components/Library/LibraryList.tsx @@ -28,6 +28,9 @@ interface LibraryListProps { errorMessage?: string; onRetry?: () => void; onItemClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; className?: string; } @@ -38,6 +41,9 @@ export default function LibraryList({ errorMessage, onRetry, onItemClick, + onDownload, + onShare, + onMore, className = '', }: LibraryListProps) { const [filters, setFilters] = useState(DEFAULT_FILTERS); @@ -104,16 +110,30 @@ export default function LibraryList({ aria-label="Library items" > {filteredItems.map((item) => ( - + ))}
) : (
{filteredItems.map((item) => ( - + ))}
)}
); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryListItem.tsx b/client/src/components/Library/LibraryListItem.tsx index 297c98f..da8720e 100644 --- a/client/src/components/Library/LibraryListItem.tsx +++ b/client/src/components/Library/LibraryListItem.tsx @@ -8,12 +8,21 @@ import LibraryItemActions from './LibraryItemActions'; interface LibraryListItemProps { item: LibraryItem; onClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; } const itemRowClassName = 'flex w-full items-center gap-3 rounded-lg border border-border-light bg-surface-primary px-4 py-3 text-left transition-colors'; -export default function LibraryListItem({ item, onClick }: LibraryListItemProps) { +export default function LibraryListItem({ + item, + onClick, + onDownload, + onShare, + onMore, +}: LibraryListItemProps) { const fileType = getLibraryFileType(item.type); const isArtifact = item.type === 'artifact'; const iconColor = TYPE_COLOR_MAP[item.type]; @@ -74,7 +83,13 @@ export default function LibraryListItem({ item, onClick }: LibraryListItemProps) ) : (
{content}
)} - + onClick(item) : undefined} + onDownload={onDownload ? () => onDownload(item) : undefined} + onShare={onShare ? () => onShare(item) : undefined} + onMore={onMore ? () => onMore(item) : undefined} + className="hidden shrink-0 sm:flex" + />
); } diff --git a/client/src/components/Library/LibraryPreviewCard.tsx b/client/src/components/Library/LibraryPreviewCard.tsx index 45ba5ff..4a15994 100644 --- a/client/src/components/Library/LibraryPreviewCard.tsx +++ b/client/src/components/Library/LibraryPreviewCard.tsx @@ -8,6 +8,9 @@ import LibraryItemActions from './LibraryItemActions'; interface LibraryPreviewCardProps { item: LibraryItem; onClick?: (item: LibraryItem) => void; + onDownload?: (item: LibraryItem) => void; + onShare?: (item: LibraryItem) => void; + onMore?: (item: LibraryItem) => void; } function isPresentation(item: LibraryItem): boolean { @@ -118,7 +121,13 @@ function PreviewArea({ item }: { item: LibraryItem }) { ); } -export default function LibraryPreviewCard({ item, onClick }: LibraryPreviewCardProps) { +export default function LibraryPreviewCard({ + item, + onClick, + onDownload, + onShare, + onMore, +}: LibraryPreviewCardProps) { const fileType = getLibraryFileType(item.type); const isArtifact = item.type === 'artifact'; const iconColor = TYPE_COLOR_MAP[item.type]; @@ -135,14 +144,20 @@ export default function LibraryPreviewCard({ item, onClick }: LibraryPreviewCard )} aria-label={ariaLabel} > - + {onClick ? ( + + ) : ( +
+ +
+ )}
@@ -169,7 +184,12 @@ export default function LibraryPreviewCard({ item, onClick }: LibraryPreviewCard
Quick actions - + onClick(item) : undefined} + onDownload={onDownload ? () => onDownload(item) : undefined} + onShare={onShare ? () => onShare(item) : undefined} + onMore={onMore ? () => onMore(item) : undefined} + />
diff --git a/client/src/components/Library/LibraryViewToggle.tsx b/client/src/components/Library/LibraryViewToggle.tsx index f46bf8c..61cf4d5 100644 --- a/client/src/components/Library/LibraryViewToggle.tsx +++ b/client/src/components/Library/LibraryViewToggle.tsx @@ -19,7 +19,7 @@ export default function LibraryViewToggle({ 'inline-flex items-center rounded-lg border border-border-light bg-surface-primary p-0.5', className, )} - role="radiogroup" + role="group" aria-label="Library view" > )} @@ -58,8 +61,8 @@ export default function LibraryEmptyState({ return ( ); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryErrorState.tsx b/client/src/components/Library/LibraryErrorState.tsx index 434bc68..189c381 100644 --- a/client/src/components/Library/LibraryErrorState.tsx +++ b/client/src/components/Library/LibraryErrorState.tsx @@ -1,15 +1,16 @@ import { AlertCircle } from 'lucide-react'; import { Button } from '@librechat/client'; +import { useLocalize } from '~/hooks'; interface LibraryErrorStateProps { message?: string; onRetry?: () => void; } -export default function LibraryErrorState({ - message = 'Something went wrong while loading your library.', - onRetry, -}: LibraryErrorStateProps) { +export default function LibraryErrorState({ message, onRetry }: LibraryErrorStateProps) { + const localize = useLocalize(); + const displayMessage = message ?? localize('com_ui_library_error_default'); + return (
-

Unable to load library

-

{message}

+

+ {localize('com_ui_library_error_title')} +

+

{displayMessage}

{onRetry && ( )}
); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryFilters.tsx b/client/src/components/Library/LibraryFilters.tsx index 852bd80..bceee45 100644 --- a/client/src/components/Library/LibraryFilters.tsx +++ b/client/src/components/Library/LibraryFilters.tsx @@ -10,12 +10,12 @@ import { Search } from 'lucide-react'; import { CrossIcon } from '@librechat/client'; import { Input, SelectDropDown } from '@librechat/client'; import type { LibraryFiltersState, LibraryViewMode } from '~/types/library'; -import { useDebounce } from '~/hooks'; +import { useDebounce, useLocalize } from '~/hooks'; import { createDropdownSetter } from '~/utils'; import { findLibraryOption, - LIBRARY_SORT_OPTIONS, - LIBRARY_TYPE_FILTER_OPTIONS, + createLibrarySortOptions, + createLibraryTypeFilterOptions, parseLibrarySortOption, parseLibraryTypeFilter, } from './libraryUtils'; @@ -38,6 +38,7 @@ export default function LibraryFilters({ viewMode, onViewModeChange, }: LibraryFiltersProps) { + const localize = useLocalize(); const [searchInput, setSearchInput] = useState(filters.search); const debouncedSearch = useDebounce(searchInput, 300); @@ -56,9 +57,12 @@ export default function LibraryFilters({ onFiltersChange((prev) => ({ ...prev, search: '' })); }, [onFiltersChange]); - const selectedTypeOption = findLibraryOption(LIBRARY_TYPE_FILTER_OPTIONS, filters.typeFilter); + const typeFilterOptions = useMemo(() => createLibraryTypeFilterOptions(localize), [localize]); + const sortOptions = useMemo(() => createLibrarySortOptions(localize), [localize]); - const selectedSortOption = findLibraryOption(LIBRARY_SORT_OPTIONS, filters.sortBy); + const selectedTypeOption = findLibraryOption(typeFilterOptions, filters.typeFilter); + + const selectedSortOption = findLibraryOption(sortOptions, filters.sortBy); const handleTypeChange = useMemo( () => @@ -80,24 +84,21 @@ export default function LibraryFilters({ [onFiltersChange], ); - const itemLabel = totalCount === 1 ? 'item' : 'items'; - return (
- {/* eslint-disable-next-line i18next/no-literal-string */} setSearchInput(event.target.value)} - placeholder="Search files and artifacts..." + placeholder={localize('com_ui_library_search_placeholder')} className="h-10 rounded-lg border-border-medium bg-transparent pl-10 pr-10 text-sm text-text-primary placeholder:text-text-secondary focus:ring-0" - aria-label="Search library" + aria-label={localize('com_ui_library_search')} autoComplete="off" spellCheck={false} /> @@ -110,7 +111,7 @@ export default function LibraryFilters({ type="button" onClick={handleClearSearch} className="absolute right-3 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Clear search" + aria-label={localize('com_ui_clear_search')} >
-
+
-
+
- {/* eslint-disable i18next/no-literal-string */}

- Showing {resultCount} of {totalCount} {itemLabel} + {localize( + totalCount === 1 ? 'com_ui_library_items_count_one' : 'com_ui_library_items_count_other', + { resultCount, totalCount }, + )}

- {/* eslint-enable i18next/no-literal-string */}
); } diff --git a/client/src/components/Library/LibraryItemActions.tsx b/client/src/components/Library/LibraryItemActions.tsx index e8cfef8..f2f8298 100644 --- a/client/src/components/Library/LibraryItemActions.tsx +++ b/client/src/components/Library/LibraryItemActions.tsx @@ -1,4 +1,5 @@ import { Download, ExternalLink, MoreVertical, Share2 } from 'lucide-react'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; interface LibraryItemActionsProps { @@ -16,26 +17,25 @@ export default function LibraryItemActions({ onMore, className, }: LibraryItemActionsProps) { + const localize = useLocalize(); + if (!onOpen && !onDownload && !onShare && !onMore) { return null; } return (
event.stopPropagation()} role="group" - aria-label="Quick actions" + aria-label={localize('com_ui_library_quick_actions')} > {onOpen && ( @@ -45,7 +45,7 @@ export default function LibraryItemActions({ type="button" onClick={onDownload} className="inline-flex size-7 items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-surface-tertiary hover:text-green-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label="Download" + aria-label={localize('com_ui_download')} >
- - {LIBRARY_TYPE_LABELS[item.type]} - - {item.kind} + {typeLabel} + {itemKind}
@@ -68,7 +76,7 @@ export default function LibraryListItem({ className={cn( itemRowClassName, onClick && - 'cursor-pointer hover:border-border-medium hover:bg-surface-hover focus-within:outline-none focus-within:ring-2 focus-within:ring-ring', + 'cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-ring hover:border-border-medium hover:bg-surface-hover', )} aria-label={ariaLabel} > diff --git a/client/src/components/Library/LibraryListWithMockData.tsx b/client/src/components/Library/LibraryListWithMockData.tsx index 1a08a7c..09e17b5 100644 --- a/client/src/components/Library/LibraryListWithMockData.tsx +++ b/client/src/components/Library/LibraryListWithMockData.tsx @@ -26,4 +26,4 @@ export default function LibraryListWithMockData({ className={className} /> ); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryLoadingState.tsx b/client/src/components/Library/LibraryLoadingState.tsx index e9d81ad..248ddb5 100644 --- a/client/src/components/Library/LibraryLoadingState.tsx +++ b/client/src/components/Library/LibraryLoadingState.tsx @@ -1,14 +1,17 @@ import { Skeleton } from '@librechat/client'; +import { useLocalize } from '~/hooks'; const SKELETON_ROWS = 6; export default function LibraryLoadingState() { + const localize = useLocalize(); + return (
@@ -25,4 +28,4 @@ export default function LibraryLoadingState() {
); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryPreviewCard.tsx b/client/src/components/Library/LibraryPreviewCard.tsx index 15406ad..3d20261 100644 --- a/client/src/components/Library/LibraryPreviewCard.tsx +++ b/client/src/components/Library/LibraryPreviewCard.tsx @@ -1,7 +1,9 @@ import { FileIcon, Sparkles } from '@librechat/client'; +import type { LocalizeFunction } from '~/common'; import type { LibraryItem } from '~/types/library'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -import { formatLibraryDate, formatLibraryFileSize, LIBRARY_TYPE_LABELS } from './libraryUtils'; +import { formatLibraryDate, formatLibraryFileSize, getLibraryTypeLabel } from './libraryUtils'; import { getLibraryFileType, TYPE_COLOR_MAP } from './libraryTypeMeta'; import LibraryItemActions from './LibraryItemActions'; @@ -15,8 +17,7 @@ interface LibraryPreviewCardProps { function isPresentation(item: LibraryItem): boolean { return ( - item.name.toLowerCase().endsWith('.pptx') || - item.contentType?.includes('presentation') === true + item.name.toLowerCase().endsWith('.pptx') || item.contentType?.includes('presentation') === true ); } @@ -49,7 +50,10 @@ function DeckPreview() { function DocumentPreview() { return (
-
+
@@ -66,7 +70,7 @@ function SpreadsheetPreview() {
{Array.from({ length: 4 }).map((_, i) => ( -
+
))}
{Array.from({ length: 4 }).map((_, row) => ( @@ -103,7 +107,7 @@ function CodePreview() { ); } -function PreviewArea({ item }: { item: LibraryItem }) { +function PreviewArea({ item, localize }: { item: LibraryItem; localize: LocalizeFunction }) { if (item.type === 'pdf' || isWordOrMarkdown(item)) { return ; } @@ -117,13 +121,17 @@ function PreviewArea({ item }: { item: LibraryItem }) { return ; } - const fileType = getLibraryFileType(item.type); + const fileType = getLibraryFileType(item.type, localize); const iconColor = TYPE_COLOR_MAP[item.type]; const isArtifact = item.type === 'artifact'; return ( ); } @@ -135,12 +143,24 @@ export default function LibraryPreviewCard({ onShare, onMore, }: LibraryPreviewCardProps) { - const fileType = getLibraryFileType(item.type); + const localize = useLocalize(); + const hasActions = Boolean(onClick || onDownload || onShare || onMore); + const fileType = getLibraryFileType(item.type, localize); const isArtifact = item.type === 'artifact'; const iconColor = TYPE_COLOR_MAP[item.type]; - const ariaLabel = `${item.name}, ${LIBRARY_TYPE_LABELS[item.type]}, ${formatLibraryFileSize(item.sizeBytes)}`; + const typeLabel = getLibraryTypeLabel(item.type, localize); + const itemKind = localize( + item.kind === 'artifact' ? 'com_ui_library_type_artifact' : 'com_ui_library_type_file', + ); + const ariaLabel = localize('com_ui_library_item_label', { + name: item.name, + type: typeLabel, + size: formatLibraryFileSize(item.sizeBytes), + }); - const sourceLabel = item.conversationId ? `Chat ${item.conversationId}` : item.kind; + const sourceLabel = item.conversationId + ? localize('com_ui_library_chat_source', { chatId: item.conversationId }) + : itemKind; return (
onClick(item)} className="flex h-32 items-center justify-center bg-amber-50/80 p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label={`Open ${item.name}`} + aria-label={localize('com_ui_library_open_item', { name: item.name })} > - + ) : (
- +
)}
-

+

{item.name}

- {LIBRARY_TYPE_LABELS[item.type]} + {typeLabel} - {sourceLabel} + {sourceLabel}
@@ -189,15 +216,19 @@ export default function LibraryPreviewCard({ {formatLibraryFileSize(item.sizeBytes)}
-
- Quick actions - onClick(item) : undefined} - onDownload={onDownload ? () => onDownload(item) : undefined} - onShare={onShare ? () => onShare(item) : undefined} - onMore={onMore ? () => onMore(item) : undefined} - /> -
+ {hasActions && ( +
+ + {localize('com_ui_library_quick_actions')} + + onClick(item) : undefined} + onDownload={onDownload ? () => onDownload(item) : undefined} + onShare={onShare ? () => onShare(item) : undefined} + onMore={onMore ? () => onMore(item) : undefined} + /> +
+ )}
); diff --git a/client/src/components/Library/LibraryStorageBar.tsx b/client/src/components/Library/LibraryStorageBar.tsx index bfa054e..9c0ffae 100644 --- a/client/src/components/Library/LibraryStorageBar.tsx +++ b/client/src/components/Library/LibraryStorageBar.tsx @@ -1,32 +1,32 @@ import { Progress } from '@librechat/client'; import type { LibraryStorageInfo } from '~/types/library'; -import { - formatLibraryFileSize, - getStorageUsagePercent, -} from './libraryUtils'; +import { useLocalize } from '~/hooks'; +import { formatLibraryFileSize, getStorageUsagePercent } from './libraryUtils'; interface LibraryStorageBarProps { storage: LibraryStorageInfo; } export default function LibraryStorageBar({ storage }: LibraryStorageBarProps) { + const localize = useLocalize(); const percent = getStorageUsagePercent(storage.usedBytes, storage.totalBytes); + const used = formatLibraryFileSize(storage.usedBytes); + const total = formatLibraryFileSize(storage.totalBytes); return ( -
+
- Storage + {localize('com_ui_library_storage')} - {formatLibraryFileSize(storage.usedBytes)} of {formatLibraryFileSize(storage.totalBytes)}{' '} - used + {localize('com_ui_library_storage_usage', { used, total })}

{percent}%

); -} \ No newline at end of file +} diff --git a/client/src/components/Library/LibraryViewToggle.tsx b/client/src/components/Library/LibraryViewToggle.tsx index 61cf4d5..ea5a905 100644 --- a/client/src/components/Library/LibraryViewToggle.tsx +++ b/client/src/components/Library/LibraryViewToggle.tsx @@ -1,5 +1,6 @@ import { LayoutGrid, List } from 'lucide-react'; import type { LibraryViewMode } from '~/types/library'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; interface LibraryViewToggleProps { @@ -13,6 +14,8 @@ export default function LibraryViewToggle({ onViewModeChange, className, }: LibraryViewToggleProps) { + const localize = useLocalize(); + return (