mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: refine project sidebar and creation UX
This commit is contained in:
parent
4dbc277b91
commit
0fafaa2935
5 changed files with 199 additions and 50 deletions
|
|
@ -337,6 +337,8 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
|
|||
};
|
||||
});
|
||||
|
||||
const resultsBySectionId: Record<string, typeof sectionQueryResults> = {};
|
||||
|
||||
sectionQuerySpecs.forEach((spec, index) => {
|
||||
const result = sectionQueryResults[index];
|
||||
const state = states[spec.sectionId];
|
||||
|
|
@ -344,18 +346,32 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
resultsBySectionId[spec.sectionId] ??= [];
|
||||
if (result) {
|
||||
resultsBySectionId[spec.sectionId].push(result);
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
state.pages.push(result.data as ConversationListResponse);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(states).forEach(([sectionId, state]) => {
|
||||
const sectionResults = resultsBySectionId[sectionId] ?? [];
|
||||
state.conversations = state.pages.flatMap((page) => page.conversations) as TConversation[];
|
||||
state.groupedConversations = groupConversationsByDate(state.conversations, chatSortBy);
|
||||
const expectedPageCount = sectionCursors[sectionId]?.length ?? 1;
|
||||
const lastPage = state.pages[state.pages.length - 1];
|
||||
state.isLoading = state.pages.length === 0;
|
||||
state.isFetchingNextPage = state.pages.length > 0 && state.pages.length < expectedPageCount;
|
||||
const isInitialRequestPending =
|
||||
state.pages.length === 0 &&
|
||||
sectionResults.some((result) => result.isLoading || result.isFetching);
|
||||
const isInitialRequestError =
|
||||
state.pages.length === 0 && sectionResults.some((result) => result.isError);
|
||||
state.isLoading = isInitialRequestPending && !isInitialRequestError;
|
||||
state.isFetchingNextPage =
|
||||
state.pages.length > 0 &&
|
||||
(state.pages.length < expectedPageCount ||
|
||||
sectionResults.some((result) => result.isFetching));
|
||||
state.nextCursor = lastPage?.nextCursor ?? null;
|
||||
state.hasNextPage = state.nextCursor != null;
|
||||
});
|
||||
|
|
@ -496,6 +512,30 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
|
|||
|
||||
const flattenedItemsRef = useRef(flattenedItems);
|
||||
flattenedItemsRef.current = flattenedItems;
|
||||
const flattenedLayoutKey = useMemo(
|
||||
() =>
|
||||
flattenedItems
|
||||
.map((item) => {
|
||||
if (item.type === 'section') {
|
||||
return `s:${item.section.id}:${item.isExpanded ? '1' : '0'}`;
|
||||
}
|
||||
if (item.type === 'convo') {
|
||||
return `c:${item.sectionId ?? 'search'}:${item.convo.conversationId}`;
|
||||
}
|
||||
if (item.type === 'date') {
|
||||
return `d:${item.sectionId ?? 'search'}:${item.groupName}`;
|
||||
}
|
||||
if (item.type === 'empty') {
|
||||
return `e:${item.sectionId ?? 'search'}:${item.title}`;
|
||||
}
|
||||
if (item.type === 'loading') {
|
||||
return `l:${item.sectionId ?? 'root'}:${item.key}`;
|
||||
}
|
||||
return item.type;
|
||||
})
|
||||
.join('|'),
|
||||
[flattenedItems],
|
||||
);
|
||||
|
||||
const rowHeight = isSmallScreen ? 44 : 36;
|
||||
const cache = useMemo(
|
||||
|
|
@ -539,6 +579,7 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
|
|||
cache.clearAll();
|
||||
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
|
||||
containerRef.current.recomputeRowHeights(0);
|
||||
containerRef.current.forceUpdateGrid?.();
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
|
|
@ -552,6 +593,7 @@ const ProjectConversations: FC<ProjectConversationsProps> = ({
|
|||
favorites.length,
|
||||
isFavoritesLoading,
|
||||
showAgentMarketplace,
|
||||
flattenedLayoutKey,
|
||||
containerRef,
|
||||
]);
|
||||
|
||||
|
|
|
|||
120
client/src/components/Projects/ProjectCreateDialog.tsx
Normal file
120
client/src/components/Projects/ProjectCreateDialog.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
OGDialog,
|
||||
OGDialogTemplate,
|
||||
Spinner,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import { useCreateProjectMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type ProjectCreateDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (project: TChatProject) => void;
|
||||
children?: ReactNode;
|
||||
triggerRef?: MutableRefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
export default function ProjectCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
children,
|
||||
triggerRef,
|
||||
}: ProjectCreateDialogProps) {
|
||||
const localize = useLocalize();
|
||||
const formId = useId();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const createProject = useCreateProjectMutation();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const frameId = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen && !createProject.isLoading) {
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || createProject.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({ name: trimmedName });
|
||||
setName('');
|
||||
onOpenChange(false);
|
||||
onCreated?.(project);
|
||||
} catch {
|
||||
showToast({
|
||||
message: localize('com_ui_project_create_error'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={handleOpenChange} triggerRef={triggerRef}>
|
||||
{children}
|
||||
<OGDialogTemplate
|
||||
title={localize('com_ui_create_project')}
|
||||
showCloseButton={true}
|
||||
className="w-11/12 max-w-lg bg-surface-primary text-text-primary"
|
||||
main={
|
||||
<form id={formId} onSubmit={handleCreate} className="space-y-2">
|
||||
<Label htmlFor={`${formId}-name`} className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_project_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${formId}-name`}
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={localize('com_ui_project_name_placeholder')}
|
||||
className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="submit"
|
||||
disabled={!name.trim() || createProject.isLoading}
|
||||
aria-label={localize('com_ui_create_project')}
|
||||
>
|
||||
{createProject.isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
localize('com_ui_create_project')
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useDeferredValue, useId, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useDeferredValue, useEffect, useId, useMemo, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ArrowUpDown, Check, Folder, Plus, Search } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import { Input, Button, Spinner, DropdownPopup, useToastContext } from '@librechat/client';
|
||||
import { Input, Button, Spinner, DropdownPopup } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import { useCreateProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import ProjectCreateDialog from './ProjectCreateDialog';
|
||||
|
||||
type ProjectSort = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
|
||||
|
|
@ -35,16 +35,13 @@ function formatActivity(project: TChatProject, fallback: string) {
|
|||
export default function ProjectsView() {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<ProjectSort>('lastConversationAt');
|
||||
const [isCreating, setIsCreating] = useState(searchParams.get('new') === '1');
|
||||
const sortMenuId = useId();
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const createProject = useCreateProjectMutation();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, isLoading } = useProjectsInfiniteQuery({
|
||||
search: deferredSearch || undefined,
|
||||
|
|
@ -80,23 +77,18 @@ export default function ProjectsView() {
|
|||
[sortBy, sortOptions],
|
||||
);
|
||||
|
||||
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
useEffect(() => {
|
||||
if (searchParams.get('new') === '1') {
|
||||
setIsCreating(true);
|
||||
}
|
||||
try {
|
||||
const project = await createProject.mutateAsync({ name: trimmedName });
|
||||
setName('');
|
||||
setIsCreating(false);
|
||||
navigate(`/projects/${project._id}`);
|
||||
} catch {
|
||||
showToast({
|
||||
message: localize('com_ui_project_create_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
const handleCreateDialogChange = (open: boolean) => {
|
||||
setIsCreating(open);
|
||||
if (!open && searchParams.get('new') === '1') {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete('new');
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -156,27 +148,11 @@ export default function ProjectsView() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border-light bg-transparent p-3 sm:flex-row"
|
||||
>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={localize('com_ui_project_name')}
|
||||
className="min-w-0 flex-1 bg-transparent focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="submit" disabled={createProject.isLoading}>
|
||||
{createProject.isLoading ? localize('com_ui_loading') : localize('com_ui_create')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsCreating(false)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<ProjectCreateDialog
|
||||
open={isCreating}
|
||||
onOpenChange={handleCreateDialogChange}
|
||||
onCreated={(project) => navigate(`/projects/${project._id}`)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ChatsHeaderControls } from '~/components/Conversations/Header';
|
|||
import { Conversations } from '~/components/Conversations';
|
||||
import SearchBar from '~/components/Nav/SearchBar';
|
||||
import ProjectConversations from '~/components/Conversations/ProjectConversations';
|
||||
import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
|
||||
import store from '~/store';
|
||||
|
||||
const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav'));
|
||||
|
|
@ -42,6 +43,7 @@ const ConversationsSection = memo(() => {
|
|||
);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [isProjectCreateOpen, setIsProjectCreateOpen] = useState(false);
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
|
|
@ -113,9 +115,8 @@ const ConversationsSection = memo(() => {
|
|||
);
|
||||
|
||||
const handleNewProject = useCallback(() => {
|
||||
navigate('/projects?new=1');
|
||||
toggleNav();
|
||||
}, [navigate, toggleNav]);
|
||||
setIsProjectCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
const chatsHeaderControls = useMemo<ChatsHeaderControls>(
|
||||
() => ({
|
||||
|
|
@ -198,6 +199,14 @@ const ConversationsSection = memo(() => {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<ProjectCreateDialog
|
||||
open={isProjectCreateOpen}
|
||||
onOpenChange={setIsProjectCreateOpen}
|
||||
onCreated={(project) => {
|
||||
navigate(`/projects/${project._id}`);
|
||||
toggleNav();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -913,6 +913,7 @@
|
|||
"com_ui_create_mcp_server": "Create MCP server",
|
||||
"com_ui_create_memory": "Create Memory",
|
||||
"com_ui_create_new_agent": "Create New Agent",
|
||||
"com_ui_create_project": "Create project",
|
||||
"com_ui_create_prompt": "Create Prompt",
|
||||
"com_ui_create_prompt_page": "New Prompt Configuration Page",
|
||||
"com_ui_create_skill": "Create Skill",
|
||||
|
|
@ -1344,6 +1345,7 @@
|
|||
"com_ui_project_chat_count": "{{count}} chats",
|
||||
"com_ui_project_create_error": "Failed to create project",
|
||||
"com_ui_project_name": "Project name",
|
||||
"com_ui_project_name_placeholder": "Copenhagen Trip",
|
||||
"com_ui_project_not_found": "Project not found",
|
||||
"com_ui_project_update_error": "Failed to update project assignment",
|
||||
"com_ui_project_updated": "Project assignment updated",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue