diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx
index 27530a5c22..cceb268aae 100644
--- a/client/src/components/Chat/ChatView.tsx
+++ b/client/src/components/Chat/ChatView.tsx
@@ -3,11 +3,18 @@ import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { Spinner } from '@librechat/client';
import { useParams } from 'react-router-dom';
+import { Folder } from 'lucide-react';
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';
@@ -29,8 +36,27 @@ function LoadingSpinner() {
);
}
-function ChatView({ index = 0 }: { index?: number }) {
+function ProjectLanding({ project }: { project: TChatProject }) {
+ return (
+
+
+
+
+
+ {project.name}
+
+
+ {project.description && (
+
{project.description}
+ )}
+
+
+ );
+}
+
+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 +96,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 = ;
@@ -77,10 +104,17 @@ function ChatView({ index = 0 }: { index?: number }) {
content = ;
} else if (!isLandingPage) {
content = ;
+ } else if (isProjectLandingPage && project) {
+ content = ;
} else {
content = ;
}
+ const chatFormPlaceholder =
+ isProjectLandingPage && project
+ ? localize('com_ui_new_chat_in_project', { name: project.name })
+ : undefined;
+
return (
@@ -104,7 +138,7 @@ function ChatView({ index = 0 }: { index?: number }) {
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
-
+
{isLandingPage ? : }
diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx
index 884a9251c4..f8c058cae0 100644
--- a/client/src/components/Chat/Input/ChatForm.tsx
+++ b/client/src/components/Chat/Input/ChatForm.tsx
@@ -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;
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 (
{
- if (!isSelected) {
- return ;
- }
- return ;
-});
+function renderSelectedMenuItem(label: string, icon: ReactNode, isSelected: boolean): RenderProp {
+ return function SelectedMenuItem({ className, ...props }) {
+ return (
+
+
+
+ {icon}
+
+ {label}
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+}
-SelectionMark.displayName = 'ChatsHeaderSelectionMark';
+function createSelectedMenuItem({
+ id,
+ value,
+ label,
+ icon,
+ selectedValue,
+ onSelect,
+}: {
+ id: string;
+ value: T;
+ label: string;
+ icon: ReactNode;
+ selectedValue: T;
+ onSelect: (value: T) => void;
+}): MenuItemProps {
+ const isSelected = selectedValue === value;
+ return {
+ id,
+ ariaLabel: label,
+ ariaChecked: isSelected,
+ onClick: () => onSelect(value),
+ render: renderSelectedMenuItem(label, icon, isSelected),
+ };
+}
const ChatsHeader: FC = ({
isExpanded,
@@ -53,45 +82,68 @@ const ChatsHeader: FC = ({
onNewChat,
}) => {
const localize = useLocalize();
+ const menuId = useId();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
- const organizationItems: Array<{
- value: SidebarOrganizationMode;
- label: string;
- icon: ReactNode;
- }> = [
- {
- value: 'byProject',
- label: localize('com_ui_sidebar_mode_by_project'),
- icon: ,
- },
- {
- value: 'recentProjects',
- label: localize('com_ui_sidebar_mode_recent_projects'),
- icon: ,
- },
- {
- value: 'chronological',
- label: localize('com_ui_sidebar_mode_chronological_list'),
- icon: ,
- },
- ];
-
- const sortItems: Array<{
- value: SidebarChatSort;
- label: string;
- icon: ReactNode;
- }> = [
- {
- value: 'createdAt',
- label: localize('com_ui_sort_created'),
- icon: ,
- },
- {
- value: 'updatedAt',
- label: localize('com_ui_sort_updated'),
- icon: ,
- },
- ];
+ const dropdownItems = useMemo(
+ () => [
+ {
+ id: 'organize-sidebar',
+ label: localize('com_ui_sidebar_organization_label'),
+ icon: ,
+ subItems: [
+ createSelectedMenuItem({
+ id: 'organize-by-project',
+ value: 'byProject',
+ label: localize('com_ui_sidebar_mode_by_project'),
+ icon: ,
+ selectedValue: organizationMode,
+ onSelect: onOrganizationModeChange,
+ }),
+ createSelectedMenuItem({
+ id: 'organize-recent-projects',
+ value: 'recentProjects',
+ label: localize('com_ui_sidebar_mode_recent_projects'),
+ icon: ,
+ selectedValue: organizationMode,
+ onSelect: onOrganizationModeChange,
+ }),
+ createSelectedMenuItem({
+ id: 'organize-chronological',
+ value: 'chronological',
+ label: localize('com_ui_sidebar_mode_chronological_list'),
+ icon: ,
+ selectedValue: organizationMode,
+ onSelect: onOrganizationModeChange,
+ }),
+ ],
+ },
+ {
+ id: 'sort-chats',
+ label: localize('com_ui_sort_by'),
+ icon: ,
+ subItems: [
+ createSelectedMenuItem({
+ id: 'sort-created',
+ value: 'createdAt',
+ label: localize('com_ui_sort_created'),
+ icon: ,
+ selectedValue: chatSortBy,
+ onSelect: onChatSortByChange,
+ }),
+ createSelectedMenuItem({
+ id: 'sort-updated',
+ value: 'updatedAt',
+ label: localize('com_ui_sort_updated'),
+ icon: ,
+ selectedValue: chatSortBy,
+ onSelect: onChatSortByChange,
+ }),
+ ],
+ },
+ ],
+ [chatSortBy, localize, onChatSortByChange, onOrganizationModeChange, organizationMode],
+ );
return (
@@ -111,60 +163,34 @@ const ChatsHeader: FC = ({
/>
-
-
-
-
-
-
-
-
- {localize('com_ui_sidebar_organization_label')}
-
-
- {organizationItems.map((item) => (
- onOrganizationModeChange(item.value)}
- >
- {item.icon}
- {item.label}
-
-
- ))}
-
-
-
-
-
- {localize('com_ui_sort_by')}
-
-
- {sortItems.map((item) => (
- onChatSortByChange(item.value)}
- >
- {item.icon}
- {item.label}
-
-
- ))}
-
-
-
-
+
+ );
+ };
+}
+
export default function ProjectWorkspace() {
const localize = useLocalize();
const navigate = useNavigate();
const { projectId = '' } = useParams();
- const { newConversation } = useNewConvo();
const [sortBy, setSortBy] = useState('updatedAt');
+ const sortMenuId = useId();
+ const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
const activeProjectId = project?._id;
const sortOptions = useMemo(
@@ -33,6 +45,20 @@ export default function ProjectWorkspace() {
);
const selectedSortLabel =
sortOptions.find((option) => option.value === sortBy)?.label ?? localize('com_ui_sort_updated');
+ const sortMenuItems = useMemo(
+ () =>
+ 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,
@@ -66,19 +92,14 @@ export default function ProjectWorkspace() {
return lastPage.nextCursor !== null;
}, [data?.pages]);
- const startProjectChat = useCallback(
- (event?: FormEvent) => {
- event?.preventDefault();
- if (!activeProjectId) {
- return;
- }
- newConversation({ template: { chatProjectId: activeProjectId } });
- navigate(`/c/new?projectId=${encodeURIComponent(activeProjectId)}`, {
- state: { focusChat: true },
- });
- },
- [activeProjectId, navigate, newConversation],
- );
+ const startProjectChat = useCallback(() => {
+ if (!activeProjectId) {
+ return;
+ }
+ navigate(`/c/new?projectId=${encodeURIComponent(activeProjectId)}`, {
+ state: { focusChat: true },
+ });
+ }, [activeProjectId, navigate]);
if (isProjectLoading) {
return (
@@ -126,30 +147,6 @@ export default function ProjectWorkspace() {
-
-
@@ -160,30 +157,28 @@ export default function ProjectWorkspace() {
{localize('com_ui_chats')}
-
-
-
-
-
- {sortOptions.map((option) => (
- setSortBy(option.value)}>
- {option.label}
- {sortBy === option.value && (
-
- )}
-
- ))}
-
-
+
+ }
+ items={sortMenuItems}
+ />
+ {label}
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+}
+
function formatActivity(project: TChatProject, fallback: string) {
const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
return value ? new Date(value).toLocaleString() : fallback;
@@ -31,6 +39,8 @@ export default function ProjectsView() {
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState('lastConversationAt');
const [isCreating, setIsCreating] = useState(searchParams.get('new') === '1');
+ const sortMenuId = useId();
+ const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
const [name, setName] = useState('');
const deferredSearch = useDeferredValue(search);
const createProject = useCreateProjectMutation();
@@ -55,6 +65,20 @@ export default function ProjectsView() {
const selectedSortLabel =
sortOptions.find((option) => option.value === sortBy)?.label ??
localize('com_ui_latest_activity');
+ const sortMenuItems = useMemo(
+ () =>
+ 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],
+ );
const handleCreate = async (event: FormEvent) => {
event.preventDefault();
@@ -103,13 +127,21 @@ export default function ProjectsView() {
className="border-border-medium bg-transparent pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
/>
-
-
-
-
-
- {sortOptions.map((option) => (
- setSortBy(option.value)}>
- {option.label}
- {sortBy === option.value && (
-
- )}
-
- ))}
-
-
+
+ }
+ items={sortMenuItems}
+ />
{isCreating && (
diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts
index 0d4c5e1fd5..abdc1babf0 100644
--- a/client/src/hooks/Chat/useChatFunctions.ts
+++ b/client/src/hooks/Chat/useChatFunctions.ts
@@ -193,7 +193,10 @@ export default function useChatFunctions({
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
- navigate('/c/new', { state: { focusChat: true } });
+ const projectSearch = conversation?.chatProjectId
+ ? `?projectId=${encodeURIComponent(conversation.chatProjectId)}`
+ : '';
+ navigate(`/c/new${projectSearch}`, { state: { focusChat: true } });
}
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts
index d132b2299d..b0156ba201 100644
--- a/client/src/hooks/Input/useTextarea.ts
+++ b/client/src/hooks/Input/useTextarea.ts
@@ -28,11 +28,13 @@ export default function useTextarea({
submitButtonRef,
setIsScrollable,
disabled = false,
+ placeholder,
}: {
textAreaRef: React.RefObject;
submitButtonRef: React.RefObject;
setIsScrollable: React.Dispatch>;
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 })
@@ -137,6 +143,7 @@ export default function useTextarea({
conversation,
latestMessage,
isNotAppendable,
+ placeholder,
]);
const handleKeyDown = useCallback(
diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx
index 4e593dcab5..abbd04fbe0 100644
--- a/client/src/routes/ChatRoute.tsx
+++ b/client/src/routes/ChatRoute.tsx
@@ -98,15 +98,24 @@ 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 newConvoNeedsInit =
+ isNewConvo &&
+ (conversation?.conversationId !== Constants.NEW_CONVO ||
+ (verifiedChatProjectId
+ ? conversation?.chatProjectId !== verifiedChatProjectId
+ : conversation?.chatProjectId != null));
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;
}
@@ -218,6 +227,8 @@ export default function ChatRoute() {
chatProjectId,
projectQuery.data?._id,
projectQuery.isLoading,
+ conversation?.chatProjectId,
+ conversation?.conversationId,
]);
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
@@ -247,7 +258,7 @@ export default function ChatRoute() {
return (
-
+
);
}