ChatDrawer: virtualize scroll (for very numerous chats)

This commit is contained in:
Enrico Ros
2025-01-15 00:49:43 -08:00
parent 04e54d898e
commit 6ba1afa540
3 changed files with 150 additions and 43 deletions
+28
View File
@@ -25,6 +25,7 @@
"@prisma/client": "~5.22.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-virtual": "^3.11.2",
"@trpc/client": "11.0.0-rc.688",
"@trpc/next": "11.0.0-rc.688",
"@trpc/react-query": "11.0.0-rc.688",
@@ -2024,6 +2025,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.11.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+1
View File
@@ -36,6 +36,7 @@
"@prisma/client": "~5.22.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-virtual": "^3.11.2",
"@trpc/client": "11.0.0-rc.688",
"@trpc/next": "11.0.0-rc.688",
"@trpc/react-query": "11.0.0-rc.688",
@@ -1,5 +1,6 @@
import * as React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Box, Button, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
@@ -250,6 +251,44 @@ function ChatDrawer(props: {
]);
// Virtualize the list
const parentRef = React.useRef<HTMLDivElement>(null);
const virtEstimateSize = React.useCallback((index: number) => {
const item = renderNavItems[index];
switch (item.type) {
case 'nav-item-group':
return 34;
case 'nav-item-chat-data':
return item.isActive ? 80 : 36;
case 'nav-item-info-message':
return 34;
}
}, [renderNavItems]);
const virtUniqueKeys = React.useMemo(() => renderNavItems.map((item, idx) => {
switch (item.type) {
case 'nav-item-group':
return `g-${item.title}`;
case 'nav-item-chat-data':
return `c-${item.conversationId}${item.isActive ? '-active' : ''}`;
case 'nav-item-info-message':
return `i-${idx}`;
}
}), [renderNavItems]);
const virtUniqueKey = React.useCallback((index: number) => virtUniqueKeys[index], [virtUniqueKeys]);
const rowVirtualizer = useVirtualizer({
count: renderNavItems.length,
getScrollElement: () => parentRef.current,
estimateSize: virtEstimateSize,
getItemKey: virtUniqueKey,
overscan: 0,
});
return <>
{/* Drawer Header */}
@@ -328,55 +367,94 @@ function ChatDrawer(props: {
// transition: 'box-shadow 0.2s',
}}
>
<ListItemDecorator><AddIcon sx={{ fontSize: '' }} /></ListItemDecorator>
<ListItemDecorator><AddIcon /></ListItemDecorator>
New chat
</Button>
</Box>
{/* Chat Titles List (shrink as half the rate as the Folders List) */}
<Box sx={{ flexGrow: 1, flexShrink: 1, flexBasis: '20rem', overflowY: 'auto', ...themeScalingMap[contentScaling].chatDrawerItemSx }}>
{renderNavItems.map((item, idx) => item.type === 'nav-item-chat-data' ? (
<ChatDrawerItemMemo
key={'nav-chat-' + item.conversationId}
item={item}
showSymbols={!showPersonaIcons ? false : zenMode ? false : gifMode ? 'gif' : true}
bottomBarBasis={filteredChatsBarBasis}
onConversationActivate={handleConversationActivate}
onConversationBranch={onConversationBranch}
onConversationDeleteNoConfirmation={handleConversationDeleteNoConfirmation}
onConversationExport={onConversationsExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>
) : item.type === 'nav-item-group' ? (
<Typography key={'nav-divider-' + idx} level='body-xs' sx={{
textAlign: 'center',
my: 1,
// my: 'calc(var(--ListItem-minHeight) / 4)',
// keeps the group header sticky to the top
position: 'sticky',
top: 0,
backgroundColor: 'background.popup',
zIndex: 1,
}}>
{item.title}
</Typography>
) : item.type === 'nav-item-info-message' ? (
<Box key={'nav-info-' + idx} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, ml: 2 }}>
<Typography level='body-xs' sx={{ color: 'primary.softColor', my: 'calc(var(--ListItem-minHeight) / 4)' }}>
{filterHasStars && <StarOutlineRoundedIcon sx={{ color: 'primary.softColor', fontSize: 'xl', mb: -0.5, mr: 1 }} />}
{item.message}
</Typography>
{(filterHasStars || filterHasImageAssets || filterHasDocFragments) && (
<Tooltip title='Clear Filters'>
<IconButton size='sm' color='primary' onClick={clearFilters}>
<ClearIcon />
</IconButton>
</Tooltip>
)}
</Box>
) : null,
)}
<Box
ref={parentRef}
sx={{
flex: 1,
// flexGrow: 1,
// flexShrink: 1,
// flexBasis: '20rem',
overflowY: 'auto',
...themeScalingMap[contentScaling].chatDrawerItemSx,
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = renderNavItems[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item.type === 'nav-item-group' ? (
<Typography
level='body-xs'
sx={{
textAlign: 'center',
my: 1,
// my: 'calc(var(--ListItem-minHeight) / 4)',
// keeps the group header sticky to the top
position: 'sticky',
top: 0,
backgroundColor: 'background.popup',
zIndex: 1,
}}
>
{item.title}
</Typography>
) : item.type === 'nav-item-chat-data' ? (
<ChatDrawerItemMemo
item={item}
showSymbols={!showPersonaIcons ? false : zenMode ? false : gifMode ? 'gif' : true}
bottomBarBasis={filteredChatsBarBasis}
onConversationActivate={handleConversationActivate}
onConversationBranch={onConversationBranch}
onConversationDeleteNoConfirmation={handleConversationDeleteNoConfirmation}
onConversationExport={onConversationsExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>
) : item.type === 'nav-item-info-message' ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, ml: 2 }}>
<Typography level='body-xs' sx={{ color: 'primary.softColor', my: 'calc(var(--ListItem-minHeight) / 4)' }}>
{filterHasStars && (
<StarOutlineRoundedIcon sx={{ color: 'primary.softColor', fontSize: 'xl', mb: -0.5, mr: 1 }} />
)}
{item.message}
</Typography>
{(filterHasStars || filterHasImageAssets || filterHasDocFragments) && (
<Tooltip title='Clear Filters'>
<IconButton size='sm' color='primary' onClick={clearFilters}>
<ClearIcon />
</IconButton>
</Tooltip>
)}
</Box>
) : 'Unknown item type'}
</div>
);
})}
</div>
</Box>
<ListDivider sx={{ my: 0 }} />