fix: refine project navigation affordances

This commit is contained in:
Danny Avila 2026-06-02 23:17:05 -04:00
parent 4b2f78520d
commit ec9f3d6661
6 changed files with 196 additions and 70 deletions

View file

@ -13,6 +13,7 @@ export interface ChatsHeaderControls {
onOrganizationModeChange: (mode: SidebarOrganizationMode) => void;
onChatSortByChange: (sortBy: SidebarChatSort) => void;
onNewProject: () => void;
onOpenProjects: () => void;
}
interface ChatsHeaderProps extends ChatsHeaderControls {
@ -78,6 +79,7 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
onOrganizationModeChange,
onChatSortByChange,
onNewProject,
onOpenProjects,
}) => {
const localize = useLocalize();
const menuId = useId();
@ -144,6 +146,10 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
],
[chatSortBy, localize, onChatSortByChange, onOrganizationModeChange, organizationMode],
);
const headerLabel =
organizationMode === 'chronological'
? localize('com_ui_all_chats')
: localize('com_ui_projects');
return (
<div className="flex h-8 w-full items-center gap-0.5">
@ -153,7 +159,7 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
type="button"
aria-expanded={isExpanded}
>
<span className="select-none truncate">{localize('com_ui_all_chats')}</span>
<span className="select-none truncate">{headerLabel}</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
@ -193,6 +199,20 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
items={dropdownItems}
/>
<TooltipAnchor
description={localize('com_ui_all_projects')}
render={
<button
type="button"
aria-label={localize('com_ui_all_projects')}
className={headerIconButtonClassName}
onClick={onOpenProjects}
>
<Folder className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
<TooltipAnchor
description={localize('com_ui_new_project')}
render={

View file

@ -1,9 +1,9 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type FC } from 'react';
import throttle from 'lodash/throttle';
import { ChevronDown, Folder, FolderOpen } from 'lucide-react';
import { ChevronDown, Folder, FolderOpen, SquarePen } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { Spinner, useMediaQuery } from '@librechat/client';
import { useQueries } from '@tanstack/react-query';
import { useQueries, useQueryClient } from '@tanstack/react-query';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type {
@ -21,8 +21,8 @@ import {
useConversationsInfiniteQuery,
useProjectsInfiniteQuery,
} from '~/data-provider';
import { useFavorites, useLocalize, useShowMarketplace } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
import { useFavorites, useLocalize, useNewConvo, useShowMarketplace } from '~/hooks';
import { clearMessagesCache, groupConversationsByDate, cn } from '~/utils';
import { DateLabel } from './Conversations';
import ChatsHeader from './Header';
import Convo from './Convo';
@ -38,6 +38,7 @@ type Section = {
type FlattenedItem =
| { type: 'favorites' }
| { type: 'chats-header' }
| { type: 'projects-overview' }
| { type: 'section'; section: Section; isExpanded: boolean }
| { type: 'date'; sectionId?: string; groupName: string; isFirst?: boolean }
| { type: 'convo'; sectionId?: string; convo: TConversation }
@ -113,10 +114,12 @@ const SectionHeader = memo(
section,
isExpanded,
onToggle,
onStartProjectChat,
}: {
section: Section;
isExpanded: boolean;
onToggle: () => void;
onStartProjectChat: (project: TChatProject, event: React.MouseEvent<HTMLButtonElement>) => void;
}) => {
const localize = useLocalize();
const Icon = isExpanded ? FolderOpen : Folder;
@ -126,33 +129,55 @@ const SectionHeader = memo(
: undefined;
return (
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
<div
className={cn(
'group flex h-8 w-full items-center gap-2 rounded-lg px-1 text-left text-sm outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white',
'group/project-row flex h-8 w-full items-center rounded-lg text-sm outline-none transition-colors duration-150',
isExpanded ? 'text-text-primary' : 'text-text-secondary',
'hover:bg-surface-active-alt hover:text-text-primary',
)}
>
<Icon
className={cn(
'h-4 w-4 shrink-0 transition-colors duration-150',
isExpanded ? 'text-text-primary' : 'text-text-secondary group-hover:text-text-primary',
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">{section.title}</span>
{countLabel && <span className="shrink-0 text-xs text-text-tertiary">{countLabel}</span>}
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-text-tertiary transition-transform duration-200',
isExpanded ? 'rotate-180' : '',
)}
aria-hidden="true"
/>
</button>
<button
type="button"
aria-expanded={isExpanded}
onClick={onToggle}
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
>
<Icon
className={cn(
'h-4 w-4 shrink-0 transition-colors duration-150',
isExpanded
? 'text-text-primary'
: 'text-text-secondary group-hover/project-row:text-text-primary',
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">{section.title}</span>
{countLabel && <span className="shrink-0 text-xs text-text-tertiary">{countLabel}</span>}
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 text-text-tertiary transition-transform duration-200',
isExpanded ? 'rotate-180' : '',
)}
aria-hidden="true"
/>
</button>
{section.project && (
<button
type="button"
aria-label={localize('com_ui_new_chat_in_project', { name: section.project.name })}
title={localize('com_ui_new_chat_in_project', { name: section.project.name })}
className={cn(
'mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-all duration-150 hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white',
isExpanded
? 'opacity-100'
: 'opacity-0 group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100',
)}
onClick={(event) => onStartProjectChat(section.project as TChatProject, event)}
>
<SquarePen className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
},
);
@ -165,6 +190,22 @@ const EmptyRow = memo(({ title }: { title: string }) => {
EmptyRow.displayName = 'ProjectEmptyRow';
const ProjectsOverviewRow = memo(({ onOpenProjects }: { onOpenProjects: () => void }) => {
const localize = useLocalize();
return (
<button
type="button"
className="group flex h-8 w-full items-center gap-2 rounded-lg px-1 text-left text-sm text-text-secondary outline-none transition-colors duration-150 hover:bg-surface-active-alt hover:text-text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
onClick={onOpenProjects}
>
<Folder className="h-4 w-4 shrink-0 text-text-secondary group-hover:text-text-primary" />
<span className="min-w-0 flex-1 truncate">{localize('com_ui_all_projects')}</span>
</button>
);
});
ProjectsOverviewRow.displayName = 'ProjectsOverviewRow';
const ProjectConversations: FC<ProjectConversationsProps> = ({
mode,
chatSortBy,
@ -177,8 +218,11 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
chatsHeaderControls,
}) => {
const localize = useLocalize();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const conversation = useRecoilValue(store.conversationByIndex(0));
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { newConversation } = useNewConvo();
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
const showAgentMarketplace = useShowMarketplace();
const favoritesContentKeyRef = useRef('');
@ -403,6 +447,10 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
};
}
if (!isSearching) {
items.push({ type: 'projects-overview' });
}
if (isSearching) {
if (isSearchConversationsLoading) {
items.push({ type: 'loading', key: 'loading-search-conversations' });
@ -557,6 +605,9 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
if (item.type === 'chats-header') {
return 'project-chats-header';
}
if (item.type === 'projects-overview') {
return 'project-overview';
}
if (item.type === 'date') {
return `project-date-${item.sectionId ?? 'search'}-${item.groupName}-${
item.isFirst ? 'first' : 'next'
@ -601,6 +652,22 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
containerRef.current?.scrollToPosition(0);
}, [containerRef]);
const startProjectChat = useCallback(
(project: TChatProject, event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const path = `/c/new?projectId=${encodeURIComponent(project._id)}`;
if (event.ctrlKey || event.metaKey) {
window.open(path, '_blank');
return;
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: project._id } });
toggleNav();
},
[conversation?.conversationId, newConversation, queryClient, toggleNav],
);
const toggleSection = useCallback((sectionId: string) => {
setExpandedSectionIds((current) => {
const next = new Set(current);
@ -746,6 +813,14 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
);
}
if (item.type === 'projects-overview') {
return (
<MeasuredRow key={key} {...rowProps}>
<ProjectsOverviewRow onOpenProjects={chatsHeaderControls.onOpenProjects} />
</MeasuredRow>
);
}
if (item.type === 'section') {
return (
<MeasuredRow key={key} {...rowProps}>
@ -753,6 +828,7 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
section={item.section}
isExpanded={item.isExpanded}
onToggle={() => toggleSection(item.section.id)}
onStartProjectChat={startProjectChat}
/>
</MeasuredRow>
);
@ -800,6 +876,7 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
isSmallScreen,
setIsChatsExpanded,
chatsHeaderControls,
startProjectChat,
],
);

View file

@ -1,8 +1,10 @@
import { useCallback, useId, useMemo, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, ArrowUpDown, Check, Folder, Plus, SlidersHorizontal } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import { Button, Spinner, DropdownPopup } from '@librechat/client';
import type { MenuItemProps, RenderProp } from '~/common';
@ -11,8 +13,14 @@ import {
useGetStartupConfig,
useProjectQuery,
} from '~/data-provider';
import { useAgentsMap, useLocalize } from '~/hooks';
import { cn, getDefaultModelSpec, getLocalStorageItems, getModelSpec } from '~/utils';
import { useAgentsMap, useLocalize, useNewConvo } from '~/hooks';
import {
cn,
clearMessagesCache,
getDefaultModelSpec,
getLocalStorageItems,
getModelSpec,
} from '~/utils';
import ProjectChatList from './ProjectChatList';
import store from '~/store';
@ -36,6 +44,7 @@ function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
export default function ProjectWorkspace() {
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { projectId = '' } = useParams();
const [sortBy, setSortBy] = useState<ChatSortField>('updatedAt');
const sortMenuId = useId();
@ -43,6 +52,7 @@ export default function ProjectWorkspace() {
const { data: startupConfig } = useGetStartupConfig();
const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { newConversation } = useNewConvo();
const agentsMap = useAgentsMap({ isAuthenticated: true });
const activeProjectId = project?._id;
const activeSettingsLabel = useMemo(() => {
@ -145,10 +155,10 @@ export default function ProjectWorkspace() {
if (!activeProjectId) {
return;
}
navigate(`/c/new?projectId=${encodeURIComponent(activeProjectId)}`, {
state: { focusChat: true },
});
}, [activeProjectId, navigate]);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: activeProjectId } });
}, [activeProjectId, conversation?.conversationId, newConversation, queryClient]);
if (isProjectLoading) {
return (

View file

@ -94,18 +94,50 @@ export default function ProjectsView() {
return (
<main className="flex h-full min-h-0 flex-col overflow-auto bg-presentation text-text-primary">
<div className="container mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 md:px-6">
<div className="container mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 md:px-6 lg:pt-12">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold tracking-tight text-text-primary md:text-3xl">
{localize('com_ui_projects')}
</h1>
<Button type="button" variant="submit" size="sm" onClick={() => setIsCreating(true)}>
<Plus className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_new_project')}
</Button>
<div className="flex items-center gap-2">
<span className="hidden text-sm text-text-secondary sm:inline">
{localize('com_ui_sort_by')}
</span>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={sortMenuId}
isOpen={isSortMenuOpen}
setIsOpen={setIsSortMenuOpen}
className="z-[125] min-w-56"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_projects_by')}
className={cn(
'inline-flex h-10 items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-border-medium bg-surface-primary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:pointer-events-none disabled:opacity-50 sm:w-44',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<span className="flex min-w-0 items-center gap-2">
<ArrowUpDown
className="h-4 w-4 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<span className="truncate">{selectedSortLabel}</span>
</span>
</Ariakit.MenuButton>
}
items={sortMenuItems}
/>
<Button type="button" variant="submit" size="sm" onClick={() => setIsCreating(true)}>
<Plus className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_new_project')}
</Button>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex flex-col gap-5">
<label className="relative min-w-0 flex-1">
<span className="sr-only">{localize('com_ui_search_projects')}</span>
<Search
@ -116,36 +148,14 @@ export default function ProjectsView() {
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={localize('com_ui_search_projects')}
className="border-border-medium bg-transparent pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
className="border-border-medium bg-surface-primary pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
/>
</label>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={sortMenuId}
isOpen={isSortMenuOpen}
setIsOpen={setIsSortMenuOpen}
className="z-[125] min-w-56"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_projects_by')}
className={cn(
'inline-flex h-10 items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-border-medium bg-transparent px-3 text-sm font-medium text-text-primary ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-56',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<span className="flex min-w-0 items-center gap-2">
<ArrowUpDown
className="h-4 w-4 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<span className="truncate">{selectedSortLabel}</span>
</span>
</Ariakit.MenuButton>
}
items={sortMenuItems}
/>
<div className="flex items-center">
<span className="rounded-full bg-surface-active-alt px-4 py-2 text-sm font-medium text-text-primary">
{localize('com_ui_your_projects')}
</span>
</div>
</div>
<ProjectCreateDialog
@ -159,14 +169,14 @@ export default function ProjectsView() {
<Spinner className="text-text-primary" />
</div>
) : (
<div className="grid gap-2 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-2">
{projects.map((project) => (
<button
key={project._id}
type="button"
className={cn(
'group/project min-h-32 rounded-xl border border-border-light bg-transparent p-4 text-left transition-colors',
'hover:bg-surface-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
'group/project min-h-32 rounded-lg border border-border-light bg-transparent p-4 text-left transition-colors',
'hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
onClick={() => navigate(`/projects/${project._id}`)}
>

View file

@ -118,6 +118,11 @@ const ConversationsSection = memo(() => {
setIsProjectCreateOpen(true);
}, []);
const handleOpenProjects = useCallback(() => {
navigate('/projects');
toggleNav();
}, [navigate, toggleNav]);
const chatsHeaderControls = useMemo<ChatsHeaderControls>(
() => ({
organizationMode,
@ -125,6 +130,7 @@ const ConversationsSection = memo(() => {
onOrganizationModeChange: handleOrganizationModeChange,
onChatSortByChange: handleChatSortByChange,
onNewProject: handleNewProject,
onOpenProjects: handleOpenProjects,
}),
[
organizationMode,
@ -132,6 +138,7 @@ const ConversationsSection = memo(() => {
handleOrganizationModeChange,
handleChatSortByChange,
handleNewProject,
handleOpenProjects,
],
);

View file

@ -1528,6 +1528,7 @@
"com_ui_sidebar_mode_chronological": "Chronological",
"com_ui_sidebar_mode_chronological_list": "Chronological list",
"com_ui_sidebar_mode_recent_projects": "Recent projects",
"com_ui_sort_by": "Sort by",
"com_ui_sort_chats_by": "Sort chats by",
"com_ui_sort_created": "Created",
"com_ui_sort_projects_by": "Sort projects by",
@ -1773,6 +1774,7 @@
"com_ui_yes": "Yes",
"com_ui_you": "You",
"com_ui_your_api_key": "Your API Key",
"com_ui_your_projects": "Your projects",
"com_ui_zoom": "Zoom",
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_level": "Zoom level",