Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
202bc51
feat: Add private chat projects
danny-avila Jun 2, 2026
58afad4
fix: Format project files
danny-avila Jun 2, 2026
4805b89
fix: Address project review findings
danny-avila Jun 2, 2026
7c43b93
fix: Resolve project review follow-ups
danny-avila Jun 2, 2026
77e9c0c
fix: Handle project stats and cache edge cases
danny-avila Jun 2, 2026
346a131
style: align projects UI with sidebar patterns
danny-avila Jun 2, 2026
c66b700
fix: resolve projects UI lint issues
danny-avila Jun 2, 2026
6741b70
style: Align project menus and composer
danny-avila Jun 2, 2026
3d50d26
fix: Avoid project placeholder shadowing
danny-avila Jun 2, 2026
b1addca
fix: Handle project search and stale ids
danny-avila Jun 2, 2026
b34387b
fix: Polish project sidebar behavior
danny-avila Jun 2, 2026
c143d78
fix: Preserve new chat stream after creation
danny-avila Jun 2, 2026
c8299a2
fix: Stabilize project sidebar sections
danny-avila Jun 2, 2026
bdb979f
fix: Smooth project sidebar organization
danny-avila Jun 2, 2026
4e8ad2d
fix: stabilize project chat entry
danny-avila Jun 3, 2026
dbfbbe4
fix: keep project workspace outside chat context
danny-avila Jun 3, 2026
f31b744
fix: show default model on project workspace
danny-avila Jun 3, 2026
a696882
fix: fallback project workspace model label
danny-avila Jun 3, 2026
06a5fc3
fix: preserve project scope during draft hydration
danny-avila Jun 3, 2026
6042923
fix: include route project in new chat submission
danny-avila Jun 3, 2026
4dbc277
fix: persist project id in agent chat saves
danny-avila Jun 3, 2026
0fafaa2
fix: refine project sidebar and creation UX
danny-avila Jun 3, 2026
b1342bf
fix: export chat project method types
danny-avila Jun 3, 2026
4b2f785
fix: polish project landing context
danny-avila Jun 3, 2026
ec9f3d6
fix: refine project navigation affordances
danny-avila Jun 3, 2026
e25feaa
feat: rework projects UX — coexisting sidebar sections + URL-driven s…
danny-avila Jun 3, 2026
7278334
chore: prune unused i18n keys; fix project chat-count pluralization
danny-avila Jun 3, 2026
e1e1ebb
fix: project new-chat keeps model spec; sidebar header + row polish
danny-avila Jun 3, 2026
e185e2c
fix: read project scope from router params; align sidebar header icons
danny-avila Jun 3, 2026
ed17449
fix: preserve chatProjectId through the new-chat template reduction
danny-avila Jun 3, 2026
dc6ca40
style: align chat-history panel top padding; improve projects page co…
danny-avila Jun 3, 2026
5b9eb9f
feat: interactive project landing chip + gallery icon for all-projects
danny-avila Jun 3, 2026
4a5d2b5
test: fix Conversations unit test for refactored sidebar; add project…
danny-avila Jun 3, 2026
319bf15
fix: refresh project stats after project-chat activity; stabilize e2e
danny-avila Jun 3, 2026
8d590b3
test: verify a project chat saves and is filed under its project (e2e)
danny-avila Jun 3, 2026
c5473ac
fix: address Codex review findings (project scope edge cases)
danny-avila Jun 3, 2026
d8686aa
fix: project stats pagination + archived-chat edge cases (data-schemas)
danny-avila Jun 3, 2026
377be92
test: cover chat-less project pagination across the dated→null boundary
danny-avila Jun 3, 2026
c90e665
fix: validate project ownership in bulkSaveConvos
danny-avila Jun 3, 2026
5b258d3
fix(projects): preserve chatProjectId on continuation, basename-safe …
danny-avila Jun 3, 2026
7e7c998
fix(projects): navigate project workspace chats via useNavigateToConv…
danny-avila Jun 3, 2026
90dd1af
fix(projects): include projectConversations cache when resolving dele…
danny-avila Jun 3, 2026
e68ce3d
fix(projects): refresh both projects when a save or bulk write moves …
danny-avila Jun 3, 2026
ecc36c4
Merge remote-tracking branch 'origin/dev' into projects
danny-avila Jun 3, 2026
028c481
style(projects): use Folders icon for the sidebar Projects header
danny-avila Jun 3, 2026
4543b08
fix(projects): require id on ProjectUser so ProjectRequest extends Ex…
danny-avila Jun 3, 2026
3e929ae
style(projects): taller project chip with hover-revealed remove butto…
danny-avila Jun 3, 2026
749a7d5
style(projects): show endpoint/agent icon for project workspace chat …
danny-avila Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,23 @@ 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;
const isArchived = isEnabled(req.query.isArchived);
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) {
Expand All @@ -53,6 +63,7 @@ router.get('/', async (req, res) => {
search,
sortBy,
sortDirection,
projectId,
});
res.status(200).json(result);
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -59,6 +60,7 @@ module.exports = {
config,
models,
prompts,
projects,
skills,
actions,
presets,
Expand Down
25 changes: 25 additions & 0 deletions api/server/routes/projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const express = require('express');
Comment thread
danny-avila marked this conversation as resolved.
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;
3 changes: 2 additions & 1 deletion api/server/services/Endpoints/agents/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +28,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint,
agent_id,
endpointType,
chatProjectId,
model_parameters,
agent: agentPromise,
addedConvo,
Expand Down
1 change: 1 addition & 0 deletions api/server/services/Endpoints/agents/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 19 additions & 4 deletions client/src/components/Chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -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 />;
Expand All @@ -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}>
Expand All @@ -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>
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,6 +56,7 @@ interface ChatFormProps {

const ChatForm = memo(function ChatForm({
index,
placeholder,
files,
setFiles,
conversation,
Expand Down Expand Up @@ -177,6 +179,7 @@ const ChatForm = memo(function ChatForm({
submitButtonRef,
setIsScrollable,
disabled: disableInputs,
placeholder,
});

useQueryParams({ textAreaRef });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -465,6 +468,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) {
return (
<ChatForm
index={index}
placeholder={placeholder}
files={files}
setFiles={setFiles}
conversation={stableConversation}
Expand Down
89 changes: 89 additions & 0 deletions client/src/components/Chat/ProjectLandingChip.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading