mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🩹 fix: Stop Archived and Shared Chats Dialogs Crashing on Open (#14886)
* fix: stop the virtualized data table looping on render Opening Archived chats or Shared chats with 50 or more rows threw "Too many re-renders". DataTable passed an inline getItemKey to useVirtualizer, and virtual-core lists that option among the deps of its getMeasurementOptions memo, whose onChange notifies. getVirtualItems() is read during render, so every render built a new closure, notified, and dispatched a render-phase update on the component that was still rendering, until React gave up at 25 passes. It only fired past the 50-row virtualization threshold, which is why both dialogs looked fine while empty. Memoize getItemKey and estimateSize so their identity tracks their inputs. DataTable.spec had mocked @tanstack/react-virtual away, attributing the same error to jsdom, which hid this from CI. Keep that mock, since its row assertions need every row rendered, and add a spec that drives the real virtualizer and fails without the fix. Also restyle both dialogs, which is what made them look unfinished: - add the 19 keys these components pull from @librechat/client but the app locale never defined, so the empty state rendered com_ui_no_data verbatim - rename Shared links to Shared chats, matching the sibling Archived chats - transparent table with a rounded hover highlight painted on the cells, since border-radius does not apply to a table row, which needs separated borders - row height 56 to 40, dividers dropped, skeletons follow the same height - row hover uses surface-secondary-alt: plain surface-secondary is 247 against a 255 dialog in light mode and reads as nothing - row action buttons use surface-hover-alt, because surface-hover is also 227 in light and would vanish against the row highlight - drop the focus ring from the dialog containers and stop Shared chats seating focus in its search field, so neither flashes an outline on open - narrow both dialogs and let the table height follow its content * Fix compact row actions and selection count * fix: update selected count translation test to match interpolated output
This commit is contained in:
parent
0a2f59ab86
commit
edc6cf5936
17 changed files with 312 additions and 114 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { useCallback, useState, useMemo, useRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
|
@ -42,6 +42,7 @@ export default function SharedLinks() {
|
|||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const searchStore = useRecoilValue(store.search);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
|
@ -64,6 +65,17 @@ export default function SharedLinks() {
|
|||
}));
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback((row: SharedLinkRow) => row.shareId, []);
|
||||
|
||||
/** Radix would otherwise seat focus on the search field, flashing its ring every
|
||||
* time the dialog opens. Anchor focus to the content instead: it is a landing
|
||||
* spot rather than a tab stop, so it shows no ring and the first Tab reaches a
|
||||
* real control that does. */
|
||||
const handleOpenAutoFocus = useCallback((event: Event) => {
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const allLinks = useMemo<SharedLinkRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
|
|
@ -184,11 +196,11 @@ export default function SharedLinks() {
|
|||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-text-primary"
|
||||
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
|
||||
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
|
|
@ -225,7 +237,7 @@ export default function SharedLinks() {
|
|||
<TooltipAnchor
|
||||
description={localize('com_ui_open_source_chat_new_tab')}
|
||||
render={
|
||||
<Button asChild variant="ghost" className="h-8 w-8 p-0 hover:bg-surface-hover">
|
||||
<Button asChild variant="row-action" size="icon-sm">
|
||||
<a
|
||||
href={`/c/${row.original.conversationId}`}
|
||||
target="_blank"
|
||||
|
|
@ -243,8 +255,8 @@ export default function SharedLinks() {
|
|||
description={localize('com_ui_delete_shared_link_heading')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
|
|
@ -278,8 +290,10 @@ export default function SharedLinks() {
|
|||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_shared_links')}
|
||||
className="w-11/12 max-w-5xl bg-surface-dialog text-text-primary shadow-2xl"
|
||||
ref={contentRef}
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
|
|
@ -287,8 +301,8 @@ export default function SharedLinks() {
|
|||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
getRowId={(row) => row.shareId}
|
||||
className="scrollbar-gutter-stable h-[60vh]"
|
||||
getRowId={getRowId}
|
||||
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
|
|
@ -300,6 +314,7 @@ export default function SharedLinks() {
|
|||
isLoading={isLoading}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
skeleton: { count: 6 },
|
||||
search: { enableSearch: searchStore.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ export function ArchivedChatsModal({
|
|||
|
||||
/** The virtualized table has no stable focusable on mount, so Radix's default
|
||||
* autofocus lands on a row that the virtualizer tears out, dropping focus to
|
||||
* the page's top focus guard; anchor focus to the dialog content instead. */
|
||||
* the page's top focus guard; anchor focus to the dialog content instead.
|
||||
* The container is only a landing spot for focus, never a tab stop, so it
|
||||
* draws no focus ring of its own; the first Tab reveals one on a real control. */
|
||||
const handleOpenAutoFocus = (event: Event) => {
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus();
|
||||
|
|
@ -30,8 +32,7 @@ export function ArchivedChatsModal({
|
|||
ref={contentRef}
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="w-11/12 max-w-[1000px] bg-surface-dialog text-text-primary shadow-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
|
||||
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export default function ArchivedChatsTable() {
|
|||
}));
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: ArchivedConversationRow, index: number) => row.conversationId ?? `archived-${index}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const allConversations = useMemo<ArchivedConversationRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
|
|
@ -151,30 +156,32 @@ export default function ArchivedChatsTable() {
|
|||
header: localize('com_nav_archive_name'),
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
const link = (
|
||||
<Link
|
||||
to={`/c/${conversationId ?? ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
|
||||
title: title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MinimalIcon
|
||||
endpoint={row.original.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<Link
|
||||
to={`/c/${conversationId ?? ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
title={title ?? undefined}
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
|
||||
title: title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
{title ? <TooltipAnchor description={title} render={link} /> : link}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -204,8 +211,8 @@ export default function ArchivedChatsTable() {
|
|||
description={localize('com_ui_unarchive_conversation')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
unarchiveConversation({
|
||||
conversationId: conversation.conversationId ?? '',
|
||||
|
|
@ -223,8 +230,8 @@ export default function ArchivedChatsTable() {
|
|||
description={localize('com_ui_delete_conversation_tooltip')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDeleteConversation(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
|
|
@ -248,37 +255,31 @@ export default function ArchivedChatsTable() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed height keeps the loading (skeleton) and loaded states the same
|
||||
size, so the virtualized table can't reflow the dialog on load. */}
|
||||
<div className="h-[60vh]">
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
getRowId={(row, index) => row.conversationId ?? `archived-${index}`}
|
||||
className="scrollbar-gutter-stable h-full max-h-none"
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
search: { enableSearch: searchState.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The skeleton count matches the minimum height so the loading and loaded
|
||||
states are close in size, while a short list still collapses the box. */}
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
getRowId={getRowId}
|
||||
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
skeleton: { count: 6 },
|
||||
search: { enableSearch: searchState.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm', {
|
||||
title: deleteConversation?.title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
className="w-11/12 max-w-md"
|
||||
>
|
||||
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
<Trans
|
||||
|
|
|
|||
|
|
@ -621,7 +621,7 @@
|
|||
"com_nav_setting_mcp": "MCP Settings",
|
||||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
"com_nav_shared_links": "Shared chats",
|
||||
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
|
||||
"com_nav_slash_command": "/-Command",
|
||||
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
|
||||
|
|
@ -966,6 +966,7 @@
|
|||
"com_ui_cache_write": "Cache write",
|
||||
"com_ui_callback_url": "Callback URL",
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancel_dialog": "Cancel dialog",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
"com_ui_capabilities_count": "{{count}} capabilities",
|
||||
"com_ui_capabilities_count_one": "{{count}} capability",
|
||||
|
|
@ -1009,6 +1010,7 @@
|
|||
"com_ui_confirm": "Confirm",
|
||||
"com_ui_confirm_action": "Confirm Action",
|
||||
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
||||
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||
"com_ui_confirm_change": "Confirm Change",
|
||||
"com_ui_connecting": "Connecting",
|
||||
"com_ui_contact_admin_if_issue_persists": "Contact the Admin if the issue persists",
|
||||
|
|
@ -1087,6 +1089,8 @@
|
|||
"com_ui_custom_header_name": "Custom Header Name",
|
||||
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
|
||||
"com_ui_dark_theme_enabled": "Dark theme enabled",
|
||||
"com_ui_data_table": "Data table",
|
||||
"com_ui_data_table_scroll_area": "Scrollable data table area",
|
||||
"com_ui_date": "Date",
|
||||
"com_ui_date_april": "April",
|
||||
"com_ui_date_august": "August",
|
||||
|
|
@ -1130,6 +1134,8 @@
|
|||
"com_ui_delete_project": "Delete project?",
|
||||
"com_ui_delete_project_confirm": "Delete \"{{name}}\"? The chats inside won't be deleted.",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_selected": "Delete selected",
|
||||
"com_ui_delete_selected_items": "Delete selected items",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_shared_link_heading": "Delete Shared Link",
|
||||
"com_ui_delete_success": "Successfully deleted",
|
||||
|
|
@ -1182,6 +1188,7 @@
|
|||
"com_ui_editing_file": "Editing {{0}}",
|
||||
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_enabled": "Enabled",
|
||||
"com_ui_endpoint": "Endpoint",
|
||||
"com_ui_endpoint_menu": "LLM Endpoint Menu",
|
||||
"com_ui_enter": "Enter",
|
||||
|
|
@ -1194,6 +1201,7 @@
|
|||
"com_ui_enter_value": "Enter value",
|
||||
"com_ui_error": "Error",
|
||||
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
|
||||
"com_ui_error_details": "Error details",
|
||||
"com_ui_error_message_prefix": "Error Message:",
|
||||
"com_ui_error_save_admin_settings": "There was an error saving your admin settings.",
|
||||
"com_ui_error_try_following_prefix": "Please try one of the following",
|
||||
|
|
@ -1252,6 +1260,7 @@
|
|||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_files_count_size": "{{0}} Files ({{1}}KB)",
|
||||
"com_ui_filter_by": "Filter by {{title}}",
|
||||
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
|
||||
"com_ui_filter_prompts": "Filter Prompts",
|
||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||
|
|
@ -1427,6 +1436,7 @@
|
|||
"com_ui_live": "live",
|
||||
"com_ui_load_more": "Load more",
|
||||
"com_ui_loading": "Loading...",
|
||||
"com_ui_loading_more_data": "Loading more results",
|
||||
"com_ui_locked": "Locked",
|
||||
"com_ui_logo": "{{0}} Logo",
|
||||
"com_ui_low": "Low",
|
||||
|
|
@ -1577,6 +1587,7 @@
|
|||
"com_ui_no_categories": "No categories available",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes were made",
|
||||
"com_ui_no_data": "Nothing here yet",
|
||||
"com_ui_no_individual_resource_access": "No individual users or groups have access",
|
||||
"com_ui_no_labels": "No Labels",
|
||||
"com_ui_no_mcp_servers": "No MCP servers yet",
|
||||
|
|
@ -1584,11 +1595,14 @@
|
|||
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
|
||||
"com_ui_no_memories_match": "No memories match your search",
|
||||
"com_ui_no_memories_title": "No memories yet",
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_no_project_chats": "No chats yet",
|
||||
"com_ui_no_projects": "No projects yet",
|
||||
"com_ui_no_prompts_title": "No prompts yet",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_search_results": "No results match your search",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_no_skills_found": "No skills found",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "No valid items were selected",
|
||||
|
|
@ -1816,6 +1830,8 @@
|
|||
"com_ui_search_result_count": "{{count}} result found",
|
||||
"com_ui_search_results_count": "{{count}} results found",
|
||||
"com_ui_search_skills": "Search skills...",
|
||||
"com_ui_search_table": "Search table",
|
||||
"com_ui_search_table_description": "Type to filter results",
|
||||
"com_ui_searching_files": "Searching your files",
|
||||
"com_ui_seconds": "seconds",
|
||||
"com_ui_secret_key": "Secret Key",
|
||||
|
|
@ -1834,6 +1850,7 @@
|
|||
"com_ui_select_search_provider": "Search provider by name",
|
||||
"com_ui_select_search_region": "Search region by name",
|
||||
"com_ui_select_var": "Select {{0}}",
|
||||
"com_ui_selected_count": "{{count}} selected",
|
||||
"com_ui_send_now": "Send now",
|
||||
"com_ui_set": "Set",
|
||||
"com_ui_settings_label_2fa": "Two-factor authentication",
|
||||
|
|
@ -1854,7 +1871,7 @@
|
|||
"com_ui_settings_label_playback_rate": "Playback rate",
|
||||
"com_ui_settings_label_provider_api_keys": "Provider API keys",
|
||||
"com_ui_settings_label_revoke_keys": "Revoke keys",
|
||||
"com_ui_settings_label_shared_links": "Shared links",
|
||||
"com_ui_settings_label_shared_links": "Shared chats",
|
||||
"com_ui_settings_label_voice": "Voice",
|
||||
"com_ui_settings_no_results": "No settings match your search",
|
||||
"com_ui_settings_results_aria": "Search results",
|
||||
|
|
@ -2064,6 +2081,8 @@
|
|||
"com_ui_support_contact_name": "Name",
|
||||
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
|
||||
"com_ui_support_contact_name_placeholder": "Support contact name",
|
||||
"com_ui_table_error": "Table error",
|
||||
"com_ui_table_error_description": "The table failed to load. Refresh the page or try again.",
|
||||
"com_ui_teach_or_explain": "Learning",
|
||||
"com_ui_temporary": "Temporary Chat",
|
||||
"com_ui_terms_and_conditions": "Terms and Conditions",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue