fix: refine project sidebar and creation UX

This commit is contained in:
Danny Avila 2026-06-02 22:47:35 -04:00
parent 4dbc277b91
commit 0fafaa2935
5 changed files with 199 additions and 50 deletions

View file

@ -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,
]);

View 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>
);
}

View file

@ -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">

View file

@ -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>
);
});

View file

@ -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",