🗂️ feat: Add Private Chat Projects (#13467)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

* feat: Add private chat projects

* fix: Format project files

* fix: Address project review findings

* fix: Resolve project review follow-ups

* fix: Handle project stats and cache edge cases

* style: align projects UI with sidebar patterns

* fix: resolve projects UI lint issues

* style: Align project menus and composer

* fix: Avoid project placeholder shadowing

* fix: Handle project search and stale ids

* fix: Polish project sidebar behavior

* fix: Preserve new chat stream after creation

* fix: Stabilize project sidebar sections

* fix: Smooth project sidebar organization

* fix: stabilize project chat entry

* fix: keep project workspace outside chat context

* fix: show default model on project workspace

* fix: fallback project workspace model label

* fix: preserve project scope during draft hydration

* fix: include route project in new chat submission

* fix: persist project id in agent chat saves

* fix: refine project sidebar and creation UX

* fix: export chat project method types

* fix: polish project landing context

* fix: refine project navigation affordances

* feat: rework projects UX — coexisting sidebar sections + URL-driven scope

Sidebar
- Replace the chronological/by-project mode toggle with coexisting
  Projects + Chats sections (both always visible)
- Remove ProjectConversations (927 lines), the org-mode Header, and types
- Add ProjectsSection: collapsible project rows that unfurl chats inline
  (full-size rows), with per-project new chat and an open/rename/delete menu
- Lift the marketplace/favorites shortcuts above the Projects section

Chat scope
- Derive a new chat's project strictly from the URL ?projectId, so the
  global New Chat no longer stays stuck in a project after a project chat

Surfaces
- Chat landing: subtle, clickable project chip instead of the floating badge
- Project workspace: modest header, composer-style entry, chats list
- All-projects grid: Claude-style cards with pluralized chat counts

* chore: prune unused i18n keys; fix project chat-count pluralization

* fix: project new-chat keeps model spec; sidebar header + row polish

- newConversation: ignore a chatProjectId-only template when deciding to
  apply the default model spec, so starting a chat in a project no longer
  strips the conversation `spec`
- useSelectMention: the Model Selector and @ command now retain the active
  project across endpoint/spec/preset switches; other new-chat paths still
  clear it
- Chats header now matches the Projects header (inline chevron + a new-chat
  icon button) and starts a non-project chat
- Project rows: use the new-chat icon for the per-project add button, render
  at text-sm to match the chat list, and align the row actions + hover color
  with conversation rows

* fix: read project scope from router params; align sidebar header icons

- useSelectMention now reads the active project from React Router's search
  params instead of window.location, which can drift out of sync because
  new-chat params are written to the URL via raw history.pushState; the
  Model Selector and @ command now reliably keep the project on switch
- Move the Chats section header out of the virtualized list so it renders
  in the same context as the Projects header and isn't shifted by the
  list scrollbar
- Inset header action icons (pr-2) so Projects/Chats header icons line up
  with the project-row and conversation-row trailing actions
- Extract getRouteChatProjectId into utils for the submit path

* fix: preserve chatProjectId through the new-chat template reduction

The param-endpoint guard in newConversation reduced a new chat's template to
{ endpoint } only, dropping the chatProjectId injected by the Model Selector /
@ switch — so switching models cleared the project scope. Keep chatProjectId
in the reduced template.

* style: align chat-history panel top padding; improve projects page contrast

- Add pt-2 to the chat-history panel so its top spacing matches the other
  side panels (agent builder, skills, files, etc.)
- Projects grid + workspace now use the darkest surface for the page
  (surface-primary) with cards, inputs, and the composer one step lighter
  (surface-secondary) and tertiary on hover, so cards read as elevated
  rather than darker than the background

* feat: interactive project landing chip + gallery icon for all-projects

- All-projects sidebar button uses the gallery-vertical-end icon
- The project landing chip is now interactive: click it to switch projects
  via a searchable combobox (ControlCombobox), or the trailing × to drop the
  project scope. Both update the draft conversation and the ?projectId search
  param in place, so the typed message and selected model are preserved

* test: fix Conversations unit test for refactored sidebar; add projects e2e

- Update Conversations.test.tsx mocks for the inline Chats header
  (useNewConvo, useQueryClient, conversation atom, NewChatIcon, TooltipAnchor),
  drop the removed chatsHeaderControls prop, and remove the mock for the
  deleted ../Header module — fixes the failing frontend Jest job
- Add e2e/specs/mock/projects.spec.ts covering project creation, the
  project-scoped new-chat landing + interactive chip (switch/remove), and
  listing projects on /projects
- Give the landing chip combobox a stable selectId for reliable targeting

* fix: refresh project stats after project-chat activity; stabilize e2e

- useEventHandlers: when a project chat is created/updated, invalidate the
  live [projects] query (gated on chatProjectId) instead of the now-unused
  projectConversations key, so the sidebar + all-projects stats refresh
  after a streamed reply (addresses a Codex finding)
- projects e2e: assert the reliable project-landing behavior (chip, scoped
  composer, accepted send) rather than the /c/:id transition, which the
  mock LLM harness doesn't complete

* test: verify a project chat saves and is filed under its project (e2e)

- Switch to a mock endpoint before sending so the message streams without a
  real API key (the default model failed with "No key found", so no chat was
  saved and the page never left /c/new); this also asserts the project chip
  survives the model switch
- Restore the reply + /c/:id transition assertions and add a check that the
  chat is listed under the expanded project in the sidebar
- Add data-testid="project-chats-<id>" to the inline project chat list

* fix: address Codex review findings (project scope edge cases)

- useSelectMention: fall back to the conversation's chatProjectId when the
  URL has no projectId, so switching model/spec inside an existing project
  chat (/c/:id) keeps the project assignment
- Conversations: include chatProjectId in the MemoizedConvo comparator so a
  sidebar row's project menu doesn't stay stale after a reassignment
- useDeleteProjectMutation: clear the active conversation's chatProjectId
  when its project is deleted (mirrors the assignment mutation); drop the
  now-dead projectConversations invalidation
- useQueryParams: carry the project into the new conversation when applying
  URL settings, so /c/new?projectId=...&<settings> stays scoped

* fix: project stats pagination + archived-chat edge cases (data-schemas)

- listChatProjects: include the null lastConversationAt bucket in the desc
  cursor so empty projects paginate (a $lt:<date> predicate excluded nulls,
  hiding chat-less projects from "Load more")
- saveConvo: recompute project stats instead of the incremental fast path
  when the saved conversation is itself archived/temporary/expired, so a
  project's lastConversationAt/Id no longer points at a hidden chat

* test: cover chat-less project pagination across the dated→null boundary

* fix: validate project ownership in bulkSaveConvos

Bulk paths (import/duplicate/fork) persisted whatever chatProjectId the
payload carried; an id that does not belong to the user created an orphan
assignment hidden from both the project and the unassigned sidebar. Validate
ownership like saveConvo and strip un-owned project ids before persisting,
refreshing stats only for owned projects.

* fix(projects): preserve chatProjectId on continuation, basename-safe delete redirect, project-detail invalidation

* fix(projects): navigate project workspace chats via useNavigateToConvo to avoid stale conversation state

* fix(projects): include projectConversations cache when resolving deleted chat's project for detail invalidation

* fix(projects): refresh both projects when a save or bulk write moves a chat between them

* style(projects): use Folders icon for the sidebar Projects header

* fix(projects): require id on ProjectUser so ProjectRequest extends Express Request cleanly

* style(projects): taller project chip with hover-revealed remove button, upward combobox; sort en translations

* style(projects): show endpoint/agent icon for project workspace chat rows
This commit is contained in:
Danny Avila 2026-06-03 15:29:18 -04:00 • committed by GitHub
parent 86fe79c37d
commit baa23a8e24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 4082 additions and 107 deletions

View file

@ -209,6 +209,7 @@ class AgentClient extends BaseClient {
{
spec: this.options.spec,
iconURL: this.options.iconURL,
chatProjectId: this.options.chatProjectId,
endpoint: this.options.endpoint,
agent_id: this.options.agent.id,
modelLabel: this.options.modelLabel,

View file

@ -382,6 +382,7 @@ if (cluster.isMaster) {
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
app.use('/api/skills', routes.skills);
app.use('/api/categories', routes.categories);

View file

@ -201,6 +201,7 @@ const startServer = async () => {
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
app.use('/api/skills', routes.skills);
app.use('/api/categories', routes.categories);

View file

@ -31,6 +31,9 @@ const assistantClients = {
const router = express.Router();
router.use(requireJwtAuth);
const isValidProjectFilter = (projectId) =>
!projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId);
router.get('/', async (req, res) => {
const limit = parseInt(req.query.limit, 10) || 25;
const cursor = req.query.cursor;
@ -38,6 +41,13 @@ router.get('/', async (req, res) => {
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
const sortBy = req.query.sortBy || 'updatedAt';
const sortDirection = req.query.sortDirection || 'desc';
const projectId = Array.isArray(req.query.projectId)
? req.query.projectId[0]
: req.query.projectId;
if (!isValidProjectFilter(projectId)) {
return res.status(400).json({ error: 'projectId must be a valid project id or unassigned' });
}
let tags;
if (req.query.tags) {
@ -53,6 +63,7 @@ router.get('/', async (req, res) => {
search,
sortBy,
sortDirection,
projectId,
});
res.status(200).json(result);
} catch (error) {

View file

@ -12,6 +12,7 @@ const staticRoute = require('./static');
const messages = require('./messages');
const memories = require('./memories');
const presets = require('./presets');
const projects = require('./projects');
const prompts = require('./prompts');
const skills = require('./skills');
const balance = require('./balance');
@ -59,6 +60,7 @@ module.exports = {
config,
models,
prompts,
projects,
skills,
actions,
presets,

View file

@ -0,0 +1,25 @@
const express = require('express');
const { createProjectHandlers } = require('@librechat/api');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const db = require('~/models');
const router = express.Router();
const handlers = createProjectHandlers({
listChatProjects: db.listChatProjects,
createChatProject: db.createChatProject,
getChatProject: db.getChatProject,
updateChatProject: db.updateChatProject,
deleteChatProject: db.deleteChatProject,
assignConversationToProject: db.assignConversationToProject,
});
router.use(requireJwtAuth);
router.get('/', handlers.listProjects);
router.post('/', handlers.createProject);
router.put('/conversations/:conversationId', handlers.assignConversationToProject);
router.get('/:projectId', handlers.getProject);
router.patch('/:projectId', handlers.updateProject);
router.delete('/:projectId', handlers.deleteProject);
module.exports = router;

View file

@ -7,7 +7,7 @@ const db = require('~/models');
const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
const { spec, iconURL, agent_id, chatProjectId, ...model_parameters } = parsedBody;
const agentPromise = loadAgent({
req,
spec,
@ -28,6 +28,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint,
agent_id,
endpointType,
chatProjectId,
model_parameters,
agent: agentPromise,
addedConvo,

View file

@ -857,6 +857,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
agent: primaryConfig,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
chatProjectId: endpointOption.chatProjectId,
attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments,
agentContextAttachmentsByAgentId,
endpointType: endpointOption.endpointType,

View file

@ -4,14 +4,21 @@ import { useForm } from 'react-hook-form';
import { Spinner } from '@librechat/client';
import { useParams } from 'react-router-dom';
import { Constants, buildTree } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TChatProject, TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
import {
useAddedResponse,
useResumeOnLoad,
useAdaptiveSSE,
useChatHelpers,
useLocalize,
} from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ProjectLandingChip from './ProjectLandingChip';
import ChatForm from './Input/ChatForm';
import Landing from './Landing';
import Header from './Header';
@ -29,8 +36,9 @@ function LoadingSpinner() {
);
}
function ChatView({ index = 0 }: { index?: number }) {
function ChatView({ index = 0, project }: { index?: number; project?: TChatProject }) {
const { conversationId } = useParams();
const localize = useLocalize();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
@ -70,6 +78,7 @@ function ChatView({ index = 0 }: { index?: number }) {
(!messagesTree || messagesTree.length === 0) &&
(conversationId === Constants.NEW_CONVO || !conversationId);
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
const isProjectLandingPage = isLandingPage && project != null;
if (isLoading && conversationId !== Constants.NEW_CONVO) {
content = <LoadingSpinner />;
@ -81,6 +90,11 @@ function ChatView({ index = 0 }: { index?: number }) {
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}
const chatFormPlaceholder =
isProjectLandingPage && project
? localize('com_ui_new_chat_in_project', { name: project.name })
: undefined;
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
@ -104,7 +118,8 @@ function ChatView({ index = 0 }: { index?: number }) {
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<ChatForm index={index} />
{isProjectLandingPage && project && <ProjectLandingChip project={project} />}
<ChatForm index={index} placeholder={chatFormPlaceholder} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
</div>
</div>

View file

@ -42,6 +42,7 @@ import store from '~/store';
interface ChatFormProps {
index: number;
placeholder?: string;
/** From ChatContext — individual values so memo can compare them */
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
@ -55,6 +56,7 @@ interface ChatFormProps {
const ChatForm = memo(function ChatForm({
index,
placeholder,
files,
setFiles,
conversation,
@ -177,6 +179,7 @@ const ChatForm = memo(function ChatForm({
submitButtonRef,
setIsScrollable,
disabled: disableInputs,
placeholder,
});
useQueryParams({ textAreaRef });
@ -413,7 +416,7 @@ ChatForm.displayName = 'ChatForm';
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
* streaming chunk — it only re-renders when the specific values it uses change.
*/
function ChatFormWrapper({ index = 0 }: { index?: number }) {
function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) {
const {
files,
setFiles,
@ -465,6 +468,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) {
return (
<ChatForm
index={index}
placeholder={placeholder}
files={files}
setFiles={setFiles}
conversation={stableConversation}

View file

@ -0,0 +1,89 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Folder, X } from 'lucide-react';
import { ControlCombobox, TooltipAnchor } from '@librechat/client';
import type { TChatProject } from 'librechat-data-provider';
import type { OptionWithIcon } from '~/common';
import { useProjectsInfiniteQuery } from '~/data-provider';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
/**
* Subtle project indicator shown on a project-scoped new-chat landing.
* Clicking the chip switches the project (searchable combobox); the trailing
* `×` removes the project scope. Both update the conversation draft and the
* `?projectId` search param in place, so the typed message and model are kept.
*/
export default function ProjectLandingChip({ project }: { project: TChatProject }) {
const localize = useLocalize();
const { conversation, setConversation } = useChatContext();
const [searchParams, setSearchParams] = useSearchParams();
const { data } = useProjectsInfiniteQuery(
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 100 },
{ staleTime: 30000 },
);
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
const items = useMemo<OptionWithIcon[]>(
() =>
projects.map((item) => ({
label: item.name,
value: item._id,
icon: <Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />,
})),
[projects],
);
const applyProject = useCallback(
(projectId: string | null) => {
if (conversation) {
setConversation({ ...conversation, chatProjectId: projectId });
}
const nextParams = new URLSearchParams(searchParams);
if (projectId) {
nextParams.set('projectId', projectId);
} else {
nextParams.delete('projectId');
}
setSearchParams(nextParams, { replace: true });
},
[conversation, setConversation, searchParams, setSearchParams],
);
return (
<div className="group mb-2.5 flex items-center gap-1 px-1">
<ControlCombobox
selectId="project-landing-select"
selectedValue={project._id}
displayValue={project.name}
items={items}
setValue={(value) => {
if (value) {
applyProject(value);
}
}}
SelectIcon={<Folder className="h-3.5 w-3.5 text-text-secondary" aria-hidden="true" />}
ariaLabel={localize('com_ui_change_project')}
searchPlaceholder={localize('com_ui_search_projects')}
isCollapsed={false}
showCarat={true}
placement="top"
containerClassName="w-auto px-0"
className="h-8 w-auto min-w-[11rem] gap-1.5 rounded-full px-2.5 text-sm font-medium text-text-secondary hover:text-text-primary"
/>
<TooltipAnchor
description={localize('com_ui_remove_from_project')}
render={
<button
type="button"
aria-label={localize('com_ui_remove_from_project')}
onClick={() => applyProject(null)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-secondary opacity-0 outline-none transition-all hover:bg-surface-hover hover:text-text-primary focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
}
/>
</div>
);
}

View file

@ -2,13 +2,21 @@ import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash/throttle';
import { ChevronDown } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { Spinner, useMediaQuery } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from 'librechat-data-provider';
import { Spinner, TooltipAnchor, NewChatIcon, useMediaQuery } from '@librechat/client';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import type { TConversation } from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
import {
useLocalize,
TranslationKeys,
useFavorites,
useShowMarketplace,
useNewConvo,
} from '~/hooks';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import { useActiveJobs } from '~/data-provider';
import { groupConversationsByDate, cn } from '~/utils';
import { groupConversationsByDate, clearMessagesCache, cn } from '~/utils';
import Convo from './Convo';
import store from '~/store';
@ -32,6 +40,7 @@ interface ConversationsProps {
isSearchLoading: boolean;
isChatsExpanded: boolean;
setIsChatsExpanded: (expanded: boolean) => void;
showFavorites?: boolean;
}
interface MeasuredRowProps {
@ -76,20 +85,53 @@ interface ChatsHeaderProps {
onToggle: () => void;
}
const headerIconButtonClassName =
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white';
/** Collapsible header for the Chats section */
const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle }) => {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const handleNewChat = useCallback(() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}, [conversation?.conversationId, newConversation, queryClient]);
return (
<button
onClick={onToggle}
className="group flex w-full items-center justify-between rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
type="button"
>
<span className="select-none">{localize('com_ui_chats')}</span>
<ChevronDown
className={cn('h-3 w-3 transition-transform duration-200', isExpanded ? 'rotate-180' : '')}
<div className="flex h-8 w-full items-center gap-0.5 pr-2">
<button
onClick={onToggle}
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
type="button"
aria-expanded={isExpanded}
>
<span className="select-none truncate">{localize('com_ui_chats')}</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
isExpanded ? '' : '-rotate-90',
)}
aria-hidden="true"
/>
</button>
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<button
type="button"
aria-label={localize('com_ui_new_chat')}
className={headerIconButtonClassName}
onClick={handleNewChat}
>
<NewChatIcon className="h-4 w-4" />
</button>
}
/>
</button>
</div>
);
});
@ -114,7 +156,6 @@ DateLabel.displayName = 'DateLabel';
type FlattenedItem =
| { type: 'favorites' }
| { type: 'chats-header' }
| { type: 'header'; groupName: string }
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
@ -145,6 +186,7 @@ const MemoizedConvo = memo(
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
prevProps.conversation.title === nextProps.conversation.title &&
prevProps.conversation.endpoint === nextProps.conversation.endpoint &&
prevProps.conversation.chatProjectId === nextProps.conversation.chatProjectId &&
prevProps.isGenerating === nextProps.isGenerating
);
},
@ -160,6 +202,7 @@ const Conversations: FC<ConversationsProps> = ({
isSearchLoading,
isChatsExpanded,
setIsChatsExpanded,
showFavorites = true,
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
@ -179,7 +222,9 @@ const Conversations: FC<ConversationsProps> = ({
// Determine if FavoritesList will render content
const shouldShowFavorites =
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
showFavorites &&
!search.query &&
(isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
favoritesContentKeyRef.current = `${favorites.length}-${showAgentMarketplace ? 1 : 0}-${isFavoritesLoading ? 1 : 0}`;
@ -199,7 +244,6 @@ const Conversations: FC<ConversationsProps> = ({
if (shouldShowFavorites) {
items.push({ type: 'favorites' });
}
items.push({ type: 'chats-header' });
if (isChatsExpanded) {
groupedConversations.forEach(([groupName, convos]) => {
@ -232,9 +276,6 @@ const Conversations: FC<ConversationsProps> = ({
if (item.type === 'favorites') {
return `favorites-${favoritesContentKeyRef.current}`;
}
if (item.type === 'chats-header') {
return 'chats-header';
}
if (item.type === 'header') {
return `header-${item.groupName}`;
}
@ -297,22 +338,9 @@ const Conversations: FC<ConversationsProps> = ({
);
}
if (item.type === 'chats-header') {
return (
<MeasuredRow key={key} {...rowProps}>
<ChatsHeader
isExpanded={isChatsExpanded}
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
/>
</MeasuredRow>
);
}
if (item.type === 'header') {
// First date header index depends on whether favorites row is included
// With favorites: [favorites, chats-header, first-header] → index 2
// Without favorites: [chats-header, first-header] → index 1
const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
// First date header index depends on whether the favorites row is included
const firstHeaderIndex = shouldShowFavorites ? 1 : 0;
return (
<MeasuredRow key={key} {...rowProps}>
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
@ -336,17 +364,7 @@ const Conversations: FC<ConversationsProps> = ({
return null;
},
[
cache,
flattenedItems,
moveToTop,
toggleNav,
isSmallScreen,
isChatsExpanded,
setIsChatsExpanded,
shouldShowFavorites,
activeJobIds,
],
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, shouldShowFavorites, activeJobIds],
);
const getRowHeight = useCallback(
@ -370,6 +388,12 @@ const Conversations: FC<ConversationsProps> = ({
return (
<div className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary">
<div className="px-3">
<ChatsHeader
isExpanded={isChatsExpanded}
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
/>
</div>
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />

View file

@ -171,6 +171,7 @@ export default function Conversation({
renameHandler: handleRename,
isActiveConvo,
conversationId,
chatProjectId: conversation.chatProjectId,
isPopoverActive,
setIsPopoverActive: handlePopoverOpenChange,
isShiftHeld: isActiveConvo ? isShiftHeld : false,

View file

@ -3,12 +3,22 @@ import * as Ariakit from '@ariakit/react';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import {
Ellipsis,
Share2,
CopyPlus,
Archive,
FolderInput,
FolderX,
Pen,
Trash,
} from 'lucide-react';
import { QueryKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { MouseEvent } from 'react';
import type { TMessage } from 'librechat-data-provider';
import {
useDuplicateConversationMutation,
useAssignConversationToProjectMutation,
useDeleteConversationMutation,
useGetStartupConfig,
useArchiveConvoMutation,
@ -17,11 +27,13 @@ import { useHasAccess, useLocalize, useNavigateToConvo, useNewConvo } from '~/ho
import { NotificationSeverity } from '~/common';
import { useChatContext } from '~/Providers';
import DeleteButton from './DeleteButton';
import ProjectButton from './ProjectButton';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
function ConvoOptions({
conversationId,
chatProjectId,
title,
retainView,
renameHandler,
@ -31,6 +43,7 @@ function ConvoOptions({
isShiftHeld = false,
}: {
conversationId: string | null;
chatProjectId?: string | null;
title: string | null;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
@ -53,8 +66,10 @@ function ConvoOptions({
const menuId = useId();
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const projectButtonRef = useRef<HTMLButtonElement>(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showProjectDialog, setShowProjectDialog] = useState(false);
const [announcement, setAnnouncement] = useState('');
const canCreateSharedLinks = useHasAccess({
@ -63,6 +78,7 @@ function ConvoOptions({
});
const archiveConvoMutation = useArchiveConvoMutation();
const assignConversationToProject = useAssignConversationToProjectMutation();
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
@ -121,6 +137,37 @@ function ConvoOptions({
setShowDeleteDialog(true);
}, []);
const projectHandler = useCallback(() => {
setShowProjectDialog(true);
}, []);
const removeProjectHandler = useCallback(() => {
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
assignConversationToProject.mutate(
{ conversationId: convoId, projectId: null },
{
onSuccess: () => {
setIsPopoverActive(false);
showToast({
message: localize('com_ui_project_updated'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: () => {
showToast({
message: localize('com_ui_project_update_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
}, [assignConversationToProject, conversationId, localize, setIsPopoverActive, showToast]);
const handleInstantDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
@ -217,6 +264,27 @@ function ConvoOptions({
<CopyPlus className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
),
},
{
label: localize('com_ui_change_project'),
onClick: projectHandler,
icon: <FolderInput className="icon-sm mr-2 text-text-primary" aria-hidden="true" />,
ariaHasPopup: 'dialog' as const,
ariaControls: 'project-conversation-dialog',
hideOnClick: false,
ref: projectButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_remove_from_project'),
onClick: removeProjectHandler,
show: Boolean(chatProjectId),
hideOnClick: false,
icon: assignConversationToProject.isLoading ? (
<Spinner className="size-4" />
) : (
<FolderX className="icon-sm mr-2 text-text-primary" aria-hidden="true" />
),
},
{
label: localize('com_ui_archive'),
onClick: handleArchiveClick,
@ -250,6 +318,10 @@ function ConvoOptions({
handleArchiveClick,
canCreateSharedLinks,
handleDuplicateClick,
projectHandler,
removeProjectHandler,
chatProjectId,
assignConversationToProject.isLoading,
],
);
@ -348,6 +420,16 @@ function ConvoOptions({
setShowDeleteDialog={setShowDeleteDialog}
/>
)}
{showProjectDialog && (
<ProjectButton
conversationId={conversationId ?? ''}
chatProjectId={chatProjectId}
setMenuOpen={setIsPopoverActive}
triggerRef={projectButtonRef}
showProjectDialog={showProjectDialog}
setShowProjectDialog={setShowProjectDialog}
/>
)}
</>
);
}
@ -356,6 +438,7 @@ export default memo(ConvoOptions, (prevProps, nextProps) => {
return (
prevProps.conversationId === nextProps.conversationId &&
prevProps.title === nextProps.title &&
prevProps.chatProjectId === nextProps.chatProjectId &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo &&
prevProps.isShiftHeld === nextProps.isShiftHeld

View file

@ -0,0 +1,157 @@
import { useEffect, useMemo, useState, type RefObject } from 'react';
import {
Button,
Spinner,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
useToastContext,
} from '@librechat/client';
import type { TChatProject } from 'librechat-data-provider';
import { useAssignConversationToProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
type ProjectButtonProps = {
conversationId: string;
chatProjectId?: string | null;
showProjectDialog?: boolean;
setShowProjectDialog?: (value: boolean) => void;
triggerRef?: RefObject<HTMLButtonElement>;
setMenuOpen?: (open: boolean) => void;
};
function ProjectConversationDialog({
conversationId,
chatProjectId,
setMenuOpen,
setShowProjectDialog,
}: {
conversationId: string;
chatProjectId?: string | null;
setMenuOpen?: (open: boolean) => void;
setShowProjectDialog: (value: boolean) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [selectedProjectId, setSelectedProjectId] = useState(chatProjectId ?? '');
const assignConversation = useAssignConversationToProjectMutation();
const { data, fetchNextPage, isFetchingNextPage } = useProjectsInfiniteQuery({
sortBy: 'name',
sortDirection: 'asc',
});
const projects = useMemo<TChatProject[]>(
() => data?.pages.flatMap((page) => page.projects) ?? [],
[data?.pages],
);
const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
useEffect(() => {
setSelectedProjectId(chatProjectId ?? '');
}, [chatProjectId]);
const saveProject = () => {
assignConversation.mutate(
{
conversationId,
projectId: selectedProjectId || null,
},
{
onSuccess: () => {
setShowProjectDialog(false);
setMenuOpen?.(false);
showToast({
message: localize('com_ui_project_updated'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: () => {
showToast({
message: localize('com_ui_project_update_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
};
return (
<OGDialogContent
id="project-conversation-dialog"
className="w-11/12 max-w-md"
showCloseButton={false}
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_change_project')}</OGDialogTitle>
</OGDialogHeader>
<label className="flex flex-col gap-2 text-sm text-text-primary">
{localize('com_ui_select_project')}
<select
value={selectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)}
className="h-10 rounded-lg border border-border-light bg-surface-primary px-3 text-sm outline-none focus:ring-2 focus:ring-ring-primary"
>
<option value="">{localize('com_ui_unassigned')}</option>
{projects.map((project) => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</select>
</label>
{hasNextPage && (
<button
type="button"
className="mt-3 text-sm text-text-secondary hover:text-text-primary"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
</button>
)}
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button onClick={saveProject} disabled={assignConversation.isLoading}>
{assignConversation.isLoading ? <Spinner /> : localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
);
}
export default function ProjectButton({
conversationId,
chatProjectId,
setMenuOpen,
showProjectDialog,
setShowProjectDialog,
triggerRef,
}: ProjectButtonProps) {
if (showProjectDialog === undefined || setShowProjectDialog === undefined) {
return null;
}
if (!conversationId) {
return null;
}
return (
<OGDialog open={showProjectDialog} onOpenChange={setShowProjectDialog} triggerRef={triggerRef}>
<ProjectConversationDialog
conversationId={conversationId}
chatProjectId={chatProjectId}
setMenuOpen={setMenuOpen}
setShowProjectDialog={setShowProjectDialog}
/>
</OGDialog>
);
}

View file

@ -1,4 +1,5 @@
export * from './DeleteButton';
export { default as ProjectButton } from './ProjectButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';

View file

@ -1,4 +1,5 @@
export * from './DeleteButton';
export { default as ProjectButton } from './ProjectButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';

View file

@ -0,0 +1,516 @@
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { useRecoilValue } from 'recoil';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Ellipsis,
Folder,
FolderPlus,
Folders,
Pencil,
Trash2,
} from 'lucide-react';
import { QueryKeys } from 'librechat-data-provider';
import type { TChatProject, TConversation } from 'librechat-data-provider';
import {
Button,
Input,
Spinner,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
TooltipAnchor,
DropdownPopup,
NewChatIcon,
useToastContext,
} from '@librechat/client';
import type { MenuItemProps } from '~/common';
import {
useProjectsInfiniteQuery,
useConversationsInfiniteQuery,
useUpdateProjectMutation,
useDeleteProjectMutation,
} from '~/data-provider';
import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
import { clearMessagesCache, cn } from '~/utils';
import { NotificationSeverity } from '~/common';
import Convo from './Convo';
import store from '~/store';
const INLINE_CHAT_LIMIT = 8;
const iconButtonClassName =
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white';
function ProjectRenameDialog({
open,
onOpenChange,
project,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
project: TChatProject;
}) {
const localize = useLocalize();
const formId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const [name, setName] = useState(project.name);
const updateProject = useUpdateProjectMutation();
const { showToast } = useToastContext();
useEffect(() => {
if (!open) {
return;
}
setName(project.name);
const frameId = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frameId);
}, [open, project.name]);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed || updateProject.isLoading) {
return;
}
updateProject.mutate(
{ projectId: project._id, name: trimmed },
{
onSuccess: () => onOpenChange(false),
onError: () =>
showToast({
message: localize('com_ui_project_rename_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
}),
},
);
};
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_rename_project')}</OGDialogTitle>
</OGDialogHeader>
<form id={formId} onSubmit={handleSubmit}>
<Input
ref={inputRef}
value={name}
onChange={(event) => setName(event.target.value)}
aria-label={localize('com_ui_project_name')}
className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
/>
</form>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
type="submit"
form={formId}
variant="submit"
disabled={!name.trim() || updateProject.isLoading}
>
{updateProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
);
}
function ProjectDeleteDialog({
open,
onOpenChange,
project,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
project: TChatProject;
}) {
const localize = useLocalize();
const navigate = useNavigate();
const location = useLocation();
const deleteProject = useDeleteProjectMutation();
const { showToast } = useToastContext();
const confirmDelete = () => {
deleteProject.mutate(project._id, {
onSuccess: () => {
onOpenChange(false);
if (location.pathname === `/projects/${project._id}`) {
navigate('/projects');
}
},
onError: () =>
showToast({
message: localize('com_ui_project_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
}),
});
};
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_project')}</OGDialogTitle>
</OGDialogHeader>
<div className="text-sm text-text-secondary">
{localize('com_ui_delete_project_confirm', { name: project.name })}
</div>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteProject.isLoading}>
{deleteProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
);
}
const noop = () => {};
function ProjectChatsInline({
projectId,
toggleNav,
onShowAll,
}: {
projectId: string;
toggleNav: () => void;
onShowAll: () => void;
}) {
const localize = useLocalize();
const { data, isLoading } = useConversationsInfiniteQuery(
{ projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
{ staleTime: 30000, cacheTime: 300000 },
);
const conversations = useMemo<TConversation[]>(
() =>
(data?.pages.flatMap((page) => page.conversations) ?? []).filter(Boolean) as TConversation[],
[data?.pages],
);
const hasMore =
conversations.length > INLINE_CHAT_LIMIT ||
(data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
const visible = conversations.slice(0, INLINE_CHAT_LIMIT);
if (isLoading && conversations.length === 0) {
return (
<div className="flex justify-start py-1.5 pl-2">
<Spinner className="h-4 w-4 text-text-secondary" />
</div>
);
}
if (conversations.length === 0) {
return (
<div className="py-1.5 pl-2 text-xs text-text-secondary">
{localize('com_ui_no_project_chats')}
</div>
);
}
return (
<div data-testid={`project-chats-${projectId}`}>
{visible.map((convo) => (
<Convo
key={convo.conversationId}
conversation={convo}
retainView={noop}
toggleNav={toggleNav}
isGenerating={false}
/>
))}
{hasMore && (
<button
type="button"
onClick={onShowAll}
className="ml-1 mt-0.5 rounded-md px-2 py-1 text-xs font-medium text-text-secondary outline-none transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
>
{localize('com_ui_show_all')}
</button>
)}
</div>
);
}
function ProjectItem({
project,
toggleNav,
defaultExpanded,
}: {
project: TChatProject;
toggleNav: () => void;
defaultExpanded: boolean;
}) {
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const menuId = useId();
const [expanded, setExpanded] = useState(defaultExpanded);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const openProject = useCallback(() => {
navigate(`/projects/${project._id}`);
toggleNav();
}, [navigate, project._id, toggleNav]);
const startChat = useCallback(() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: project._id } });
toggleNav();
}, [conversation?.conversationId, newConversation, project._id, queryClient, toggleNav]);
const menuItems = useMemo<MenuItemProps[]>(
() => [
{
id: `${menuId}-open`,
label: localize('com_ui_open_project'),
icon: <Folder className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: openProject,
},
{
id: `${menuId}-rename`,
label: localize('com_ui_rename'),
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsRenameOpen(true),
},
{
id: `${menuId}-delete`,
label: localize('com_ui_delete'),
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsDeleteOpen(true),
},
],
[localize, menuId, openProject],
);
return (
<li className="list-none">
<div className="group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary transition-colors hover:bg-surface-active-alt">
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-label={project.name}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg py-1.5 pl-1.5 pr-14 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-text-secondary transition-transform duration-200',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate">{project.name}</span>
</button>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-surface-active-alt opacity-0 transition-opacity group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100 has-[[data-state=open]]:opacity-100">
<TooltipAnchor
description={localize('com_ui_new_chat_in_project', { name: project.name })}
render={
<button
type="button"
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
className={iconButtonClassName}
onClick={startChat}
>
<NewChatIcon className="h-4 w-4" />
</button>
}
/>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={menuId}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
className="z-[125] min-w-44"
iconClassName="mr-2 text-text-secondary"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_more_options')}
className={cn(
iconButtonClassName,
isMenuOpen && 'bg-surface-active-alt text-text-primary',
)}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
</div>
</div>
{expanded && (
<ProjectChatsInline projectId={project._id} toggleNav={toggleNav} onShowAll={openProject} />
)}
<ProjectRenameDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
</li>
);
}
interface ProjectsSectionProps {
toggleNav: () => void;
isAuthenticated: boolean;
}
const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) => {
const localize = useLocalize();
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = useLocalStorage('projectsSectionExpanded', true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const conversation = useRecoilValue(store.conversationByIndex(0));
const activeProjectId = conversation?.chatProjectId ?? null;
const { data, isLoading } = useProjectsInfiniteQuery(
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
{ enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
);
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
const hasMore = (data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
const openProjects = useCallback(() => {
navigate('/projects');
toggleNav();
}, [navigate, toggleNav]);
const renderProjectsBody = () => {
if (isLoading && projects.length === 0) {
return (
<div className="flex justify-start py-2 pl-2">
<Spinner className="h-4 w-4 text-text-secondary" />
</div>
);
}
if (projects.length === 0) {
return (
<button
type="button"
onClick={() => setIsCreateOpen(true)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
>
<FolderPlus className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{localize('com_ui_new_project')}</span>
</button>
);
}
return (
<ul className="m-0 list-none p-0">
{projects.map((project) => (
<ProjectItem
key={project._id}
project={project}
toggleNav={toggleNav}
defaultExpanded={project._id === activeProjectId}
/>
))}
{hasMore && (
<li className="list-none">
<button
type="button"
onClick={openProjects}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-medium text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
>
{localize('com_ui_all_projects')}
</button>
</li>
)}
</ul>
);
};
if (!isAuthenticated) {
return null;
}
return (
<div className="flex flex-col px-3 text-sm">
<div className="flex h-8 w-full items-center gap-0.5 pr-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
type="button"
aria-expanded={isExpanded}
>
<span className="select-none truncate">{localize('com_ui_projects')}</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
isExpanded ? '' : '-rotate-90',
)}
aria-hidden="true"
/>
</button>
<TooltipAnchor
description={localize('com_ui_all_projects')}
render={
<button
type="button"
aria-label={localize('com_ui_all_projects')}
className={iconButtonClassName}
onClick={openProjects}
>
<Folders className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
<TooltipAnchor
description={localize('com_ui_new_project')}
render={
<button
type="button"
aria-label={localize('com_ui_new_project')}
className={iconButtonClassName}
onClick={() => setIsCreateOpen(true)}
>
<FolderPlus className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
</div>
{isExpanded && <div className="max-h-[42vh] overflow-y-auto">{renderProjectsBody()}</div>}
<ProjectCreateDialog
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={(project) => {
navigate(`/projects/${project._id}`);
toggleNav();
}}
/>
</div>
);
};
ProjectsSection.displayName = 'ProjectsSection';
export default memo(ProjectsSection);

View file

@ -48,11 +48,15 @@ jest.mock('react-virtualized', () => {
});
jest.mock('~/store', () => {
const { atom } = jest.requireActual('recoil');
const { atom, atomFamily } = jest.requireActual('recoil');
return {
__esModule: true,
default: {
search: atom({ key: 'test-conversations-search', default: { query: '' } }),
conversationByIndex: atomFamily({
key: 'test-conversations-convo-by-index',
default: null,
}),
},
};
});
@ -70,12 +74,19 @@ jest.mock('~/hooks', () => ({
useFavorites: () => mockFavoritesState,
useLocalize: () => (key: string) => key,
useShowMarketplace: () => mockShowMarketplace,
useNewConvo: () => ({ newConversation: jest.fn() }),
TranslationKeys: {},
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({ invalidateQueries: jest.fn() }),
}));
jest.mock('@librechat/client', () => ({
Spinner: () => <div data-testid="spinner" />,
useMediaQuery: () => false,
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
NewChatIcon: () => <svg data-testid="new-chat-icon" />,
}));
jest.mock('~/data-provider', () => ({
@ -84,6 +95,7 @@ jest.mock('~/data-provider', () => ({
jest.mock('~/utils', () => ({
groupConversationsByDate: () => [],
clearMessagesCache: jest.fn(),
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));

View file

@ -0,0 +1,249 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
type CSSProperties,
type FC,
type ReactNode,
} from 'react';
import throttle from 'lodash/throttle';
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
import { Spinner } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
import { DateLabel } from '~/components/Conversations/Conversations';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
type ChatSortField = 'updatedAt' | 'createdAt';
type FlattenedItem =
| { type: 'date'; groupName: string }
| { type: 'convo'; convo: TConversation }
| { type: 'loading' }
| { type: 'empty' };
interface ProjectChatListProps {
conversations: TConversation[];
isLoading: boolean;
isFetchingNextPage: boolean;
hasNextPage: boolean;
sortBy: ChatSortField;
emptyLabel: string;
loadMore: () => void;
}
interface MeasuredRowProps {
cache: CellMeasurerCache;
rowKey: string;
parent: MeasuredCellParent;
index: number;
style: CSSProperties;
children: ReactNode;
}
const MeasuredRow: FC<MeasuredRowProps> = memo(
({ cache, rowKey, parent, index, style, children }) => (
<CellMeasurer cache={cache} columnIndex={0} key={rowKey} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style}>
{children}
</div>
)}
</CellMeasurer>
),
);
MeasuredRow.displayName = 'ProjectWorkspaceMeasuredRow';
const LoadingRow = memo(() => {
const localize = useLocalize();
return (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-text-secondary">
<Spinner className="text-text-primary" />
<span>{localize('com_ui_loading')}</span>
</div>
);
});
LoadingRow.displayName = 'ProjectWorkspaceLoadingRow';
const ConversationRow = memo(({ conversation }: { conversation: TConversation }) => {
const { navigateToConvo } = useNavigateToConvo();
const localize = useLocalize();
const { data: endpointsConfig } = useGetEndpointsQuery();
const title = conversation.title || localize('com_ui_untitled');
const updatedAt = conversation.updatedAt || conversation.createdAt;
const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
return (
<button
type="button"
className="flex w-full items-center gap-3 border-b border-border-light py-3 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary"
onClick={() => navigateToConvo(conversation)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig ?? {}}
size={24}
context="menu-item"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs text-text-secondary">{formattedDate}</span>
</span>
</button>
);
});
ConversationRow.displayName = 'ProjectWorkspaceConversationRow';
const ProjectChatList = ({
conversations,
isLoading,
isFetchingNextPage,
hasNextPage,
sortBy,
emptyLabel,
loadMore,
}: ProjectChatListProps) => {
const flattenedItems = useMemo(() => {
if (isLoading) {
return [{ type: 'loading' as const }];
}
if (!conversations.length) {
return [{ type: 'empty' as const }];
}
const items: FlattenedItem[] = [];
groupConversationsByDate(conversations, sortBy).forEach(([groupName, convos]) => {
items.push({ type: 'date', groupName });
convos.forEach((convo) => items.push({ type: 'convo', convo }));
});
if (isFetchingNextPage) {
items.push({ type: 'loading' });
}
return items;
}, [conversations, isFetchingNextPage, isLoading, sortBy]);
const flattenedItemsRef = useRef(flattenedItems);
flattenedItemsRef.current = flattenedItems;
const cache = useMemo(
() =>
new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 52,
keyMapper: (index) => {
const item = flattenedItemsRef.current[index];
if (!item) {
return `project-workspace-unknown-${index}`;
}
if (item.type === 'date') {
return `project-workspace-date-${item.groupName}`;
}
if (item.type === 'convo') {
return `project-workspace-convo-${item.convo.conversationId}`;
}
return `project-workspace-${item.type}`;
},
}),
[],
);
const listRef = useRef<List | null>(null);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
cache.clearAll();
listRef.current?.recomputeRowHeights(0);
});
return () => cancelAnimationFrame(frameId);
}, [cache, conversations.length, sortBy]);
const throttledLoadMore = useMemo(() => throttle(loadMore, 300), [loadMore]);
const rowRenderer = useCallback(
({ index, key, parent, style }) => {
const item = flattenedItems[index];
const rowProps = { cache, rowKey: key, parent, index, style };
if (item.type === 'loading') {
return (
<MeasuredRow key={key} {...rowProps}>
<LoadingRow />
</MeasuredRow>
);
}
if (item.type === 'empty') {
return (
<MeasuredRow key={key} {...rowProps}>
<div className="py-12 text-center text-sm text-text-secondary">{emptyLabel}</div>
</MeasuredRow>
);
}
if (item.type === 'date') {
return (
<MeasuredRow key={key} {...rowProps}>
<DateLabel groupName={item.groupName} />
</MeasuredRow>
);
}
return (
<MeasuredRow key={key} {...rowProps}>
<ConversationRow conversation={item.convo} />
</MeasuredRow>
);
},
[cache, emptyLabel, flattenedItems],
);
const getRowHeight = useCallback(
({ index }: { index: number }) => cache.getHeight(index, 0),
[cache],
);
const handleRowsRendered = useCallback(
({ stopIndex }: { stopIndex: number }) => {
if (hasNextPage && stopIndex >= flattenedItems.length - 6) {
throttledLoadMore();
}
},
[flattenedItems.length, hasNextPage, throttledLoadMore],
);
return (
<div
className={cn('min-h-[280px] flex-1 overflow-hidden rounded-lg border border-border-light')}
>
<AutoSizer>
{({ width, height }) => (
<List
ref={listRef}
width={width}
height={height}
rowCount={flattenedItems.length}
rowHeight={getRowHeight}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
overscanRowCount={8}
onRowsRendered={handleRowsRendered}
className="outline-none"
style={{ outline: 'none' }}
/>
)}
</AutoSizer>
</div>
);
};
export default memo(ProjectChatList);

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

@ -0,0 +1,215 @@
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 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import { Spinner, DropdownPopup } from '@librechat/client';
import type { MenuItemProps, RenderProp } from '~/common';
import { useConversationsInfiniteQuery, useProjectQuery } from '~/data-provider';
import { useLocalize, useNewConvo } from '~/hooks';
import { cn, clearMessagesCache } from '~/utils';
import ProjectChatList from './ProjectChatList';
import store from '~/store';
type ChatSortField = 'updatedAt' | 'createdAt';
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
return function SortMenuItem({ className, ...props }) {
return (
<div {...props} className={cn(className, 'justify-between gap-5')}>
<span className="truncate">{label}</span>
{isSelected ? (
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
) : (
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
)}
</div>
);
};
}
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();
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { newConversation } = useNewConvo();
const activeProjectId = project?._id;
const sortOptions = useMemo(
() => [
{ value: 'updatedAt' as const, label: localize('com_ui_sort_updated') },
{ value: 'createdAt' as const, label: localize('com_ui_sort_created') },
],
[localize],
);
const selectedSortLabel =
sortOptions.find((option) => option.value === sortBy)?.label ?? localize('com_ui_sort_updated');
const sortMenuItems = useMemo<MenuItemProps[]>(
() =>
sortOptions.map((option) => {
const isSelected = sortBy === option.value;
return {
id: `project-chat-sort-${option.value}`,
ariaLabel: option.label,
ariaChecked: isSelected,
onClick: () => setSortBy(option.value),
render: renderSortMenuItem(option.label, isSelected),
};
}),
[sortBy, sortOptions],
);
const {
data,
fetchNextPage,
isFetchingNextPage,
isLoading: isConversationsLoading,
} = useConversationsInfiniteQuery(
{
projectId: activeProjectId,
sortBy,
sortDirection: 'desc',
},
{
enabled: Boolean(activeProjectId),
staleTime: 30000,
cacheTime: 300000,
},
);
const conversations = useMemo(
() => data?.pages.flatMap((page) => page.conversations) ?? [],
[data?.pages],
);
const hasNextPage = useMemo(() => {
const pages = data?.pages;
if (!pages?.length) {
return false;
}
const lastPage: ConversationListResponse = pages[pages.length - 1];
return lastPage.nextCursor !== null;
}, [data?.pages]);
const startProjectChat = useCallback(() => {
if (!activeProjectId) {
return;
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation({ template: { chatProjectId: activeProjectId } });
}, [activeProjectId, conversation?.conversationId, newConversation, queryClient]);
if (isProjectLoading) {
return (
<div className="flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-secondary">
{localize('com_ui_project_not_found')}
</div>
);
}
return (
<main className="flex h-full min-h-0 flex-col overflow-y-auto bg-surface-primary text-text-primary">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 pb-10 pt-4 md:px-6 lg:pt-8">
<button
type="button"
onClick={() => navigate('/projects')}
className="-ml-1.5 inline-flex w-fit items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_all_projects')}
</button>
<header className="mt-5 flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-surface-secondary text-text-secondary">
<Folder className="h-6 w-6" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<h1 className="truncate text-2xl font-semibold tracking-tight text-text-primary">
{project.name}
</h1>
{project.description ? (
<p className="mt-0.5 line-clamp-2 text-sm text-text-secondary">
{project.description}
</p>
) : null}
</div>
</header>
<button
type="button"
onClick={startProjectChat}
className={cn(
'mt-6 flex w-full items-center gap-3 rounded-[26px] border border-border-medium bg-surface-secondary px-3.5 py-3 text-left shadow-sm transition-colors',
'hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary text-text-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 truncate text-text-secondary">
{localize('com_ui_new_chat_in_project', { name: project.name })}
</span>
</button>
<section className="mt-8 flex min-h-0 flex-1 flex-col">
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className="flex items-baseline gap-2 text-sm font-medium text-text-primary">
{localize('com_ui_chats')}
<span className="text-text-secondary">{project.conversationCount}</span>
</h2>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={sortMenuId}
isOpen={isSortMenuOpen}
setIsOpen={setIsSortMenuOpen}
className="z-[125] min-w-44"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_chats_by')}
className={cn(
'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<ArrowUpDown className="h-4 w-4" aria-hidden="true" />
{selectedSortLabel}
</Ariakit.MenuButton>
}
items={sortMenuItems}
/>
</div>
<ProjectChatList
conversations={conversations}
isLoading={isConversationsLoading}
isFetchingNextPage={isFetchingNextPage}
hasNextPage={hasNextPage}
sortBy={sortBy}
emptyLabel={localize('com_ui_no_project_chats')}
loadMore={() => fetchNextPage()}
/>
</section>
</div>
</main>
);
}

View file

@ -0,0 +1,239 @@
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 } from '@librechat/client';
import type { MenuItemProps, RenderProp } from '~/common';
import { useProjectsInfiniteQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import ProjectCreateDialog from './ProjectCreateDialog';
type ProjectSort = 'name' | 'createdAt' | 'lastConversationAt';
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
return function SortMenuItem({ className, ...props }) {
return (
<div {...props} className={cn(className, 'justify-between gap-5')}>
<span className="truncate">{label}</span>
{isSelected ? (
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
) : (
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
)}
</div>
);
};
}
function formatActivity(project: TChatProject) {
const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
export default function ProjectsView() {
const localize = useLocalize();
const navigate = useNavigate();
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 deferredSearch = useDeferredValue(search);
const { data, fetchNextPage, isFetchingNextPage, isLoading } = useProjectsInfiniteQuery({
search: deferredSearch || undefined,
sortBy,
sortDirection: sortBy === 'name' ? 'asc' : 'desc',
});
const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
const sortOptions = useMemo(
() => [
{ value: 'lastConversationAt' as const, label: localize('com_ui_latest_activity') },
{ value: 'createdAt' as const, label: localize('com_ui_sort_created') },
{ value: 'name' as const, label: localize('com_ui_name') },
],
[localize],
);
const selectedSortLabel =
sortOptions.find((option) => option.value === sortBy)?.label ??
localize('com_ui_latest_activity');
const sortMenuItems = useMemo<MenuItemProps[]>(
() =>
sortOptions.map((option) => {
const isSelected = sortBy === option.value;
return {
id: `project-sort-${option.value}`,
ariaLabel: option.label,
ariaChecked: isSelected,
onClick: () => setSortBy(option.value),
render: renderSortMenuItem(option.label, isSelected),
};
}),
[sortBy, sortOptions],
);
useEffect(() => {
if (searchParams.get('new') === '1') {
setIsCreating(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 });
}
};
return (
<main className="flex h-full min-h-0 flex-col overflow-auto bg-surface-primary 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 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>
<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-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-surface-tertiary 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-5">
<label className="relative min-w-0 flex-1">
<span className="sr-only">{localize('com_ui_search_projects')}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary"
aria-hidden="true"
/>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={localize('com_ui_search_projects')}
className="border-border-medium bg-surface-secondary pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
/>
</label>
<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
open={isCreating}
onOpenChange={handleCreateDialogChange}
onCreated={(project) => navigate(`/projects/${project._id}`)}
/>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 md:gap-4">
{projects.map((project) => {
const activity = formatActivity(project);
return (
<button
key={project._id}
type="button"
className={cn(
'group/project flex min-h-[8.5rem] flex-col rounded-xl border border-border-medium bg-surface-secondary p-4 text-left transition-colors',
'hover:border-border-heavy hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
onClick={() => navigate(`/projects/${project._id}`)}
>
<span className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate text-base font-semibold text-text-primary">
{project.name}
</span>
</span>
{project.description ? (
<span className="mt-2 line-clamp-2 text-sm leading-relaxed text-text-secondary">
{project.description}
</span>
) : null}
<span className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs text-text-secondary">
<span>
{project.conversationCount === 1
? localize('com_ui_project_chat_count_single')
: localize('com_ui_project_chat_count', {
count: project.conversationCount,
})}
</span>
{activity ? <span className="shrink-0 truncate">{activity}</span> : null}
</span>
</button>
);
})}
</div>
)}
{!isLoading && projects.length === 0 && (
<div className="rounded-lg border border-border-medium bg-transparent py-16 text-center text-sm text-text-secondary">
{localize('com_ui_no_projects')}
</div>
)}
{hasNextPage && (
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
</Button>
)}
</div>
</main>
);
}

View file

@ -0,0 +1,2 @@
export { default as ProjectsView } from './ProjectsView';
export { default as ProjectWorkspace } from './ProjectWorkspace';

View file

@ -14,6 +14,8 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import ProjectsSection from '~/components/Conversations/ProjectsSection';
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import SearchBar from '~/components/Nav/SearchBar';
import store from '~/store';
@ -104,7 +106,7 @@ const ConversationsSection = memo(() => {
return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden pb-3"
className="flex h-full min-h-0 flex-col overflow-hidden pb-3 pt-2"
role="region"
aria-label={localize('com_ui_chat_history')}
>
@ -116,6 +118,12 @@ const ConversationsSection = memo(() => {
)}
{search.enabled && <SearchBar isSmallScreen={isSmallScreen} />}
</div>
{!search.query && (
<div className="px-3">
<FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />
</div>
)}
{!search.query && <ProjectsSection toggleNav={toggleNav} isAuthenticated={isAuthenticated} />}
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
@ -127,6 +135,7 @@ const ConversationsSection = memo(() => {
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
showFavorites={false}
/>
</div>
</div>

View file

@ -0,0 +1,2 @@
export * from './queries';
export * from './mutations';

View file

@ -0,0 +1,117 @@
import { useRecoilCallback } from 'recoil';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type {
TChatProject,
TConversation,
TCreateChatProjectRequest,
TDeleteChatProjectResponse,
TUpdateChatProjectRequest,
TAssignConversationToProjectRequest,
TAssignConversationToProjectResponse,
} from 'librechat-data-provider';
import store from '~/store';
export const useCreateProjectMutation = (): UseMutationResult<
TChatProject,
unknown,
TCreateChatProjectRequest,
unknown
> => {
const queryClient = useQueryClient();
return useMutation((payload: TCreateChatProjectRequest) => dataService.createProject(payload), {
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.projects]);
},
});
};
export const useUpdateProjectMutation = (): UseMutationResult<
TChatProject,
unknown,
TUpdateChatProjectRequest,
unknown
> => {
const queryClient = useQueryClient();
return useMutation((payload: TUpdateChatProjectRequest) => dataService.updateProject(payload), {
onSuccess: (project) => {
queryClient.setQueryData([QueryKeys.project, project._id], project);
queryClient.invalidateQueries([QueryKeys.projects]);
},
});
};
export const useDeleteProjectMutation = (): UseMutationResult<
TDeleteChatProjectResponse,
unknown,
string,
unknown
> => {
const queryClient = useQueryClient();
const clearActiveConversationProject = useRecoilCallback(
({ snapshot, set }) =>
async (projectId: string) => {
const conversation = await snapshot.getPromise(store.conversationByIndex(0));
if (conversation?.conversationId && conversation.chatProjectId === projectId) {
set(store.updateConversationSelector(conversation.conversationId), {
...conversation,
chatProjectId: null,
});
}
},
[],
);
return useMutation((projectId: string) => dataService.deleteProject(projectId), {
onSuccess: (_result, projectId) => {
clearActiveConversationProject(projectId);
queryClient.removeQueries([QueryKeys.project, projectId]);
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.allConversations]);
},
});
};
export const useAssignConversationToProjectMutation = (): UseMutationResult<
TAssignConversationToProjectResponse,
unknown,
TAssignConversationToProjectRequest,
unknown
> => {
const queryClient = useQueryClient();
const updateActiveConversation = useRecoilCallback(
({ set }) =>
(conversation: TConversation) => {
if (!conversation.conversationId) {
return;
}
set(store.updateConversationSelector(conversation.conversationId), {
...conversation,
chatProjectId: conversation.chatProjectId ?? null,
});
},
[],
);
return useMutation(
(payload: TAssignConversationToProjectRequest) =>
dataService.assignConversationToProject(payload),
{
onSuccess: (result) => {
updateActiveConversation(result.conversation);
queryClient.setQueryData(
[QueryKeys.conversation, result.conversation.conversationId],
result.conversation,
);
[result.previousProjectId, result.projectId].forEach((projectId) => {
if (projectId) {
queryClient.invalidateQueries([QueryKeys.project, projectId]);
}
});
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.allConversations]);
queryClient.invalidateQueries([QueryKeys.projectConversations]);
},
},
);
};

View file

@ -0,0 +1,49 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type {
UseInfiniteQueryOptions,
QueryObserverResult,
UseQueryOptions,
} from '@tanstack/react-query';
import type { ProjectListParams, ProjectListResponse, TChatProject } from 'librechat-data-provider';
export const useProjectsInfiniteQuery = (
params: ProjectListParams = {},
config?: UseInfiniteQueryOptions<ProjectListResponse, unknown>,
) => {
const { sortBy, sortDirection, search, limit } = params;
return useInfiniteQuery<ProjectListResponse>({
queryKey: [QueryKeys.projects, { sortBy, sortDirection, search, limit }],
queryFn: ({ pageParam }) =>
dataService.listProjects({
sortBy,
sortDirection,
search,
limit,
cursor: pageParam?.toString(),
}),
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
...config,
});
};
export const useProjectQuery = (
projectId?: string | null,
config?: UseQueryOptions<TChatProject>,
): QueryObserverResult<TChatProject, unknown> => {
return useQuery<TChatProject>(
[QueryKeys.project, projectId],
() => dataService.getProjectById(projectId ?? ''),
{
enabled: Boolean(projectId),
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};

View file

@ -7,6 +7,7 @@ export * from './Files';
export * from './Memories';
export * from './Messages';
export * from './Misc';
export * from './Projects';
export * from './Tools';
export * from './connection';
export * from './Favorites';

View file

@ -11,6 +11,7 @@ import {
logger,
/* Conversations */
addConvoToAllQueries,
findConversationInInfinite,
updateConvoInAllQueries,
removeConvoFromAllQueries,
} from '~/utils';
@ -34,6 +35,7 @@ export const useUpdateConversationMutation = (
const targetId = payload.conversationId || id;
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
queryClient.invalidateQueries([QueryKeys.projectConversations]);
},
},
);
@ -123,6 +125,9 @@ export const useArchiveConvoMutation = (
[QueryKeys.conversation, vars.conversationId],
isArchived ? null : _data,
);
if (_data.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.project, _data.chatProjectId]);
}
onSuccess?.(_data, vars, context);
},
@ -136,6 +141,8 @@ export const useArchiveConvoMutation = (
queryKey: archivedConvoQueryKey,
refetchPage: (_, index) => index === 0,
});
queryClient.invalidateQueries([QueryKeys.projectConversations]);
queryClient.invalidateQueries([QueryKeys.projects]);
},
..._options,
},
@ -475,6 +482,30 @@ export const useDeleteConversationMutation = (
// TODO: CHECK THIS, no-op; restore if needed
},
onSuccess: (data, vars, context) => {
const deletedConversation = vars.conversationId
? queryClient.getQueryData<t.TConversation>([QueryKeys.conversation, vars.conversationId])
: undefined;
let deletedProjectId = deletedConversation?.chatProjectId;
if (!deletedProjectId && vars.conversationId) {
const cacheKeys = [QueryKeys.allConversations, QueryKeys.projectConversations];
for (const cacheKey of cacheKeys) {
const queries = queryClient.getQueryCache().findAll([cacheKey], { exact: false });
for (const query of queries) {
const found = findConversationInInfinite(
queryClient.getQueryData<InfiniteData<ConversationListResponse>>(query.queryKey),
vars.conversationId,
);
if (found?.chatProjectId) {
deletedProjectId = found.chatProjectId;
break;
}
}
if (deletedProjectId) {
break;
}
}
}
if (vars.conversationId) {
removeConvoFromAllQueries(queryClient, vars.conversationId);
}
@ -519,6 +550,11 @@ export const useDeleteConversationMutation = (
queryKey: [QueryKeys.archivedConversations],
refetchPage: (_, index) => index === 0,
});
queryClient.invalidateQueries([QueryKeys.projectConversations]);
queryClient.invalidateQueries([QueryKeys.projects]);
if (deletedProjectId) {
queryClient.invalidateQueries([QueryKeys.project, deletedProjectId]);
}
options?.onSuccess?.(data, vars, context);
},
@ -550,6 +586,11 @@ export const useDuplicateConversationMutation = (
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0,
});
queryClient.invalidateQueries([QueryKeys.projectConversations]);
queryClient.invalidateQueries([QueryKeys.projects]);
if (duplicatedConversation.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.project, duplicatedConversation.chatProjectId]);
}
if (duplicatedConversation.tags && duplicatedConversation.tags.length > 0) {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (oldTags) => {
@ -593,6 +634,11 @@ export const useForkConvoMutation = (
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0,
});
queryClient.invalidateQueries([QueryKeys.projectConversations]);
queryClient.invalidateQueries([QueryKeys.projects]);
if (forkedConversation.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.project, forkedConversation.chatProjectId]);
}
if (forkedConversation.tags && forkedConversation.tags.length > 0) {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (oldTags) => {

View file

@ -86,12 +86,12 @@ export const useConversationsInfiniteQuery = (
params: ConversationListParams,
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
) => {
const { isArchived, sortBy, sortDirection, tags, search } = params;
const { isArchived, sortBy, sortDirection, tags, search, projectId } = params;
return useInfiniteQuery<ConversationListResponse>({
queryKey: [
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
{ isArchived, sortBy, sortDirection, tags, search },
{ isArchived, sortBy, sortDirection, tags, search, projectId },
],
queryFn: ({ pageParam }) =>
dataService.listConversations({
@ -100,6 +100,7 @@ export const useConversationsInfiniteQuery = (
sortDirection,
tags,
search,
projectId,
cursor: pageParam?.toString(),
}),
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,

View file

@ -28,7 +28,7 @@ import type { SetterOrUpdater } from 'recoil';
import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { logger, createDualMessageContent } from '~/utils';
import { logger, createDualMessageContent, getRouteChatProjectId } from '~/utils';
import store, { useGetEphemeralAgent } from '~/store';
import { startupConfigKey } from '~/data-provider';
import useUserKey from '~/hooks/Input/useUserKey';
@ -174,6 +174,13 @@ export default function useChatFunctions({
});
}
const chatProjectId =
conversationId === Constants.NEW_CONVO
? getRouteChatProjectId()
: (conversation?.chatProjectId ?? null);
const conversationForPayload =
chatProjectId != null ? { ...(conversation ?? {}), chatProjectId } : (conversation ?? {});
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
const intermediateId = overrideUserMessageId ?? v4();
@ -193,7 +200,8 @@ export default function useChatFunctions({
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
navigate('/c/new', { state: { focusChat: true } });
const projectSearch = chatProjectId ? `?projectId=${encodeURIComponent(chatProjectId)}` : '';
navigate(`/c/new${projectSearch}`, { state: { focusChat: true } });
}
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
@ -221,7 +229,7 @@ export default function useChatFunctions({
const convo = parseCompactConvo({
endpoint: endpoint as EndpointSchemaKey,
endpointType: endpointType as EndpointSchemaKey,
conversation: conversation ?? {},
conversation: conversationForPayload,
defaultParamsEndpoint,
});
@ -234,6 +242,7 @@ export default function useChatFunctions({
overrideUserMessageId,
},
convo,
chatProjectId ? { chatProjectId } : {},
) as TEndpointOption;
if (endpoint !== EModelEndpoint.agents) {
endpointOption.key = getExpiry();
@ -371,6 +380,7 @@ export default function useChatFunctions({
const submission: TSubmission = {
conversation: {
...conversation,
...(chatProjectId ? { chatProjectId } : {}),
conversationId,
},
endpointOption,

View file

@ -23,6 +23,8 @@ import { startupConfigKey, useGetAgentByIdQuery } from '~/data-provider';
import { useChatContext, useChatFormContext } from '~/Providers';
import store from '~/store';
const PROJECT_ID_SEARCH_PARAM = 'projectId';
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
const editCacheKey = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }];
const editCache = queryClient.getQueryData<AgentListResponse>(editCacheKey);
@ -72,6 +74,15 @@ export default function useQueryParams({
const urlAgentId = searchParams.get('agent_id') || '';
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId);
const getPreservedSearchParams = useCallback(() => {
const preservedParams = new URLSearchParams();
const projectId = searchParams.get(PROJECT_ID_SEARCH_PARAM);
if (projectId) {
preservedParams.set(PROJECT_ID_SEARCH_PARAM, projectId);
}
return preservedParams;
}, [searchParams]);
/**
* Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
@ -167,7 +178,11 @@ export default function useQueryParams({
return;
}
newConversation({ preset: newPreset, keepAddedConvos: true });
newConversation({
template: { chatProjectId: conversation?.chatProjectId ?? null },
preset: newPreset,
keepAddedConvos: true,
});
},
[
queryClient,
@ -223,8 +238,8 @@ export default function useQueryParams({
}
})();
setSearchParams(new URLSearchParams(), { replace: true });
}, [methods, submitMessage, setSearchParams]);
setSearchParams(getPreservedSearchParams(), { replace: true });
}, [methods, submitMessage, setSearchParams, getPreservedSearchParams]);
useEffect(() => {
const processQueryParams = () => {
@ -239,6 +254,7 @@ export default function useQueryParams({
delete queryParams.prompt;
delete queryParams.q;
delete queryParams.submit;
delete queryParams[PROJECT_ID_SEARCH_PARAM];
const validSettings = processValidSettings(queryParams);
return { decodedPrompt, validSettings, shouldAutoSubmit };
@ -281,7 +297,7 @@ export default function useQueryParams({
// Defer URL cleanup until after submission completes (processSubmission handles it)
if (!pendingSubmitRef.current) {
setSearchParams(new URLSearchParams(), { replace: true });
setSearchParams(getPreservedSearchParams(), { replace: true });
}
};
@ -349,6 +365,7 @@ export default function useQueryParams({
newConversation,
submitMessage,
setSearchParams,
getPreservedSearchParams,
queryClient,
processSubmission,
areSettingsApplied,

View file

@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useSearchParams } from 'react-router-dom';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TPreset,
@ -39,6 +40,16 @@ export default function useSelectMention({
const getDefaultConversation = useDefaultConvo();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);
const [searchParams] = useSearchParams();
/**
* Project scope is read from React Router's search params (the same source the
* project chip reads), not window.location — new-chat params are written to the
* URL via raw history.pushState, so window.location can drift out of sync.
*/
const routeChatProjectId = useMemo(() => {
const projectId = searchParams.get('projectId');
return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
}, [searchParams]);
const onSelectSpec = useCallback(
(spec?: TModelSpec) => {
@ -107,7 +118,10 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new spec', conversation);
newConversation({
template: { ...(template as Partial<TConversation>) },
template: {
...(template as Partial<TConversation>),
chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
},
preset,
keepAddedConvos: isModular,
});
@ -119,6 +133,7 @@ export default function useSelectMention({
newConversation,
endpointsConfig,
assistantsMap,
routeChatProjectId,
],
);
@ -201,12 +216,22 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new endpoint/model', template);
newConversation({
template: { ...(template as Partial<TConversation>) },
template: {
...(template as Partial<TConversation>),
chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
},
preset: { ...kwargs, spec: null, iconURL: null, modelLabel: null, endpoint: newEndpoint },
keepAddedConvos: isNewModular,
});
},
[getConversation, getDefaultConversation, modularChat, newConversation, endpointsConfig],
[
getConversation,
getDefaultConversation,
modularChat,
newConversation,
endpointsConfig,
routeChatProjectId,
],
);
const onSelectPreset = useCallback(
@ -264,6 +289,7 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new preset', template);
newConversation({
template: { chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null },
preset: newPreset,
keepAddedConvos: isModular,
disableParams,
@ -276,6 +302,7 @@ export default function useSelectMention({
newConversation,
endpointsConfig,
getDefaultConversation,
routeChatProjectId,
],
);

View file

@ -28,11 +28,13 @@ export default function useTextarea({
submitButtonRef,
setIsScrollable,
disabled = false,
placeholder,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
submitButtonRef: React.RefObject<HTMLButtonElement>;
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
disabled?: boolean;
placeholder?: string;
}) {
const localize = useLocalize();
const getSender = useGetSender();
@ -95,6 +97,10 @@ export default function useTextarea({
return localize('com_endpoint_message_not_appendable');
}
if (placeholder) {
return placeholder;
}
const sender =
isAssistant || isAgent
? getEntityName({ name: entityName, isAgent, localize })
@ -105,17 +111,17 @@ export default function useTextarea({
})}`;
};
const placeholder = getPlaceholderText();
const placeholderText = getPlaceholderText();
if (textAreaRef.current?.getAttribute('placeholder') === placeholder) {
if (textAreaRef.current?.getAttribute('placeholder') === placeholderText) {
return;
}
const setPlaceholder = () => {
const placeholder = getPlaceholderText();
const placeholderText = getPlaceholderText();
if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
textAreaRef.current?.setAttribute('placeholder', placeholder);
if (textAreaRef.current?.getAttribute('placeholder') !== placeholderText) {
textAreaRef.current?.setAttribute('placeholder', placeholderText);
forceResize(textAreaRef.current);
}
};
@ -137,6 +143,7 @@ export default function useTextarea({
conversation,
latestMessage,
isNotAppendable,
placeholder,
]);
const handleKeyDown = useCallback(

View file

@ -373,6 +373,10 @@ export default function useEventHandlers({
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
if (update.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
}
} else if (setConversation) {
setConversation((prevState) => {
update = tConvoUpdateSchema.parse({
@ -448,6 +452,10 @@ export default function useEventHandlers({
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
if (update.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
}
}
} else if (setConversation) {
setConversation((prevState) => {
@ -720,6 +728,11 @@ export default function useEventHandlers({
});
}
if (conversation.chatProjectId) {
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.project, conversation.chatProjectId]);
}
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
preserveSubagentAtomsForNewConvoIdRef.current = conversation.conversationId;
navigate(`/c/${conversation.conversationId}`, { replace: true });

View file

@ -88,6 +88,11 @@ const useNewConvo = (index = 0) => {
const modelsConfig = modelsData ?? modelsQuery.data;
const { endpoint = null } = conversation;
const buildDefaultConversation = (endpoint === null || buildDefault) ?? false;
const hasExplicitChatProjectId = Object.prototype.hasOwnProperty.call(
conversation,
'chatProjectId',
);
const explicitChatProjectId = conversation.chatProjectId;
const activePreset =
// use default preset only when it's defined,
// preset is not provided,
@ -198,6 +203,12 @@ const useNewConvo = (index = 0) => {
models,
defaultParamsEndpoint,
});
if (hasExplicitChatProjectId) {
conversation.chatProjectId = explicitChatProjectId ?? null;
} else {
delete conversation.chatProjectId;
}
}
if (disableParams === true) {
@ -227,26 +238,44 @@ const useNewConvo = (index = 0) => {
return;
}
const searchParamsString = searchParams?.toString();
const getParams = () => (searchParamsString ? `?${searchParamsString}` : '');
const getParams = (nextConversation: TConversation) => {
const nextParams = new URLSearchParams(searchParams);
nextParams.delete('projectId');
if (
nextConversation.conversationId === Constants.NEW_CONVO &&
nextConversation.chatProjectId
) {
nextParams.set('projectId', nextConversation.chatProjectId);
}
const searchParamsString = nextParams.toString();
return searchParamsString ? `?${searchParamsString}` : '';
};
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
const appTitle = localStorage.getItem(LocalStorageKeys.APP_TITLE) ?? '';
if (appTitle) {
document.title = appTitle;
}
const path = `/c/${Constants.NEW_CONVO}${getParams()}`;
const path = `/c/${Constants.NEW_CONVO}${getParams(conversation)}`;
navigate(path, { state: { focusChat: true } });
return;
}
const path = `/c/${conversation.conversationId}${getParams()}`;
const path = `/c/${conversation.conversationId}${getParams(conversation)}`;
navigate(path, {
replace: true,
state: disableFocus ? {} : { focusChat: true },
});
},
[endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
[
endpointsConfig,
defaultPreset,
assistantsListMap,
modelsQuery.data,
hasAgentAccess,
searchParams,
],
);
const newConversation = useCallback(
@ -278,7 +307,7 @@ const useNewConvo = (index = 0) => {
isParamEndpoint(_preset?.endpoint ?? '', _preset?.endpointType ?? '');
const template =
paramEndpoint === true && templateConvoId && templateConvoId === Constants.NEW_CONVO
? { endpoint: _template.endpoint }
? { endpoint: _template.endpoint, chatProjectId: _template.chatProjectId }
: _template;
const conversation = {
@ -298,7 +327,8 @@ const useNewConvo = (index = 0) => {
startupConfig &&
(startupConfig.modelSpecs?.prioritize === true ||
(startupConfig.interface?.modelSelect ?? true) !== true ||
(result?.last != null && Object.keys(_template).length === 0)) &&
(result?.last != null &&
Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0)) &&
defaultModelSpec
) {
preset = getModelSpecPreset(defaultModelSpec);

View file

@ -750,6 +750,7 @@
"com_ui_agents_allow_share_public": "Allow sharing Agents publicly",
"com_ui_agents_allow_use": "Allow using Agents",
"com_ui_all": "all",
"com_ui_all_projects": "All projects",
"com_ui_all_proper": "All",
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
@ -840,6 +841,7 @@
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
"com_ui_category": "Category",
"com_ui_change_project": "Change project",
"com_ui_change_version": "Change Version",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
@ -910,6 +912,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",
@ -961,6 +964,8 @@
"com_ui_delete_memory": "Delete Memory",
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_preset": "Delete Preset?",
"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_shared_link": "Delete shared link?",
"com_ui_delete_shared_link_heading": "Delete Shared Link",
@ -1140,6 +1145,7 @@
"com_ui_last_used": "Last used",
"com_ui_late_night": "Happy late night",
"com_ui_latest": "latest",
"com_ui_latest_activity": "Latest activity",
"com_ui_latest_footer": "Every AI for Everyone.",
"com_ui_latest_version": "Latest version",
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
@ -1149,6 +1155,7 @@
"com_ui_link_copied": "Link copied",
"com_ui_link_refreshed": "Link refreshed",
"com_ui_live": "live",
"com_ui_load_more": "Load more",
"com_ui_loading": "Loading...",
"com_ui_locked": "Locked",
"com_ui_logo": "{{0}} Logo",
@ -1251,6 +1258,7 @@
"com_ui_model_selected": "{{0}} selected",
"com_ui_model_spec": "Model Spec",
"com_ui_more_info": "More info",
"com_ui_more_options": "More options",
"com_ui_my_prompts": "My Prompts",
"com_ui_my_skills": "My Skills",
"com_ui_n_files": "{{0}} files",
@ -1259,7 +1267,9 @@
"com_ui_navigate_results": "Navigate results",
"com_ui_new": "New",
"com_ui_new_chat": "New chat",
"com_ui_new_chat_in_project": "New chat in {{name}}",
"com_ui_new_conversation_title": "New Conversation Title",
"com_ui_new_project": "New project",
"com_ui_next": "Next",
"com_ui_next_result": "Next result",
"com_ui_no": "No",
@ -1279,6 +1289,8 @@
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_memories_title": "No memories yet",
"com_ui_no_personalization_available": "No personalization options are currently 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",
@ -1307,6 +1319,7 @@
"com_ui_omitted": "Omitted",
"com_ui_on": "On",
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
"com_ui_open_project": "Open project",
"com_ui_open_source_chat_new_tab": "Open Source Chat in New Tab",
"com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}",
"com_ui_open_var": "Open {{0}}",
@ -1337,6 +1350,17 @@
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_production": "Production",
"com_ui_project_chat_count": "{{count}} chats",
"com_ui_project_chat_count_single": "1 chat",
"com_ui_project_create_error": "Failed to create project",
"com_ui_project_delete_error": "Failed to delete 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_rename_error": "Failed to rename project",
"com_ui_project_update_error": "Failed to update project assignment",
"com_ui_project_updated": "Project assignment updated",
"com_ui_projects": "Projects",
"com_ui_prompt": "Prompt",
"com_ui_prompt_category_selector_aria": "Prompt's category selector",
"com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
@ -1393,11 +1417,13 @@
"com_ui_remote_agents_allow_share_public": "Allow users to grant API access to agents to all users",
"com_ui_remote_agents_allow_use": "Allow users to create API keys and query agents remotely",
"com_ui_remove_agent_from_chain": "Remove {{0}} from chain",
"com_ui_remove_from_project": "Remove from project",
"com_ui_remove_skill_var": "Remove {{0}}",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_rename": "Rename",
"com_ui_rename_conversation": "Rename Conversation",
"com_ui_rename_failed": "Failed to rename conversation",
"com_ui_rename_project": "Rename project",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset": "Reset",
"com_ui_reset_adjustments": "Reset adjustments",
@ -1454,6 +1480,7 @@
"com_ui_search_agent_category": "Search categories...",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
"com_ui_search_projects": "Search projects",
"com_ui_search_result_count": "{{count}} result found",
"com_ui_search_results_count": "{{count}} results found",
"com_ui_search_skills": "Search skills...",
@ -1467,6 +1494,7 @@
"com_ui_select_model": "Select a model",
"com_ui_select_options": "Select options...",
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
"com_ui_select_project": "Select project",
"com_ui_select_provider": "Select a provider",
"com_ui_select_provider_first": "Select a provider first",
"com_ui_select_region": "Select a region",
@ -1574,6 +1602,11 @@
"com_ui_skills_load_error": "Failed to load skills",
"com_ui_skills_manual_invoked": "Manually invoked skills",
"com_ui_skills_queued": "Skills queued for next submission",
"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",
"com_ui_sort_updated": "Updated",
"com_ui_special": "special",
"com_ui_special_var_current_date": "Current Date",
"com_ui_special_var_current_datetime": "Current Date & Time",
@ -1654,6 +1687,7 @@
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unassigned": "Unassigned",
"com_ui_unavailable": "Unavailable",
"com_ui_unfavorite": "Remove from favorites",
"com_ui_unknown": "Unknown",
@ -1743,6 +1777,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",

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { Spinner, useToastContext } from '@librechat/client';
import { useParams, useSearchParams } from 'react-router-dom';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
@ -13,6 +14,7 @@ import {
isNotFoundError,
isTemporaryConversation,
logger,
clearMessagesCache,
} from '~/utils';
import {
useAssistantListMap,
@ -21,7 +23,12 @@ import {
useNewConvo,
useLocalize,
} from '~/hooks';
import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
import {
useGetConvoIdQuery,
useGetStartupConfig,
useGetEndpointsQuery,
useProjectQuery,
} from '~/data-provider';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import { NotificationSeverity } from '~/common';
@ -29,9 +36,13 @@ import useAuthRedirect from './useAuthRedirect';
import temporaryStore from '~/store/temporary';
import store from '~/store';
const isValidChatProjectId = (projectId: string | null): projectId is string =>
projectId != null && /^[a-f\d]{24}$/i.test(projectId);
export default function ChatRoute() {
const { data: startupConfig } = useGetStartupConfig();
const { isAuthenticated, user, roles } = useAuthRedirect();
const queryClient = useQueryClient();
const defaultTemporaryChat = useRecoilValue(temporaryStore.defaultTemporaryChat);
const setIsTemporary = useRecoilCallback(
@ -46,11 +57,24 @@ export default function ChatRoute() {
const index = 0;
const [searchParams] = useSearchParams();
const { conversationId = '' } = useParams();
const projectIdParam = searchParams.get('projectId');
const chatProjectId = isValidChatProjectId(projectIdParam) ? projectIdParam : null;
useIdChangeEffect(conversationId);
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
const { newConversation } = useNewConvo();
const { showToast } = useToastContext();
const localize = useLocalize();
const projectQuery = useProjectQuery(chatProjectId, {
enabled: isAuthenticated && Boolean(chatProjectId),
retry: false,
staleTime: 30000,
cacheTime: 300000,
});
const verifiedChatProjectId = projectQuery.data?._id === chatProjectId ? chatProjectId : null;
const projectTemplate = useMemo(
() => (verifiedChatProjectId ? { chatProjectId: verifiedChatProjectId } : {}),
[verifiedChatProjectId],
);
const modelsQuery = useGetModelsQuery({
enabled: isAuthenticated,
@ -81,15 +105,27 @@ export default function ChatRoute() {
useEffect(() => {
// Wait for roles to load so hasAgentAccess has a definitive value in useNewConvo
const rolesLoaded = roles?.USER != null;
const isNewConvo = conversationId === Constants.NEW_CONVO;
const isDraftNewConvo = conversation?.conversationId === Constants.NEW_CONVO;
const draftProjectMismatch = verifiedChatProjectId
? conversation?.chatProjectId !== verifiedChatProjectId
: conversation?.chatProjectId != null;
const newConvoNeedsInit =
isNewConvo && (!conversation || (isDraftNewConvo && draftProjectMismatch));
const shouldSetConvo =
(startupConfig && rolesLoaded && !hasSetConversation.current && !modelsQuery.data?.initial) ??
(startupConfig &&
rolesLoaded &&
(!hasSetConversation.current || newConvoNeedsInit) &&
!modelsQuery.data?.initial) ??
false;
/* Early exit if startupConfig is not loaded and conversation is already set and only initial models have loaded */
if (!shouldSetConvo) {
return;
}
const isNewConvo = conversationId === Constants.NEW_CONVO;
if (isNewConvo && chatProjectId && projectQuery.isLoading) {
return;
}
const getNewConvoPreset = () => {
const result = getDefaultModelSpec(startupConfig);
@ -98,7 +134,7 @@ export default function ChatRoute() {
const queryParams: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key !== 'prompt' && key !== 'q' && key !== 'submit') {
if (key !== 'prompt' && key !== 'q' && key !== 'submit' && key !== 'projectId') {
queryParams[key] = value;
}
});
@ -114,9 +150,10 @@ export default function ChatRoute() {
const preset = getNewConvoPreset();
logger.log('conversation', 'ChatRoute, new convo effect', conversation);
clearMessagesCache(queryClient, conversation?.conversationId);
newConversation({
modelsData: modelsQuery.data,
template: conversation ? conversation : undefined,
template: projectTemplate,
...(preset ? { preset } : {}),
});
@ -161,9 +198,10 @@ export default function ChatRoute() {
const preset = getNewConvoPreset();
logger.log('conversation', 'ChatRoute new convo, assistants effect', conversation);
clearMessagesCache(queryClient, conversation?.conversationId);
newConversation({
modelsData: modelsQuery.data,
template: conversation ? conversation : undefined,
template: projectTemplate,
...(preset ? { preset } : {}),
});
hasSetConversation.current = true;
@ -189,6 +227,13 @@ export default function ChatRoute() {
endpointsQuery.data,
modelsQuery.data,
assistantListMap,
chatProjectId,
projectQuery.data?._id,
projectQuery.isLoading,
projectTemplate,
queryClient,
conversation?.chatProjectId,
conversation?.conversationId,
]);
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
@ -218,7 +263,7 @@ export default function ChatRoute() {
return (
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
<ChatView index={index} />
<ChatView index={index} project={verifiedChatProjectId ? projectQuery.data : undefined} />
</ToolCallsMapProvider>
);
}

View file

@ -41,6 +41,16 @@ const loadSkillsView = () =>
Component: m.default,
}));
const loadProjectsView = () =>
import('~/components/Projects').then((m) => ({
Component: m.ProjectsView,
}));
const loadProjectWorkspace = () =>
import('~/components/Projects').then((m) => ({
Component: m.ProjectWorkspace,
}));
const baseEl = document.querySelector('base');
const baseHref = baseEl?.getAttribute('href') || '/';
@ -152,6 +162,14 @@ export const router = createBrowserRouter(
path: 'skills/:skillId/edit',
lazy: loadSkillsView,
},
{
path: 'projects',
lazy: loadProjectsView,
},
{
path: 'projects/:projectId',
lazy: loadProjectWorkspace,
},
{
path: 'agents',
element: (

View file

@ -93,6 +93,9 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
if (shouldUpdateParams) {
const newParams = createChatSearchParams(newValue);
if (newValue.chatProjectId) {
newParams.set('projectId', newValue.chatProjectId);
}
const searchParams = createSearchParams(newParams);
const url = `${window.location.pathname}?${searchParams.toString()}`;
window.history.pushState({}, '', url);

View file

@ -78,6 +78,7 @@ const dateGroupsSet = new Set([
export const groupConversationsByDate = (
conversations: Array<TConversation | null>,
dateField: 'updatedAt' | 'createdAt' = 'updatedAt',
): GroupedConversations => {
if (!Array.isArray(conversations)) {
return [];
@ -93,8 +94,9 @@ export const groupConversationsByDate = (
seenConversationIds.add(conversation.conversationId);
let date: Date;
if (conversation.updatedAt) {
date = parseISO(conversation.updatedAt);
const dateValue = conversation[dateField] ?? conversation.updatedAt ?? conversation.createdAt;
if (dateValue) {
date = parseISO(dateValue);
} else {
date = now;
}
@ -131,7 +133,8 @@ export const groupConversationsByDate = (
sortedGroups.forEach((conversations) => {
conversations.sort(
(a: TConversation, b: TConversation) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
new Date(b[dateField] ?? b.updatedAt ?? 0).getTime() -
new Date(a[dateField] ?? a.updatedAt ?? 0).getTime(),
);
});
return Array.from(sortedGroups, ([key, value]) => [key, value]);
@ -142,6 +145,40 @@ export type ConversationCursorData = {
nextCursor?: string | null;
};
function getConversationQueryProjectId(queryKey: readonly unknown[]): string | undefined {
const params = queryKey[1];
if (!params || typeof params !== 'object') {
return undefined;
}
return (params as { projectId?: string }).projectId;
}
function conversationMatchesProjectQuery(
queryKey: readonly unknown[],
conversation: Pick<TConversation, 'chatProjectId'>,
): boolean {
const projectId = getConversationQueryProjectId(queryKey);
if (!projectId) {
return true;
}
if (projectId === 'unassigned') {
return !conversation.chatProjectId;
}
return conversation.chatProjectId === projectId;
}
/**
* Reads the project id from the current URL's `?projectId` param — the source of
* truth for a new chat's project scope (the conversation atom can lag behind it).
*/
export function getRouteChatProjectId(): string | null {
if (typeof window === 'undefined') {
return null;
}
const projectId = new URLSearchParams(window.location.search).get('projectId');
return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
}
// === InfiniteData helpers for cursor-based convo queries ===
export function findConversationInInfinite(
@ -208,6 +245,9 @@ export function addConversationToAllConversationsQueries(
.findAll([QueryKeys.allConversations], { exact: false });
for (const query of queries) {
if (!conversationMatchesProjectQuery(query.queryKey, newConversation)) {
continue;
}
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (old) => {
if (
!old ||
@ -322,6 +362,9 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver
.findAll([QueryKeys.allConversations], { exact: false });
for (const query of queries) {
if (!conversationMatchesProjectQuery(query.queryKey, newConvo)) {
continue;
}
queryClient.setQueryData<InfiniteData<ConversationCursorData>>(query.queryKey, (oldData) => {
if (!oldData) {
return oldData;
@ -381,6 +424,9 @@ export function upsertConvoInAllQueries(
const now = new Date().toISOString();
if (pageIdx === -1) {
if (!conversationMatchesProjectQuery(query.queryKey, nextConvo)) {
return oldData;
}
const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null };
return {
...oldData,
@ -404,6 +450,10 @@ export function upsertConvoInAllQueries(
updatedAt: nextConvo.updatedAt ?? (moveToTop ? now : found.updatedAt),
};
if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
return removeConvoFromInfinitePages(oldData, updated.conversationId ?? '');
}
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
return {
...oldData,
@ -480,6 +530,10 @@ export function updateConvoInAllQueries(
? { ...updater(found), updatedAt: new Date().toISOString() }
: updater(found);
if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
return removeConvoFromInfinitePages(oldData, conversationId);
}
// If not moving to top, or already at top of page 0, update in place
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
return {

View file

@ -0,0 +1,110 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { MOCK_ENDPOINTS, mockReply, selectMockEndpoint, sendMessage } from './helpers';
/**
* Creates a project from the all-projects page and returns its id.
* Project creation navigates to the project workspace (`/projects/:id`).
*/
async function createProject(page: Page, name: string): Promise<string> {
await page.goto('/projects', { timeout: 10000 });
await page.getByRole('button', { name: 'New project' }).first().click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox', { name: 'Project name' }).fill(name);
await dialog.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name })).toBeVisible();
const projectId = new URL(page.url()).pathname.split('/projects/')[1];
expect(projectId).toBeTruthy();
return projectId;
}
const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
test.describe('chat projects', () => {
test('creates a project and lists it on the all-projects page', async ({ page }) => {
test.setTimeout(60000);
const name = uniqueName('E2E Project');
await createProject(page, name);
await page.goto('/projects', { timeout: 10000 });
await expect(page.getByRole('button', { name }).first()).toBeVisible();
});
test('starts a project-scoped chat and files it under the project', async ({ page }) => {
test.setTimeout(120000);
const name = uniqueName('E2E Project');
const projectId = await createProject(page, name);
// The workspace exposes a composer entry to start a chat in the project.
await expect(page.getByRole('button', { name: `New chat in ${name}` }).first()).toBeVisible();
// Open the project-scoped new-chat landing directly.
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
// The interactive project chip is present, and the composer is scoped.
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message input' });
await expect(input).toHaveAttribute('placeholder', new RegExp(name));
// Switch to a mock endpoint so the message streams without a real API key;
// the project scope must be retained across the model switch.
await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
// Send the message; it streams a reply and the new chat opens at /c/:id.
const response = await sendMessage(page, 'hello from a project');
expect(response.ok()).toBeTruthy();
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
// Expand the project in the sidebar and confirm the chat is filed under it.
const projectRow = page.getByRole('button', { name }).first();
if ((await projectRow.getAttribute('aria-expanded')) !== 'true') {
await projectRow.click();
}
await expect(
page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(),
).toBeVisible();
});
test('removes the project scope via the chip ×', async ({ page }) => {
test.setTimeout(60000);
const name = uniqueName('E2E Project');
const projectId = await createProject(page, name);
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
await page.getByRole('button', { name: 'Remove from project' }).click();
// Chip disappears and the URL drops the project scope.
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeHidden();
await expect(page).toHaveURL((url) => !url.searchParams.has('projectId'));
});
test('switches the project via the chip combobox', async ({ page }) => {
test.setTimeout(90000);
const nameA = uniqueName('E2E Project A');
const nameB = uniqueName('E2E Project B');
await createProject(page, nameA);
const projectIdA = new URL(page.url()).pathname.split('/projects/')[1];
await createProject(page, nameB);
const projectIdB = new URL(page.url()).pathname.split('/projects/')[1];
await page.goto(`/c/new?projectId=${projectIdA}`, { timeout: 10000 });
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
// Open the combobox and pick the other project.
await page.locator('#project-landing-select').click();
await page.getByRole('option', { name: nameB }).click();
await expect(page).toHaveURL(new RegExp(`projectId=${projectIdB}`));
await expect(page.getByRole('textbox', { name: 'Message input' })).toHaveAttribute(
'placeholder',
new RegExp(nameB),
);
});
});

View file

@ -42,6 +42,7 @@ export async function ensureRequiredCollectionsExist(db: mongo.Db): Promise<void
'agents', // Agents collection
'promptgroups', // Prompt groups collection
'projects', // Projects collection
'chatprojects', // Private chat organization projects
];
logger.debug('Ensuring required collections exist for permission system');

View file

@ -41,6 +41,8 @@ export * from './modelSpecs';
export * from './agents';
/* Prompts */
export * from './prompts';
/* Projects */
export * from './projects';
/* Skills */
export * from './skills';
/* Endpoints */

View file

@ -0,0 +1,220 @@
import { isValidObjectIdString, logger } from '@librechat/data-schemas';
import type {
ChatProjectMethods,
ChatProjectSortBy,
ChatProjectSortDirection,
CreateChatProjectInput,
UpdateChatProjectInput,
} from '@librechat/data-schemas';
import type { Request, Response } from 'express';
const PROJECT_NOT_FOUND = 'Project not found';
const CONVERSATION_NOT_FOUND = 'Conversation not found';
const PROJECT_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
interface ProjectUser {
id: string;
_id?: {
toString(): string;
};
}
interface ProjectRequest extends Request {
user?: ProjectUser;
}
type ProjectHandlerDependencies = Pick<
ChatProjectMethods,
| 'listChatProjects'
| 'createChatProject'
| 'getChatProject'
| 'updateChatProject'
| 'deleteChatProject'
| 'assignConversationToProject'
>;
const getUserId = (req: ProjectRequest): string => req.user?.id ?? req.user?._id?.toString() ?? '';
const queryString = (value: Request['query'][string]): string | undefined => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return queryString(value[0]);
}
return undefined;
};
const normalizeString = (value: string | null | undefined): string =>
typeof value === 'string' ? value.trim() : '';
const normalizeLimit = (value: Request['query'][string]): number => {
const limit = parseInt(queryString(value) ?? '', 10);
if (!Number.isFinite(limit)) {
return 25;
}
return Math.min(Math.max(limit, 1), 100);
};
const normalizeSortBy = (value: Request['query'][string]): ChatProjectSortBy | undefined => {
const sortBy = queryString(value);
return PROJECT_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
? (sortBy as ChatProjectSortBy)
: undefined;
};
const normalizeSortDirection = (
value: Request['query'][string],
): ChatProjectSortDirection | undefined => {
const sortDirection = queryString(value);
return sortDirection === 'asc' || sortDirection === 'desc' ? sortDirection : undefined;
};
const createProjectInput = (req: ProjectRequest): CreateChatProjectInput | null => {
const name = normalizeString(req.body?.name);
if (!name) {
return null;
}
return {
name,
description: typeof req.body?.description === 'string' ? req.body.description : '',
};
};
export function createProjectHandlers(deps: ProjectHandlerDependencies) {
async function listProjects(req: ProjectRequest, res: Response) {
try {
const result = await deps.listChatProjects(getUserId(req), {
cursor: queryString(req.query.cursor),
limit: normalizeLimit(req.query.limit),
sortBy: normalizeSortBy(req.query.sortBy),
sortDirection: normalizeSortDirection(req.query.sortDirection),
search: queryString(req.query.search),
});
return res.status(200).json(result);
} catch (error) {
logger.error('[projects] Error listing projects', error);
return res.status(500).json({ error: 'Error listing projects' });
}
}
async function createProject(req: ProjectRequest, res: Response) {
const input = createProjectInput(req);
if (!input) {
return res.status(400).json({ error: 'name is required' });
}
try {
const project = await deps.createChatProject(getUserId(req), input);
return res.status(201).json(project);
} catch (error) {
logger.error('[projects] Error creating project', error);
return res.status(500).json({ error: 'Error creating project' });
}
}
async function assignConversationToProject(req: ProjectRequest, res: Response) {
const { conversationId } = req.params;
const projectId = req.body?.projectId ?? null;
if (projectId !== null && typeof projectId !== 'string') {
return res.status(400).json({ error: 'projectId must be a string or null' });
}
try {
const result = await deps.assignConversationToProject(
getUserId(req),
conversationId,
projectId,
);
if (!result) {
return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
}
return res.status(200).json(result);
} catch (error) {
if (error instanceof Error && error.message === PROJECT_NOT_FOUND) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
logger.error('[projects] Error assigning conversation to project', error);
return res.status(500).json({ error: 'Error assigning conversation to project' });
}
}
async function getProject(req: ProjectRequest, res: Response) {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
try {
const project = await deps.getChatProject(getUserId(req), projectId);
if (!project) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(project);
} catch (error) {
logger.error('[projects] Error getting project', error);
return res.status(500).json({ error: 'Error getting project' });
}
}
async function updateProject(req: ProjectRequest, res: Response) {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
const input: UpdateChatProjectInput = {};
if (req.body?.name !== undefined) {
const name = normalizeString(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
input.name = name;
}
if (req.body?.description !== undefined) {
input.description = typeof req.body.description === 'string' ? req.body.description : '';
}
try {
const project = await deps.updateChatProject(getUserId(req), projectId, input);
if (!project) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(project);
} catch (error) {
logger.error('[projects] Error updating project', error);
return res.status(500).json({ error: 'Error updating project' });
}
}
async function deleteProject(req: ProjectRequest, res: Response) {
const { projectId } = req.params;
if (!isValidObjectIdString(projectId)) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
try {
const result = await deps.deleteChatProject(getUserId(req), projectId);
if (!result.deletedCount) {
return res.status(404).json({ error: PROJECT_NOT_FOUND });
}
return res.status(200).json(result);
} catch (error) {
logger.error('[projects] Error deleting project', error);
return res.status(500).json({ error: 'Error deleting project' });
}
}
return {
listProjects,
createProject,
assignConversationToProject,
getProject,
updateProject,
deleteProject,
};
}

View file

@ -0,0 +1 @@
export * from './handlers';

View file

@ -24,6 +24,7 @@ interface ControlComboboxProps {
disabled?: boolean;
iconSide?: 'left' | 'right';
selectId?: string;
placement?: Ariakit.SelectStoreProps['placement'];
}
const ROW_HEIGHT = 36;
@ -45,6 +46,7 @@ function ControlCombobox({
iconClassName,
iconSide = 'left',
selectId,
placement,
}: ControlComboboxProps) {
const [searchValue, setSearchValue] = useState('');
const buttonRef = useRef<HTMLButtonElement>(null);
@ -69,6 +71,7 @@ function ControlCombobox({
defaultItems: items.map(getItem),
value: selectedValue,
setValue,
placement,
});
const matches = useMemo(() => {

View file

@ -126,6 +126,17 @@ export const forkConversation = () => `${conversationsRoot}/fork`;
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
export const projectsRoot = `${BASE_URL}/api/projects`;
export const projects = (params: q.ProjectListParams = {}) => {
return `${projectsRoot}${buildQuery(params)}`;
};
export const projectById = (id: string) => `${projectsRoot}/${encodeURIComponent(id)}`;
export const projectConversation = (conversationId: string) =>
`${projectsRoot}/conversations/${encodeURIComponent(conversationId)}`;
export const search = (q: string, cursor?: string | null) =>
`${BASE_URL}/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`;

View file

@ -212,6 +212,7 @@ function mergeBedrockAnthropicBetaHeaders(existing: unknown, generated: string[]
export const bedrockInputSchema = s.tConversationSchema
.pick({
/* LibreChat params; optionType: 'conversation' */
chatProjectId: true,
modelLabel: true,
promptPrefix: true,
resendFiles: true,
@ -268,6 +269,7 @@ export type BedrockConverseInput = z.infer<typeof bedrockInputSchema>;
export const bedrockInputParser = s.tConversationSchema
.pick({
/* LibreChat params; optionType: 'conversation' */
chatProjectId: true,
modelLabel: true,
promptPrefix: true,
resendFiles: true,
@ -297,6 +299,7 @@ export const bedrockInputParser = s.tConversationSchema
.catchall(z.any())
.transform((data) => {
const knownKeys = [
'chatProjectId',
'modelLabel',
'promptPrefix',
'resendFiles',

View file

@ -63,6 +63,7 @@ export const excludedKeys = new Set([
'files',
'spec',
'disableParams',
'chatProjectId',
]);
export enum SettingsViews {

View file

@ -794,6 +794,34 @@ export function archiveConversation(
return request.post(endpoints.archiveConversation(), { arg: payload });
}
export function listProjects(params?: q.ProjectListParams): Promise<q.ProjectListResponse> {
return request.get(endpoints.projects(params ?? {}));
}
export function createProject(payload: t.TCreateChatProjectRequest): Promise<t.TChatProject> {
return request.post(endpoints.projects(), payload);
}
export function getProjectById(projectId: string): Promise<t.TChatProject> {
return request.get(endpoints.projectById(projectId));
}
export function updateProject(payload: t.TUpdateChatProjectRequest): Promise<t.TChatProject> {
const { projectId, ...data } = payload;
return request.patch(endpoints.projectById(projectId), data);
}
export function deleteProject(projectId: string): Promise<t.TDeleteChatProjectResponse> {
return request.delete(endpoints.projectById(projectId));
}
export function assignConversationToProject(
payload: t.TAssignConversationToProjectRequest,
): Promise<t.TAssignConversationToProjectResponse> {
const { conversationId, projectId } = payload;
return request.put(endpoints.projectConversation(conversationId), { projectId });
}
export function genTitle(payload: m.TGenTitleRequest): Promise<m.TGenTitleResponse> {
return request.get(endpoints.genTitle(payload.conversationId));
}

View file

@ -42,6 +42,9 @@ export enum QueryKeys {
promptGroups = 'promptGroups',
allPromptGroups = 'allPromptGroups',
promptGroup = 'promptGroup',
projects = 'projects',
project = 'project',
projectConversations = 'projectConversations',
categories = 'categories',
randomPrompts = 'randomPrompts',
agentCategories = 'agentCategories',
@ -112,6 +115,10 @@ export enum MutationKeys {
enableTwoFactor = 'enableTwoFactor',
verifyTwoFactor = 'verifyTwoFactor',
updateMemoryPreferences = 'updateMemoryPreferences',
createProject = 'createProject',
updateProject = 'updateProject',
deleteProject = 'deleteProject',
assignConversationToProject = 'assignConversationToProject',
/* Skill mutations from the original UI PR — tree/node operations are
* phase 2 and currently stubbed in the data-service layer. */
createSkillNode = 'createSkillNode',

View file

@ -463,6 +463,14 @@ describe('googleSettings', () => {
});
expect(result.maxOutputTokens).toBeUndefined();
});
it('preserves chat project membership metadata', () => {
const result = compactGoogleSchema.parse({
model: 'gemini-2.5-pro',
chatProjectId: 'project-1',
});
expect(result.chatProjectId).toBe('project-1');
});
});
});

View file

@ -859,6 +859,7 @@ export const tConversationSchema = z.object({
examples: z.array(tExampleSchema).optional(),
/* DB */
tags: z.array(z.string()).optional(),
chatProjectId: z.string().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
/* Files */
@ -914,6 +915,7 @@ export const tConversationSchema = z.object({
export const tPresetSchema = tConversationSchema
.omit({
conversationId: true,
chatProjectId: true,
createdAt: true,
updatedAt: true,
title: true,
@ -1094,6 +1096,7 @@ export const tConversationTagSchema = z.object({
export type TConversationTag = z.infer<typeof tConversationTagSchema>;
export const googleBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
modelLabel: true,
promptPrefix: true,
@ -1165,6 +1168,7 @@ export function removeNullishValues<T extends Record<string, unknown>>(
}
const assistantBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
assistant_id: true,
instructions: true,
@ -1200,6 +1204,7 @@ export const assistantSchema = assistantBaseSchema
}));
const compactAssistantBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
assistant_id: true,
instructions: true,
@ -1215,6 +1220,7 @@ export const compactAssistantSchema = compactAssistantBaseSchema
.catch(() => ({}));
export const agentsBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
modelLabel: true,
temperature: true,
@ -1268,6 +1274,7 @@ export const agentsSchema = agentsBaseSchema
}));
export const openAIBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
modelLabel: true,
chatGptLabel: true,
@ -1324,6 +1331,7 @@ export const compactGoogleSchema = googleBaseSchema
.catch(() => ({}));
export const anthropicBaseSchema = tConversationSchema.pick({
chatProjectId: true,
model: true,
modelLabel: true,
promptPrefix: true,
@ -1365,6 +1373,7 @@ export const tBannerSchema = z.object({
export type TBanner = z.infer<typeof tBannerSchema>;
export const compactAgentsBaseSchema = tConversationSchema.pick({
chatProjectId: true,
spec: true,
// model: true,
iconURL: true,

View file

@ -71,6 +71,7 @@ export type TEndpointOption = Pick<
| 'file_ids'
// System field
| 'system'
| 'chatProjectId'
// Google examples
| 'examples'
// Context
@ -287,6 +288,43 @@ export type TUpdateConversationRequest = {
export type TUpdateConversationResponse = TConversation;
export type TChatProject = {
_id: string;
name: string;
description?: string;
user?: string;
conversationCount: number;
lastConversationAt?: string | null;
lastConversationId?: string | null;
createdAt: string;
updatedAt: string;
};
export type TCreateChatProjectRequest = {
name: string;
description?: string;
};
export type TUpdateChatProjectRequest = Partial<TCreateChatProjectRequest> & {
projectId: string;
};
export type TDeleteChatProjectResponse = {
deletedCount: number;
modifiedCount: number;
};
export type TAssignConversationToProjectRequest = {
conversationId: string;
projectId: string | null;
};
export type TAssignConversationToProjectResponse = {
conversation: TConversation;
previousProjectId: string | null;
projectId: string | null;
};
export type TDeleteConversationRequest = {
conversationId?: string;
thread_id?: string;

View file

@ -19,11 +19,12 @@ export type ConversationListParams = {
sortDirection?: 'asc' | 'desc';
tags?: string[];
search?: string;
projectId?: string;
};
export type MinimalConversation = Pick<
s.TConversation,
'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user'
'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user' | 'chatProjectId'
>;
export type ConversationListResponse = {
@ -37,6 +38,21 @@ export type ConversationUpdater = (
conversation: s.TConversation,
) => ConversationData;
export type ProjectListParams = {
cursor?: string;
limit?: number;
sortBy?: 'name' | 'createdAt' | 'lastConversationAt';
sortDirection?: 'asc' | 'desc';
search?: string;
};
export type ProjectListResponse = {
projects: t.TChatProject[];
nextCursor: string | null;
};
export type ProjectData = InfiniteData<ProjectListResponse>;
/* Messages */
export type MessagesListParams = {
cursor?: string | null;

View file

@ -0,0 +1,293 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createModels } from '~/models';
import type { IChatProject, IConversation } from '~/types';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
let mongoServer: InstanceType<typeof MongoMemoryServer>;
let ChatProject: mongoose.Model<IChatProject>;
let Conversation: mongoose.Model<IConversation>;
let methods: ChatProjectMethods;
let modelsToCleanup: string[] = [];
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
const models = createModels(mongoose);
modelsToCleanup = Object.keys(models);
Object.assign(mongoose.models, models);
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
methods = createChatProjectMethods(mongoose);
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
});
afterEach(async () => {
await ChatProject.deleteMany({});
await Conversation.deleteMany({});
});
async function createConversation(user: string, conversationId: string, title: string) {
return await Conversation.create({
conversationId,
title,
user,
endpoint: 'openAI',
});
}
describe('ChatProject methods', () => {
const user = 'user-1';
const otherUser = 'user-2';
it('creates, reads, updates, and lists private projects', async () => {
const project = await methods.createChatProject(user, {
name: 'Customer Alpha',
description: 'Support work',
});
expect(project.name).toBe('Customer Alpha');
expect(project.conversationCount).toBe(0);
const readProject = await methods.getChatProject(user, project._id!.toString());
expect(readProject?.description).toBe('Support work');
const updatedProject = await methods.updateChatProject(user, project._id!.toString(), {
name: 'Customer Alpha Updated',
});
expect(updatedProject?.name).toBe('Customer Alpha Updated');
const list = await methods.listChatProjects(user, { sortBy: 'name', sortDirection: 'asc' });
expect(list.projects).toHaveLength(1);
expect(list.projects[0].name).toBe('Customer Alpha Updated');
});
it('paginates projects deterministically when latest activity is null', async () => {
const staleProject = await methods.createChatProject(user, { name: 'Stale' });
await methods.createChatProject(user, { name: 'Quiet A' });
await methods.createChatProject(user, { name: 'Quiet B' });
const recentProject = await methods.createChatProject(user, { name: 'Recent' });
await ChatProject.updateOne(
{ _id: staleProject._id },
{ $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
);
await ChatProject.updateOne(
{ _id: recentProject._id },
{ $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
);
const firstPage = await methods.listChatProjects(user, {
sortBy: 'lastConversationAt',
sortDirection: 'desc',
limit: 3,
});
const secondPage = await methods.listChatProjects(user, {
sortBy: 'lastConversationAt',
sortDirection: 'desc',
limit: 3,
cursor: firstPage.nextCursor,
});
const names = [...firstPage.projects, ...secondPage.projects].map((project) => project.name);
expect(firstPage.projects[0].name).toBe('Recent');
expect(firstPage.projects[1].name).toBe('Stale');
expect(firstPage.nextCursor).toBeTruthy();
expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
expect(names).toEqual(expect.arrayContaining(['Recent', 'Stale', 'Quiet A', 'Quiet B']));
expect(new Set(names).size).toBe(4);
const invalidCursor = Buffer.from(
JSON.stringify({ primary: 'not-a-date', id: recentProject._id!.toString() }),
).toString('base64');
const invalidCursorPage = await methods.listChatProjects(user, {
sortBy: 'lastConversationAt',
sortDirection: 'desc',
limit: 1,
cursor: invalidCursor,
});
expect(invalidCursorPage.projects[0].name).toBe('Recent');
});
it('paginates chat-less projects when a page ends on the last dated project', async () => {
const staleProject = await methods.createChatProject(user, { name: 'Stale' });
await methods.createChatProject(user, { name: 'Quiet A' });
await methods.createChatProject(user, { name: 'Quiet B' });
const recentProject = await methods.createChatProject(user, { name: 'Recent' });
await ChatProject.updateOne(
{ _id: staleProject._id },
{ $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
);
await ChatProject.updateOne(
{ _id: recentProject._id },
{ $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
);
// limit equals the number of dated projects, so the cursor lands on a dated
// project; the null (chat-less) projects must still appear on the next page.
const firstPage = await methods.listChatProjects(user, {
sortBy: 'lastConversationAt',
sortDirection: 'desc',
limit: 2,
});
const secondPage = await methods.listChatProjects(user, {
sortBy: 'lastConversationAt',
sortDirection: 'desc',
limit: 2,
cursor: firstPage.nextCursor,
});
expect(firstPage.projects.map((project) => project.name)).toEqual(['Recent', 'Stale']);
expect(firstPage.nextCursor).toBeTruthy();
expect(secondPage.projects.map((project) => project.name).sort()).toEqual([
'Quiet A',
'Quiet B',
]);
expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
});
it('assigns many conversations to one project and updates stats', async () => {
const project = await methods.createChatProject(user, { name: 'Customer Alpha' });
await createConversation(user, 'convo-1', 'First');
await createConversation(user, 'convo-2', 'Second');
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
await methods.assignConversationToProject(user, 'convo-2', project._id!.toString());
const conversations = await Conversation.find({
user,
chatProjectId: project._id!.toString(),
}).lean<IConversation[]>();
const refreshedProject = await methods.getChatProject(user, project._id!.toString());
expect(conversations).toHaveLength(2);
expect(refreshedProject?.conversationCount).toBe(2);
expect(refreshedProject?.lastConversationId).toBeDefined();
});
it('excludes retention-hidden conversations from project stats', async () => {
const project = await methods.createChatProject(user, { name: 'Visible Stats' });
const chatProjectId = project._id!.toString();
const visibleDate = new Date('2026-01-01T00:00:00.000Z');
const hiddenDate = new Date('2026-02-01T00:00:00.000Z');
await Conversation.collection.insertMany([
{
conversationId: 'visible-convo',
title: 'Visible',
user,
endpoint: 'openAI',
chatProjectId,
isTemporary: false,
expiredAt: null,
createdAt: visibleDate,
updatedAt: visibleDate,
},
{
conversationId: 'temporary-convo',
title: 'Temporary',
user,
endpoint: 'openAI',
chatProjectId,
isTemporary: true,
expiredAt: new Date('2027-03-01T00:00:00.000Z'),
createdAt: hiddenDate,
updatedAt: hiddenDate,
},
{
conversationId: 'expired-convo',
title: 'Expired',
user,
endpoint: 'openAI',
chatProjectId,
isTemporary: false,
expiredAt: new Date('2025-12-01T00:00:00.000Z'),
createdAt: hiddenDate,
updatedAt: hiddenDate,
},
]);
const refreshedProject = await methods.refreshChatProjectStats(user, chatProjectId);
expect(refreshedProject?.conversationCount).toBe(1);
expect(refreshedProject?.lastConversationId).toBe('visible-convo');
expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(visibleDate.toISOString());
});
it('enforces one project per chat when moving conversations', async () => {
const firstProject = await methods.createChatProject(user, { name: 'First' });
const secondProject = await methods.createChatProject(user, { name: 'Second' });
await createConversation(user, 'convo-1', 'First');
await methods.assignConversationToProject(user, 'convo-1', firstProject._id!.toString());
await methods.assignConversationToProject(user, 'convo-1', secondProject._id!.toString());
const movedConversation = await Conversation.findOne({
user,
conversationId: 'convo-1',
}).lean<IConversation>();
const refreshedFirst = await methods.getChatProject(user, firstProject._id!.toString());
const refreshedSecond = await methods.getChatProject(user, secondProject._id!.toString());
expect(movedConversation?.chatProjectId).toBe(secondProject._id!.toString());
expect(refreshedFirst?.conversationCount).toBe(0);
expect(refreshedSecond?.conversationCount).toBe(1);
});
it('deleting a project unassigns chats instead of deleting them', async () => {
const project = await methods.createChatProject(user, { name: 'Delete me' });
await createConversation(user, 'convo-1', 'First');
await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
const result = await methods.deleteChatProject(user, project._id!.toString());
const conversation = await Conversation.findOne({
user,
conversationId: 'convo-1',
}).lean<IConversation>();
expect(result.deletedCount).toBe(1);
expect(result.modifiedCount).toBe(1);
expect(conversation).not.toBeNull();
expect(conversation?.chatProjectId).toBeUndefined();
});
it('isolates projects and assignments by user', async () => {
const project = await methods.createChatProject(user, { name: 'Mine' });
await createConversation(otherUser, 'convo-1', 'Theirs');
const otherRead = await methods.getChatProject(otherUser, project._id!.toString());
const assignment = await methods.assignConversationToProject(
user,
'convo-1',
project._id!.toString(),
);
const deleteResult = await methods.deleteChatProject(otherUser, project._id!.toString());
expect(otherRead).toBeNull();
expect(assignment).toBeNull();
expect(deleteResult.deletedCount).toBe(0);
});
});

View file

@ -0,0 +1,463 @@
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import logger from '~/config/winston';
import { isValidObjectIdString } from '~/utils/objectId';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
import { escapeRegExp } from '~/utils/string';
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
export type ChatProjectSortDirection = 'asc' | 'desc';
export type CreateChatProjectInput = {
name: string;
description?: string | null;
};
export type UpdateChatProjectInput = Partial<CreateChatProjectInput>;
export type ListChatProjectsOptions = {
cursor?: string | null;
limit?: number;
sortBy?: ChatProjectSortBy;
sortDirection?: ChatProjectSortDirection;
search?: string;
};
export type ListChatProjectsResult = {
projects: IChatProject[];
nextCursor: string | null;
};
export type DeleteChatProjectResult = {
deletedCount: number;
modifiedCount: number;
};
export type AssignConversationToProjectResult = {
conversation: IConversation;
previousProjectId: string | null;
projectId: string | null;
};
export interface ChatProjectMethods {
createChatProject(user: string, input: CreateChatProjectInput): Promise<IChatProject>;
getChatProject(user: string, projectId: string): Promise<IChatProject | null>;
listChatProjects(
user: string,
options?: ListChatProjectsOptions,
): Promise<ListChatProjectsResult>;
updateChatProject(
user: string,
projectId: string,
input: UpdateChatProjectInput,
): Promise<IChatProject | null>;
deleteChatProject(user: string, projectId: string): Promise<DeleteChatProjectResult>;
assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
): Promise<AssignConversationToProjectResult | null>;
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
}
type ProjectCursor = {
primary: string | null;
id: string;
};
type ProjectLean = IChatProject & { _id: Types.ObjectId };
const VALID_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
function normalizeSortBy(sortBy?: string): ChatProjectSortBy {
return VALID_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
? (sortBy as ChatProjectSortBy)
: 'lastConversationAt';
}
function normalizeSortDirection(sortDirection?: string): ChatProjectSortDirection {
return sortDirection === 'asc' ? 'asc' : 'desc';
}
function normalizeLimit(limit?: number): number {
if (!Number.isFinite(limit) || !limit) {
return 25;
}
return Math.min(Math.max(Math.floor(limit), 1), 100);
}
function sanitizeProjectInput(input: CreateChatProjectInput): CreateChatProjectInput {
return {
name: input.name.trim().slice(0, 100),
description: input.description?.trim().slice(0, 1000) ?? '',
};
}
function parseCursor(cursor?: string | null): ProjectCursor | null {
if (!cursor) {
return null;
}
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()) as ProjectCursor;
if (!decoded.id || !isValidObjectIdString(decoded.id)) {
return null;
}
return {
primary: decoded.primary ?? null,
id: decoded.id,
};
} catch {
logger.warn('[listChatProjects] Invalid cursor format, starting from beginning');
return null;
}
}
function encodeCursor(project: ProjectLean, sortBy: ChatProjectSortBy): string {
let primary: string | null = null;
if (sortBy === 'name') {
primary = project.name;
} else {
const date = project[sortBy];
primary = date instanceof Date ? date.toISOString() : null;
}
return Buffer.from(JSON.stringify({ primary, id: project._id.toString() })).toString('base64');
}
function cursorPrimaryValue(
primary: string | null,
sortBy: ChatProjectSortBy,
): string | Date | null | undefined {
if (primary == null) {
return sortBy === 'lastConversationAt' ? null : undefined;
}
if (sortBy === 'name') {
return primary;
}
const date = new Date(primary);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function createCursorFilter(
mongoose: typeof import('mongoose'),
cursor: ProjectCursor | null,
sortBy: ChatProjectSortBy,
sortDirection: ChatProjectSortDirection,
): FilterQuery<IChatProjectDocument> | null {
if (!cursor) {
return null;
}
const op = sortDirection === 'asc' ? '$gt' : '$lt';
const id = new mongoose.Types.ObjectId(cursor.id);
const primary = cursorPrimaryValue(cursor.primary, sortBy);
if (primary === undefined) {
logger.warn('[listChatProjects] Invalid cursor primary value, starting from beginning');
return null;
}
const branches: FilterQuery<IChatProjectDocument>[] = [
{ [sortBy]: { [op]: primary } },
{ [sortBy]: primary, _id: { [op]: id } },
];
/**
* Projects with no conversations have `lastConversationAt: null`, which sorts
* after all dated projects in descending order. A `$lt: <date>` predicate does
* not match null, so once the cursor moves past the dated projects we must
* include the null bucket explicitly, otherwise empty projects never paginate.
*/
if (sortBy === 'lastConversationAt' && sortDirection === 'desc' && primary instanceof Date) {
branches.push({ lastConversationAt: null } as FilterQuery<IChatProjectDocument>);
}
return { $or: branches } as FilterQuery<IChatProjectDocument>;
}
function visibleProjectConversationFilter(
user: string,
projectId: string,
): FilterQuery<IConversation> {
return {
$and: [
{ user, chatProjectId: projectId },
{ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] },
buildRetentionVisibilityFilter<IConversation>(),
],
} as FilterQuery<IConversation>;
}
export async function refreshChatProjectStatsForUser(
mongoose: typeof import('mongoose'),
user: string,
projectId: string,
): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
const conversationFilter = visibleProjectConversationFilter(user, projectId);
const [conversationCount, latestConversation] = await Promise.all([
Conversation.countDocuments(conversationFilter),
Conversation.findOne(conversationFilter)
.select('conversationId updatedAt createdAt')
.sort({ updatedAt: -1, _id: -1 })
.lean<IConversation>(),
]);
return await ChatProject.findOneAndUpdate(
projectFilter,
{
$set: {
conversationCount,
lastConversationAt: latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
lastConversationId: latestConversation?.conversationId ?? null,
},
},
{ new: true },
).lean<IChatProject>();
}
export async function updateChatProjectLastConversationForUser(
mongoose: typeof import('mongoose'),
user: string,
projectId: string,
conversation: Pick<IConversation, 'conversationId' | 'createdAt' | 'updatedAt'>,
incrementCount = false,
): Promise<void> {
if (!isValidObjectIdString(projectId) || !conversation.conversationId) {
return;
}
const lastConversationAt = conversation.updatedAt ?? conversation.createdAt ?? new Date();
const update: Record<string, unknown> = {
$set: {
lastConversationAt,
lastConversationId: conversation.conversationId,
},
};
if (incrementCount) {
update.$inc = { conversationCount: 1 };
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
}
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
async function createChatProject(
user: string,
input: CreateChatProjectInput,
): Promise<IChatProject> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const sanitized = sanitizeProjectInput(input);
if (!sanitized.name) {
throw new Error('Project name is required');
}
const project = await ChatProject.create({
...sanitized,
user,
conversationCount: 0,
lastConversationAt: null,
lastConversationId: null,
});
return project.toObject() as IChatProject;
}
async function getChatProject(user: string, projectId: string): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
return await ChatProject.findOne({
_id: new mongoose.Types.ObjectId(projectId),
user,
}).lean<IChatProject>();
}
async function listChatProjects(
user: string,
options: ListChatProjectsOptions = {},
): Promise<ListChatProjectsResult> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const limit = normalizeLimit(options.limit);
const sortBy = normalizeSortBy(options.sortBy);
const sortDirection = normalizeSortDirection(options.sortDirection);
const sortOrder: SortOrder = sortDirection === 'asc' ? 1 : -1;
const filters: FilterQuery<IChatProjectDocument>[] = [{ user }];
if (options.search?.trim()) {
filters.push({ name: { $regex: escapeRegExp(options.search.trim()), $options: 'i' } });
}
const cursorFilter = createCursorFilter(
mongoose,
parseCursor(options.cursor),
sortBy,
sortDirection,
);
if (cursorFilter) {
filters.push(cursorFilter);
}
const query =
filters.length === 1 ? filters[0] : ({ $and: filters } as FilterQuery<IChatProjectDocument>);
const projects = await ChatProject.find(query)
.sort({ [sortBy]: sortOrder, _id: sortOrder })
.limit(limit + 1)
.lean<ProjectLean[]>();
let nextCursor: string | null = null;
if (projects.length > limit) {
projects.pop();
const lastProject = projects[projects.length - 1];
if (lastProject) {
nextCursor = encodeCursor(lastProject, sortBy);
}
}
return { projects, nextCursor };
}
async function updateChatProject(
user: string,
projectId: string,
input: UpdateChatProjectInput,
): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const update: Partial<Pick<IChatProject, 'name' | 'description'>> = {};
if (typeof input.name === 'string') {
const name = input.name.trim().slice(0, 100);
if (!name) {
throw new Error('Project name is required');
}
update.name = name;
}
if (input.description !== undefined) {
update.description = input.description?.trim().slice(0, 1000) ?? '';
}
return await ChatProject.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(projectId), user },
{ $set: update },
{ new: true, runValidators: true },
).lean<IChatProject>();
}
async function deleteChatProject(
user: string,
projectId: string,
): Promise<DeleteChatProjectResult> {
if (!isValidObjectIdString(projectId)) {
return { deletedCount: 0, modifiedCount: 0 };
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
const project = await ChatProject.findOne(projectFilter).select('_id').lean<IChatProject>();
if (!project) {
return { deletedCount: 0, modifiedCount: 0 };
}
const [conversationResult, deleteResult] = await Promise.all([
Conversation.updateMany(
{ user, chatProjectId: projectId },
{ $unset: { chatProjectId: '' } },
),
ChatProject.deleteOne(projectFilter),
]);
return {
deletedCount: deleteResult.deletedCount ?? 0,
modifiedCount: conversationResult.modifiedCount ?? 0,
};
}
async function assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
): Promise<AssignConversationToProjectResult | null> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const conversation = await Conversation.findOne({ user, conversationId }).lean<IConversation>();
if (!conversation) {
return null;
}
const normalizedProjectId = projectId || null;
if (normalizedProjectId) {
if (!isValidObjectIdString(normalizedProjectId)) {
throw new Error('Project not found');
}
const project = await ChatProject.findOne({
_id: new mongoose.Types.ObjectId(normalizedProjectId),
user,
})
.select('_id')
.lean<IChatProject>();
if (!project) {
throw new Error('Project not found');
}
}
const previousProjectId = conversation.chatProjectId ?? null;
const update =
normalizedProjectId == null
? { $unset: { chatProjectId: '' } }
: { $set: { chatProjectId: normalizedProjectId } };
const updatedConversation = await Conversation.findOneAndUpdate(
{ user, conversationId },
update,
{ new: true },
).lean<IConversation>();
if (!updatedConversation) {
return null;
}
const projectIds = new Set(
[previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)),
);
await Promise.all(
[...projectIds].map((id) => refreshChatProjectStatsForUser(mongoose, user, id)),
);
return {
conversation: updatedConversation,
previousProjectId,
projectId: normalizedProjectId,
};
}
async function refreshChatProjectStats(
user: string,
projectId: string,
): Promise<IChatProject | null> {
return await refreshChatProjectStatsForUser(mongoose, user, projectId);
}
return {
createChatProject,
getChatProject,
listChatProjects,
updateChatProject,
deleteChatProject,
assignConversationToProject,
refreshChatProjectStats,
};
}

View file

@ -1,7 +1,7 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
import type { IConversation } from '../types';
import type { IChatProject, IConversation } from '../types';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
@ -16,6 +16,7 @@ jest.mock('~/config/winston', () => ({
let mongoServer: InstanceType<typeof MongoMemoryServer>;
let Conversation: mongoose.Model<IConversation>;
let ChatProject: mongoose.Model<IChatProject>;
let modelsToCleanup: string[] = [];
// Mock message methods (same as original test mocking ./Message)
@ -32,6 +33,7 @@ beforeAll(async () => {
modelsToCleanup = Object.keys(models);
Object.assign(mongoose.models, models);
Conversation = mongoose.models.Conversation as mongoose.Model<IConversation>;
ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
@ -91,6 +93,7 @@ describe('Conversation Operations', () => {
beforeEach(async () => {
// Clear database
await Conversation.deleteMany({});
await ChatProject.deleteMany({});
// Reset mocks
jest.clearAllMocks();
@ -153,6 +156,155 @@ describe('Conversation Operations', () => {
expect(result?.conversationId).toBe(newConversationId);
});
it('refreshes project stats when archiving a project conversation', async () => {
const project = await ChatProject.create({
user: mockCtx.userId,
name: 'Project Stats',
conversationCount: 0,
lastConversationAt: null,
lastConversationId: null,
});
const firstConversationId = uuidv4();
const secondConversationId = uuidv4();
const chatProjectId = project._id!.toString();
await saveConvo(mockCtx, {
conversationId: firstConversationId,
title: 'First',
endpoint: EModelEndpoint.openAI,
chatProjectId,
});
await saveConvo(mockCtx, {
conversationId: secondConversationId,
title: 'Second',
endpoint: EModelEndpoint.openAI,
chatProjectId,
});
await saveConvo(mockCtx, {
conversationId: secondConversationId,
isArchived: true,
});
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshedProject?.conversationCount).toBe(1);
expect(refreshedProject?.lastConversationId).toBe(firstConversationId);
});
it('bulkSaveConvos keeps owned project ids and strips orphan ones', async () => {
const project = await ChatProject.create({
user: mockCtx.userId,
name: 'Bulk Project',
conversationCount: 0,
lastConversationAt: null,
lastConversationId: null,
});
const ownedId = project._id!.toString();
const orphanId = new mongoose.Types.ObjectId().toString();
const ownedConvoId = uuidv4();
const orphanConvoId = uuidv4();
await methods.bulkSaveConvos([
{
conversationId: ownedConvoId,
user: mockCtx.userId,
endpoint: EModelEndpoint.openAI,
chatProjectId: ownedId,
},
{
conversationId: orphanConvoId,
user: mockCtx.userId,
endpoint: EModelEndpoint.openAI,
chatProjectId: orphanId,
},
]);
const ownedConvo = await Conversation.findOne({
conversationId: ownedConvoId,
}).lean<IConversation>();
const orphanConvo = await Conversation.findOne({
conversationId: orphanConvoId,
}).lean<IConversation>();
expect(ownedConvo?.chatProjectId).toBe(ownedId);
expect(orphanConvo?.chatProjectId == null).toBe(true);
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshedProject?.conversationCount).toBe(1);
});
it('refreshes both projects when a save moves a conversation between them', async () => {
const projectA = await ChatProject.create({
user: mockCtx.userId,
name: 'Project A',
conversationCount: 0,
});
const projectB = await ChatProject.create({
user: mockCtx.userId,
name: 'Project B',
conversationCount: 0,
});
const conversationId = uuidv4();
const projectAId = projectA._id!.toString();
const projectBId = projectB._id!.toString();
await saveConvo(mockCtx, {
conversationId,
title: 'Moving',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectAId,
});
// A stale tab re-submits with the new project id, moving the chat A -> B.
await saveConvo(mockCtx, {
conversationId,
endpoint: EModelEndpoint.openAI,
chatProjectId: projectBId,
});
const refreshedA = await ChatProject.findById(projectA._id).lean<IChatProject>();
const refreshedB = await ChatProject.findById(projectB._id).lean<IChatProject>();
expect(refreshedA?.conversationCount).toBe(0);
expect(refreshedA?.lastConversationId == null).toBe(true);
expect(refreshedB?.conversationCount).toBe(1);
expect(refreshedB?.lastConversationId).toBe(conversationId);
});
it('bulkSaveConvos refreshes the project a conversation leaves', async () => {
const projectA = await ChatProject.create({
user: mockCtx.userId,
name: 'Bulk Project A',
conversationCount: 0,
});
const projectB = await ChatProject.create({
user: mockCtx.userId,
name: 'Bulk Project B',
conversationCount: 0,
});
const conversationId = uuidv4();
const projectAId = projectA._id!.toString();
const projectBId = projectB._id!.toString();
await saveConvo(mockCtx, {
conversationId,
title: 'Bulk Moving',
endpoint: EModelEndpoint.openAI,
chatProjectId: projectAId,
});
await methods.bulkSaveConvos([
{
conversationId,
user: mockCtx.userId,
endpoint: EModelEndpoint.openAI,
chatProjectId: projectBId,
},
]);
const refreshedA = await ChatProject.findById(projectA._id).lean<IChatProject>();
const refreshedB = await ChatProject.findById(projectB._id).lean<IChatProject>();
expect(refreshedA?.conversationCount).toBe(0);
expect(refreshedB?.conversationCount).toBe(1);
});
it('should not create a conversation when noUpsert is true and conversation does not exist', async () => {
const nonExistentId = uuidv4();
const result = await saveConvo(
@ -1170,5 +1322,34 @@ describe('Conversation Operations', () => {
expect(doc?.title).toBe('Updated');
expect(doc?.tenantId).toBe('real-tenant');
});
it('bulkSaveConvos refreshes stats for cloned project conversations', async () => {
const project = await ChatProject.create({
user: 'user123',
name: 'Bulk Project',
conversationCount: 0,
lastConversationAt: null,
lastConversationId: null,
});
const conversationId = uuidv4();
const createdAt = new Date('2026-01-01T00:00:00.000Z');
await methods.bulkSaveConvos([
{
conversationId,
user: 'user123',
title: 'Cloned Project Chat',
endpoint: EModelEndpoint.openAI,
chatProjectId: project._id!.toString(),
createdAt,
updatedAt: createdAt,
},
]);
const refreshedProject = await ChatProject.findById(project._id).lean<IChatProject>();
expect(refreshedProject?.conversationCount).toBe(1);
expect(refreshedProject?.lastConversationId).toBe(conversationId);
expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(createdAt.toISOString());
});
});
});

View file

@ -1,10 +1,15 @@
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import { RetentionMode } from 'librechat-data-provider';
import { isValidObjectIdString } from '~/utils/objectId';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
import type { AppConfig, IConversation } from '~/types';
import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
import {
refreshChatProjectStatsForUser,
updateChatProjectLastConversationForUser,
} from './chatProject';
import type { MessageMethods } from './message';
import type { DeleteResult } from 'mongoose';
@ -36,6 +41,7 @@ export interface ConversationMethods {
search?: string;
sortBy?: string;
sortDirection?: string;
projectId?: string;
},
): Promise<{ conversations: IConversation[]; nextCursor: string | null }>;
getConvosQueried(
@ -208,6 +214,38 @@ export function createConversationMethods(
const messages = await getMessages({ conversationId, user: userId }, '_id');
const update: Record<string, unknown> = { ...convo, messages, user: userId };
const unsetFields: Record<string, number> = { ...(metadata?.unsetFields ?? {}) };
if (Object.prototype.hasOwnProperty.call(update, 'chatProjectId') && update.chatProjectId) {
const chatProjectId = typeof update.chatProjectId === 'string' ? update.chatProjectId : '';
let isValidChatProject = isValidObjectIdString(chatProjectId);
if (isValidChatProject) {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const project = await ChatProject.exists({
_id: new mongoose.Types.ObjectId(chatProjectId),
user: userId,
});
isValidChatProject = project != null;
}
if (!isValidChatProject) {
delete update.chatProjectId;
unsetFields.chatProjectId = 1;
}
}
const mayChangeProjectMembership =
Object.prototype.hasOwnProperty.call(update, 'chatProjectId') ||
Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId');
let previousChatProjectId: string | null = null;
if (mayChangeProjectMembership) {
const existing = await Conversation.findOne(
{ conversationId, user: userId },
'chatProjectId',
).lean<{ chatProjectId?: string | null } | null>();
previousChatProjectId = existing?.chatProjectId ?? null;
}
if (newConversationId) {
update.conversationId = newConversationId;
@ -248,22 +286,35 @@ export function createConversationMethods(
}
const updateOperation: Record<string, unknown> = { $set: update };
if (metadata?.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
updateOperation.$unset = metadata.unsetFields;
if (Object.keys(unsetFields).length > 0) {
updateOperation.$unset = unsetFields;
}
if (createdAtOnInsert) {
updateOperation.$setOnInsert = { createdAt: createdAtOnInsert };
}
const conversation = await Conversation.findOneAndUpdate(
const conversationResult = (await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
updateOperation,
{
new: true,
upsert: metadata?.noUpsert !== true,
includeResultMetadata: true,
...(createdAtOnInsert ? { timestamps: false } : {}),
},
);
)) as unknown as {
value:
| (IConversation & {
_id: unknown;
$isDefault: (path: string) => boolean;
toObject: () => IConversation;
})
| null;
lastErrorObject?: {
updatedExisting?: boolean;
};
};
const conversation = conversationResult.value;
if (!conversation) {
logger.debug('[saveConvo] Conversation not found, skipping update');
@ -283,6 +334,60 @@ export function createConversationMethods(
conversation.isTemporary = false;
}
const newChatProjectId = conversation.chatProjectId ?? null;
const projectMembershipChanged = previousChatProjectId !== newChatProjectId;
/**
* A chat that moved between projects (e.g. a stale tab re-submitting an
* older project id) must fully recompute the stats of the project it left;
* the incremental path only ever touches the project it now belongs to.
*/
if (projectMembershipChanged && previousChatProjectId) {
await refreshChatProjectStatsForUser(mongoose, userId, previousChatProjectId);
}
if (conversation.chatProjectId) {
const isRetentionVisibilityUpdate =
typeof update.isTemporary === 'boolean' ||
Object.prototype.hasOwnProperty.call(convo, 'expiredAt') ||
Object.prototype.hasOwnProperty.call(unsetFields, 'isTemporary') ||
Object.prototype.hasOwnProperty.call(unsetFields, 'expiredAt');
/**
* Saving a conversation that is itself archived or retention-hidden (e.g.
* renaming or title generation on an archived project chat) must recompute
* stats rather than take the incremental fast path, otherwise the project's
* lastConversationAt/Id would point at a chat the project workspace hides.
*/
const isConversationHidden =
conversation.isArchived === true ||
conversation.isTemporary === true ||
(conversation.expiredAt != null &&
new Date(conversation.expiredAt).getTime() <= Date.now());
/**
* A move into this project (projectMembershipChanged) also needs a full
* refresh: the incremental path only bumps the count for brand-new inserts,
* so a pre-existing chat joining the project would otherwise be uncounted.
*/
const shouldRefreshProjectStats =
projectMembershipChanged ||
typeof update.isArchived === 'boolean' ||
Object.prototype.hasOwnProperty.call(unsetFields, 'isArchived') ||
isRetentionVisibilityUpdate ||
isConversationHidden;
if (shouldRefreshProjectStats) {
await refreshChatProjectStatsForUser(mongoose, userId, conversation.chatProjectId);
} else {
await updateChatProjectLastConversationForUser(
mongoose,
userId,
conversation.chatProjectId,
conversation,
conversationResult.lastErrorObject?.updatedExisting === false,
);
}
}
return conversation.toObject();
} catch (error) {
logger.error('[saveConvo] Error saving conversation', error);
@ -299,19 +404,113 @@ export function createConversationMethods(
async function bulkSaveConvos(conversations: Array<Record<string, unknown>>) {
try {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const bulkOps = conversations.map((convo) => ({
updateOne: {
filter: {
conversationId: convo.conversationId,
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
/**
* Validate project ownership before persisting (mirrors saveConvo). Bulk
* paths like import/duplicate/fork can carry a chatProjectId that does not
* belong to the user; persisting it would create an orphan assignment that
* is hidden from both the project and the unassigned filter.
*/
const candidatePairs = new Map<string, { user: string; projectId: string }>();
for (const convo of conversations) {
if (
typeof convo.user === 'string' &&
typeof convo.chatProjectId === 'string' &&
isValidObjectIdString(convo.chatProjectId)
) {
candidatePairs.set(`${convo.user}:${convo.chatProjectId}`, {
user: convo.user,
projectId: convo.chatProjectId,
});
}
}
const ownedProjects = new Set<string>();
if (candidatePairs.size > 0) {
const owned = await ChatProject.find({
$or: [...candidatePairs.values()].map(({ user, projectId }) => ({
_id: new mongoose.Types.ObjectId(projectId),
user,
})),
})
.select('_id user')
.lean<Array<{ _id: { toString: () => string }; user: string }>>();
for (const project of owned) {
ownedProjects.add(`${project.user}:${project._id.toString()}`);
}
}
/**
* Capture each conversation's existing project so a bulk move (import that
* overwrites an existing (user, conversationId), duplicate/fork) also refreshes
* the project it leaves, not just the one it joins. One batched read keeps this
* O(1) in round-trips regardless of batch size.
*/
const previousProjectByConversation = new Map<string, string>();
const conversationPairs = conversations
.filter((c) => typeof c.user === 'string' && typeof c.conversationId === 'string')
.map((c) => ({ user: c.user as string, conversationId: c.conversationId as string }));
if (conversationPairs.length > 0) {
const existing = await Conversation.find(
{ $or: conversationPairs },
'user conversationId chatProjectId',
).lean<Array<{ user: string; conversationId: string; chatProjectId?: string | null }>>();
for (const doc of existing) {
if (doc.chatProjectId) {
previousProjectByConversation.set(
`${doc.user}:${doc.conversationId}`,
doc.chatProjectId,
);
}
}
}
const affectedProjectStats = new Map<string, { user: string; projectId: string }>();
const bulkOps = conversations.map((convo) => {
const sanitized = { ...convo };
if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') {
if (ownedProjects.has(`${sanitized.user}:${sanitized.chatProjectId}`)) {
affectedProjectStats.set(`${sanitized.user}:${sanitized.chatProjectId}`, {
user: sanitized.user,
projectId: sanitized.chatProjectId,
});
} else {
sanitized.chatProjectId = null;
}
}
if (typeof sanitized.user === 'string' && typeof sanitized.conversationId === 'string') {
const previousProjectId = previousProjectByConversation.get(
`${sanitized.user}:${sanitized.conversationId}`,
);
const newProjectId =
typeof sanitized.chatProjectId === 'string' ? sanitized.chatProjectId : null;
if (previousProjectId && previousProjectId !== newProjectId) {
affectedProjectStats.set(`${sanitized.user}:${previousProjectId}`, {
user: sanitized.user,
projectId: previousProjectId,
});
}
}
return {
updateOne: {
filter: {
conversationId: sanitized.conversationId,
user: sanitized.user,
},
update: sanitized,
upsert: true,
timestamps: false,
},
update: convo,
upsert: true,
timestamps: false,
},
}));
};
});
const result = await tenantSafeBulkWrite(Conversation, bulkOps);
await Promise.all(
[...affectedProjectStats.values()].map(({ user, projectId }) =>
refreshChatProjectStatsForUser(mongoose, user, projectId),
),
);
return result;
} catch (error) {
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
@ -332,6 +531,7 @@ export function createConversationMethods(
search,
sortBy = 'updatedAt',
sortDirection = 'desc',
projectId,
}: {
cursor?: string | null;
limit?: number;
@ -340,6 +540,7 @@ export function createConversationMethods(
search?: string;
sortBy?: string;
sortDirection?: string;
projectId?: string;
} = {},
) {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
@ -356,6 +557,14 @@ export function createConversationMethods(
filters.push({ tags: { $in: tags } } as FilterQuery<IConversation>);
}
if (projectId === 'unassigned') {
filters.push({
$or: [{ chatProjectId: null }, { chatProjectId: { $exists: false } }],
} as FilterQuery<IConversation>);
} else if (projectId) {
filters.push({ chatProjectId: projectId } as FilterQuery<IConversation>);
}
filters.push(getVisibleConversationRetentionFilter());
if (search) {
@ -431,7 +640,7 @@ export function createConversationMethods(
const convos = await Conversation.find(query)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL chatProjectId',
)
.sort(sortObj)
.limit(limit + 1)
@ -538,8 +747,15 @@ export function createConversationMethods(
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const { deleteMessages } = getMessageMethods();
const userFilter = { ...filter, user };
const conversations = await Conversation.find(userFilter).select('conversationId');
const conversations = await Conversation.find(userFilter).select(
'conversationId chatProjectId',
);
const conversationIds = conversations.map((c) => c.conversationId);
const projectIds = new Set(
conversations
.map((conversation) => conversation.chatProjectId)
.filter((projectId): projectId is string => Boolean(projectId)),
);
if (!conversationIds.length) {
throw new Error('Conversation not found or already deleted.');
@ -552,6 +768,12 @@ export function createConversationMethods(
user,
});
await Promise.all(
[...projectIds].map((projectId) =>
refreshChatProjectStatsForUser(mongoose, user, projectId),
),
);
return { ...deleteConvoResult, messages: deleteMessagesResult };
} catch (error) {
logger.error('[deleteConvos] Error deleting conversations and messages', error);

View file

@ -32,6 +32,17 @@ import { createPresetMethods, type PresetMethods } from './preset';
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
import { createMessageMethods, type MessageMethods } from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
export type {
AssignConversationToProjectResult,
ChatProjectSortBy,
ChatProjectSortDirection,
CreateChatProjectInput,
DeleteChatProjectResult,
ListChatProjectsOptions,
ListChatProjectsResult,
UpdateChatProjectInput,
} from './chatProject';
/* Tier 3 — Complex (heavier injection) */
import {
createTxMethods,
@ -92,6 +103,7 @@ export type AllMethods = UserMethods &
ConversationTagMethods &
MessageMethods &
ConversationMethods &
ChatProjectMethods &
TxMethods &
TransactionMethods &
SpendTokensMethods &
@ -218,6 +230,7 @@ export function createMethods(
...createConversationTagMethods(mongoose),
...messageMethods,
...conversationMethods,
...createChatProjectMethods(mongoose),
/* Tier 3 */
...txMethods,
...transactionMethods,
@ -257,6 +270,7 @@ export type {
ConversationTagMethods,
MessageMethods,
ConversationMethods,
ChatProjectMethods,
TxMethods,
TransactionMethods,
SpendTokensMethods,

View file

@ -0,0 +1,11 @@
import chatProjectSchema from '~/schema/chatProject';
import { applyTenantIsolation } from '~/models/plugins/tenantIsolation';
import type { IChatProjectDocument } from '~/types';
export function createChatProjectModel(mongoose: typeof import('mongoose')) {
applyTenantIsolation(chatProjectSchema);
return (
mongoose.models.ChatProject ||
mongoose.model<IChatProjectDocument>('ChatProject', chatProjectSchema, 'chatprojects')
);
}

View file

@ -3,6 +3,7 @@ import { createTokenModel } from './token';
import { createSessionModel } from './session';
import { createBalanceModel } from './balance';
import { createConversationModel } from './convo';
import { createChatProjectModel } from './chatProject';
import { createMessageModel } from './message';
import { createAgentModel } from './agent';
import { createAgentApiKeyModel } from './agentApiKey';
@ -41,6 +42,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
Session: createSessionModel(mongoose),
Balance: createBalanceModel(mongoose),
Conversation: createConversationModel(mongoose),
ChatProject: createChatProjectModel(mongoose),
Message: createMessageModel(mongoose),
Agent: createAgentModel(mongoose),
AgentApiKey: createAgentApiKeyModel(mongoose),

View file

@ -0,0 +1,50 @@
import { Schema } from 'mongoose';
import type { IChatProjectDocument } from '~/types';
const chatProjectSchema = new Schema<IChatProjectDocument>(
{
name: {
type: String,
required: true,
trim: true,
maxlength: 100,
index: true,
},
description: {
type: String,
default: '',
trim: true,
maxlength: 1000,
},
user: {
type: String,
required: true,
index: true,
},
conversationCount: {
type: Number,
default: 0,
min: 0,
},
lastConversationAt: {
type: Date,
default: null,
index: true,
},
lastConversationId: {
type: String,
default: null,
},
tenantId: {
type: String,
index: true,
},
},
{ timestamps: true },
);
chatProjectSchema.index({ user: 1, name: 1, _id: 1 });
chatProjectSchema.index({ user: 1, createdAt: -1, _id: -1 });
chatProjectSchema.index({ user: 1, lastConversationAt: -1, _id: -1 });
export default chatProjectSchema;

View file

@ -34,6 +34,11 @@ const convoSchema: Schema<IConversation> = new Schema(
default: [],
meiliIndex: true,
},
chatProjectId: {
type: String,
default: null,
index: true,
},
files: {
type: [String],
},
@ -51,6 +56,8 @@ const convoSchema: Schema<IConversation> = new Schema(
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true });
convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 });
convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 });
convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 });
// index for MeiliSearch sync operations

View file

@ -6,6 +6,7 @@ export { default as assistantSchema } from './assistant';
export { default as balanceSchema } from './balance';
export { default as bannerSchema } from './banner';
export { default as categoriesSchema } from './categories';
export { default as chatProjectSchema } from './chatProject';
export { default as conversationTagSchema } from './conversationTag';
export { default as convoSchema } from './convo';
export { default as fileSchema } from './file';

View file

@ -0,0 +1,16 @@
import type { Document, Types } from 'mongoose';
export interface IChatProject {
_id?: Types.ObjectId;
name: string;
description?: string;
user: string;
conversationCount: number;
lastConversationAt?: Date | null;
lastConversationId?: string | null;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
}
export interface IChatProjectDocument extends Omit<IChatProject, '_id'>, Document {}

View file

@ -42,6 +42,7 @@ export interface IConversation extends Document {
greeting?: string;
spec?: string;
tags?: string[];
chatProjectId?: string | null;
tools?: string[];
maxContextTokens?: number;
max_tokens?: number;

View file

@ -5,6 +5,7 @@ export * from './app';
export * from './user';
export * from './token';
export * from './convo';
export * from './chatProject';
export * from './session';
export * from './balance';
export * from './banner';