From baa23a8e247edabd94c41b9c5d557e3e41730b4d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 3 Jun 2026 15:29:18 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Add=20Private?= =?UTF-8?q?=20Chat=20Projects=20(#13467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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-" 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=...& 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: 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 --- api/server/controllers/agents/client.js | 1 + api/server/experimental.js | 1 + api/server/index.js | 1 + api/server/routes/convos.js | 11 + api/server/routes/index.js | 2 + api/server/routes/projects.js | 25 + api/server/services/Endpoints/agents/build.js | 3 +- .../services/Endpoints/agents/initialize.js | 1 + client/src/components/Chat/ChatView.tsx | 23 +- client/src/components/Chat/Input/ChatForm.tsx | 6 +- .../components/Chat/ProjectLandingChip.tsx | 89 +++ .../Conversations/Conversations.tsx | 112 ++-- client/src/components/Conversations/Convo.tsx | 1 + .../ConvoOptions/ConvoOptions.tsx | 85 ++- .../ConvoOptions/ProjectButton.tsx | 157 ++++++ .../Conversations/ConvoOptions/index.js | 1 + .../Conversations/ConvoOptions/index.ts | 1 + .../Conversations/ProjectsSection.tsx | 516 ++++++++++++++++++ .../__tests__/Conversations.test.tsx | 14 +- .../components/Projects/ProjectChatList.tsx | 249 +++++++++ .../Projects/ProjectCreateDialog.tsx | 120 ++++ .../components/Projects/ProjectWorkspace.tsx | 215 ++++++++ .../src/components/Projects/ProjectsView.tsx | 239 ++++++++ client/src/components/Projects/index.ts | 2 + .../UnifiedSidebar/ConversationsSection.tsx | 11 +- client/src/data-provider/Projects/index.ts | 2 + .../src/data-provider/Projects/mutations.ts | 117 ++++ client/src/data-provider/Projects/queries.ts | 49 ++ client/src/data-provider/index.ts | 1 + client/src/data-provider/mutations.ts | 46 ++ client/src/data-provider/queries.ts | 5 +- client/src/hooks/Chat/useChatFunctions.ts | 16 +- client/src/hooks/Input/useQueryParams.ts | 25 +- client/src/hooks/Input/useSelectMention.ts | 35 +- client/src/hooks/Input/useTextarea.ts | 17 +- client/src/hooks/SSE/useEventHandlers.ts | 13 + client/src/hooks/useNewConvo.ts | 44 +- client/src/locales/en/translation.json | 35 ++ client/src/routes/ChatRoute.tsx | 61 ++- client/src/routes/index.tsx | 18 + client/src/store/families.ts | 3 + client/src/utils/convos.ts | 60 +- e2e/specs/mock/projects.spec.ts | 110 ++++ packages/api/src/db/utils.ts | 1 + packages/api/src/index.ts | 2 + packages/api/src/projects/handlers.ts | 220 ++++++++ packages/api/src/projects/index.ts | 1 + .../client/src/components/ControlCombobox.tsx | 3 + packages/data-provider/src/api-endpoints.ts | 11 + packages/data-provider/src/bedrock.ts | 3 + packages/data-provider/src/config.ts | 1 + packages/data-provider/src/data-service.ts | 28 + packages/data-provider/src/keys.ts | 7 + packages/data-provider/src/schemas.spec.ts | 8 + packages/data-provider/src/schemas.ts | 9 + packages/data-provider/src/types.ts | 38 ++ packages/data-provider/src/types/queries.ts | 18 +- .../src/methods/chatProject.spec.ts | 293 ++++++++++ .../data-schemas/src/methods/chatProject.ts | 463 ++++++++++++++++ .../src/methods/conversation.spec.ts | 183 ++++++- .../data-schemas/src/methods/conversation.ts | 254 ++++++++- packages/data-schemas/src/methods/index.ts | 14 + .../data-schemas/src/models/chatProject.ts | 11 + packages/data-schemas/src/models/index.ts | 2 + .../data-schemas/src/schema/chatProject.ts | 50 ++ packages/data-schemas/src/schema/convo.ts | 7 + packages/data-schemas/src/schema/index.ts | 1 + .../data-schemas/src/types/chatProject.ts | 16 + packages/data-schemas/src/types/convo.ts | 1 + packages/data-schemas/src/types/index.ts | 1 + 70 files changed, 4082 insertions(+), 107 deletions(-) create mode 100644 api/server/routes/projects.js create mode 100644 client/src/components/Chat/ProjectLandingChip.tsx create mode 100644 client/src/components/Conversations/ConvoOptions/ProjectButton.tsx create mode 100644 client/src/components/Conversations/ProjectsSection.tsx create mode 100644 client/src/components/Projects/ProjectChatList.tsx create mode 100644 client/src/components/Projects/ProjectCreateDialog.tsx create mode 100644 client/src/components/Projects/ProjectWorkspace.tsx create mode 100644 client/src/components/Projects/ProjectsView.tsx create mode 100644 client/src/components/Projects/index.ts create mode 100644 client/src/data-provider/Projects/index.ts create mode 100644 client/src/data-provider/Projects/mutations.ts create mode 100644 client/src/data-provider/Projects/queries.ts create mode 100644 e2e/specs/mock/projects.spec.ts create mode 100644 packages/api/src/projects/handlers.ts create mode 100644 packages/api/src/projects/index.ts create mode 100644 packages/data-schemas/src/methods/chatProject.spec.ts create mode 100644 packages/data-schemas/src/methods/chatProject.ts create mode 100644 packages/data-schemas/src/models/chatProject.ts create mode 100644 packages/data-schemas/src/schema/chatProject.ts create mode 100644 packages/data-schemas/src/types/chatProject.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9c85560a66..c18e0b8b1e 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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, diff --git a/api/server/experimental.js b/api/server/experimental.js index 8fd94c6723..c20f6813ca 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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); diff --git a/api/server/index.js b/api/server/index.js index bd2d260e76..d96180cc4e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 8dfe8621ef..c9d77189cd 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -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) { diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6694e47f23..3322dd57e3 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -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, diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js new file mode 100644 index 0000000000..fd782a1ba5 --- /dev/null +++ b/api/server/routes/projects.js @@ -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; diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 19ae3ab7e8..efd7130091 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -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, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index bbbcd535ce..3108f1d72b 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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, diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 27530a5c22..1a03ba8041 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -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 = ; @@ -81,6 +90,11 @@ function ChatView({ index = 0 }: { index?: number }) { content = ; } + const chatFormPlaceholder = + isProjectLandingPage && project + ? localize('com_ui_new_chat_in_project', { name: project.name }) + : undefined; + return ( @@ -104,7 +118,8 @@ function ChatView({ index = 0 }: { index?: number }) { isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl', )} > - + {isProjectLandingPage && project && } + {isLandingPage ? :