Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3bc5b84
style: Redesign the Projects Dashboard and Sidebar
berry-13 Aug 14, 2026
c658fff
feat: Edit a Project Name and Description
berry-13 Aug 15, 2026
87f9b63
feat: Delete a Project from the Workspace
berry-13 Aug 15, 2026
b243ab5
feat: Add Edit and Delete Actions to Project Cards
berry-13 Aug 15, 2026
a8028bc
feat: Match Project Descriptions When Searching Projects
berry-13 Aug 15, 2026
6f956ae
feat: Let Consumers Place and Size the ControlCombobox Popover
berry-13 Aug 15, 2026
2de7c06
fix: Re-render Conversation Rows When Pinned State Changes
berry-13 Aug 15, 2026
a0307aa
fix: Keep the Project Scope When Starting a Chat From a Project
berry-13 Aug 15, 2026
c2356c9
style: Move the Project Chip Into the Composer
berry-13 Aug 15, 2026
c8121f6
feat: Rebuild the Change Project Dialog on the Searchable Combobox
berry-13 Aug 15, 2026
fdf1704
feat: Add an Overflow Menu to Project Workspace Chats
berry-13 Aug 15, 2026
160a641
feat: Rework the Projects Sidebar Section
berry-13 Aug 15, 2026
3f3acc4
style: Widen the Projects Dashboard and Workspace Layout
berry-13 Aug 15, 2026
d882158
fix: Reopen the Change Project Dialog From the Conversation Menu
berry-13 Aug 15, 2026
1a1ddb7
fix: Keep the Project Chat Menu Mounted While its Dialogs Open
berry-13 Aug 15, 2026
1488f53
perf: Fetch a Project's Chats Only Once its Row is Expanded
berry-13 Aug 15, 2026
fe48a62
refactor: Move the Bottom Popover Animation Into the Shared Primitive
berry-13 Aug 15, 2026
a8a97db
fix: Stop Project Names and Descriptions Being Silently Truncated
berry-13 Aug 15, 2026
8485137
fix: Do Not Report the Loaded Page Size as the Project Total
berry-13 Aug 15, 2026
8e966c1
chore: Satisfy the Static Checks for the Projects Rework
berry-13 Aug 15, 2026
9e0d00b
fix: Highlight Only the Route Project in the Sidebar
berry-13 Aug 16, 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
8 changes: 5 additions & 3 deletions client/src/components/Chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import ProjectLandingChip from './ProjectLandingChip';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
Expand Down Expand Up @@ -143,9 +142,12 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
{isProjectLandingPage && project && <ProjectLandingChip project={project} />}
{isLandingPage && <ConversationStarters />}
<ChatForm index={index} placeholder={chatFormPlaceholder} />
<ChatForm
index={index}
placeholder={chatFormPlaceholder}
project={isProjectLandingPage ? project : undefined}
/>
{!isLandingPage && <Footer />}
</div>
</div>
Expand Down
19 changes: 16 additions & 3 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useWatch } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { TChatProject, TMessage, TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
import type { QueuedMessageContext } from '~/hooks/Chat/useSteering';
import {
Expand All @@ -28,6 +28,7 @@ import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskUserQuestionPopover from './AskUserQuestionPopover';
import InterruptSteerButton from './InterruptSteerButton';
import DuringRunSendButton from './DuringRunSendButton';
import ProjectLandingChip from '../ProjectLandingChip';
import { useGetStartupConfig } from '~/data-provider';
import { mainTextareaId, BadgeItem } from '~/common';
import PendingSteerChips from './PendingSteerChips';
Expand All @@ -54,7 +55,8 @@ import store from '~/store';
interface ChatFormProps {
index: number;
placeholder?: string;
/** From ChatContext — individual values so memo can compare them */
project?: TChatProject;
/** From ChatContext: individual values so memo can compare them */
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
conversation: TConversation | null;
Expand All @@ -68,6 +70,7 @@ interface ChatFormProps {
const ChatForm = memo(function ChatForm({
index,
placeholder,
project,
files,
setFiles,
conversation,
Expand Down Expand Up @@ -556,6 +559,7 @@ const ChatForm = memo(function ChatForm({
: 'border-border-light bg-surface-chat',
)}
>
{project ? <ProjectLandingChip project={project} /> : null}
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<PendingManualSkillsChips conversationId={conversationId} />
{quotesEnabled && <PendingQuoteChips conversationId={conversationId} />}
Expand Down Expand Up @@ -732,7 +736,15 @@ 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, placeholder }: { index?: number; placeholder?: string }) {
function ChatFormWrapper({
index = 0,
placeholder,
project,
}: {
index?: number;
placeholder?: string;
project?: TChatProject;
}) {
const {
files,
setFiles,
Expand Down Expand Up @@ -792,6 +804,7 @@ function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placehold
<ChatForm
index={index}
placeholder={placeholder}
project={project}
files={files}
setFiles={setFiles}
conversation={stableConversation}
Expand Down
21 changes: 12 additions & 9 deletions client/src/components/Chat/ProjectLandingChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ 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.
* Project scope control rendered inside the composer on a project-scoped
* new-chat landing. The trigger switches projects; the trailing × drops the
* scope. Both update the draft and `?projectId` in place so the typed message
* and model are kept.
*/
export default function ProjectLandingChip({ project }: { project: TChatProject }) {
const localize = useLocalize();
Expand Down Expand Up @@ -53,7 +53,7 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
);

return (
<div className="group mb-2.5 flex items-center gap-1 px-1">
<div className="flex items-center gap-0.5 px-2.5 pt-2">
<ControlCombobox
selectId="project-landing-select"
selectedValue={project._id}
Expand All @@ -69,9 +69,12 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
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"
placement="top-start"
gutter={12}
matchTriggerWidth={false}
containerClassName="w-auto min-w-0 px-0"
className="h-8 w-auto min-w-[7.5rem] max-w-[14rem] gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-sm font-medium text-text-secondary hover:bg-surface-hover hover:text-text-primary"
popoverClassName="animate-popover-bottom min-w-64 rounded-2xl shadow-xl"
/>
<TooltipAnchor
description={localize('com_ui_remove_from_project')}
Expand All @@ -80,7 +83,7 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
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"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
Expand Down
1 change: 0 additions & 1 deletion client/src/components/Chat/__tests__/ChatView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ jest.mock('../Presentation', () => ({
jest.mock('../Header', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Footer', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Landing', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../ProjectLandingChip', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Messages/MessagesView', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ChatForm', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ConversationStarters', () => ({ __esModule: true, default: () => <div /> }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function ConvoOptions({
const { newConversation } = useNewConvo();

const menuId = useId();
const menuButtonRef = useRef<HTMLButtonElement>(null);
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const projectButtonRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -427,6 +428,7 @@ function ConvoOptions({
setIsOpen={setIsPopoverActive}
trigger={
<Ariakit.MenuButton
ref={menuButtonRef}
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-expanded={isPopoverActive}
Expand Down Expand Up @@ -470,7 +472,7 @@ function ConvoOptions({
conversationId={conversationId ?? ''}
chatProjectId={chatProjectId}
setMenuOpen={setIsPopoverActive}
triggerRef={projectButtonRef}
triggerRef={menuButtonRef}
showProjectDialog={showProjectDialog}
setShowProjectDialog={setShowProjectDialog}
/>
Expand Down
105 changes: 66 additions & 39 deletions client/src/components/Conversations/ConvoOptions/ProjectButton.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { useEffect, useMemo, useState, type RefObject } from 'react';
import { useMemo, useState, type RefObject } from 'react';
import { Folder } from 'lucide-react';
import {
Button,
Spinner,
ControlCombobox,
Label,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Spinner,
useToastContext,
} from '@librechat/client';
import type { TChatProject } from 'librechat-data-provider';
import type { OptionWithIcon } from '~/common';
import { useAssignConversationToProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';

type ProjectButtonProps = {
conversationId: string;
Expand Down Expand Up @@ -41,23 +45,37 @@ function ProjectConversationDialog({
const { data, fetchNextPage, isFetchingNextPage } = useProjectsInfiniteQuery({
sortBy: 'name',
sortDirection: 'asc',
limit: 100,
});

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 selectedProject = projects.find((project) => project._id === selectedProjectId);
const projectItems = useMemo<OptionWithIcon[]>(
() =>
projects.map((project) => ({
label: project.name,
value: project._id,
icon: <Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />,
})),
[projects],
);
const canSave =
Boolean(selectedProjectId) &&
selectedProjectId !== (chatProjectId ?? '') &&
!assignConversation.isLoading;

const saveProject = () => {
if (!canSave) {
return;
}
assignConversation.mutate(
{
conversationId,
projectId: selectedProjectId || null,
projectId: selectedProjectId,
},
{
onSuccess: () => {
Expand All @@ -83,44 +101,53 @@ function ProjectConversationDialog({
return (
<OGDialogContent
id="project-conversation-dialog"
className="w-11/12 max-w-md"
className="w-11/12 max-w-md overflow-visible"
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">
<div className="flex flex-col gap-2">
<Label htmlFor="change-project-select" className="text-sm font-medium text-text-primary">
{localize('com_ui_select_project')}
</Label>
<ControlCombobox
selectId="change-project-select"
selectedValue={selectedProjectId}
displayValue={selectedProject?.name}
items={projectItems}
setValue={setSelectedProjectId}
SelectIcon={<Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />}
ariaLabel={localize('com_ui_select_project')}
searchPlaceholder={localize('com_ui_search_projects')}
selectPlaceholder={localize('com_ui_select_project')}
isCollapsed={false}
showCarat={true}
placement="bottom-start"
portal={false}
matchTriggerWidth={true}
containerClassName="w-full px-0"
className="h-10 w-full justify-start gap-2 rounded-xl border border-border-light bg-surface-tertiary px-3 text-sm text-text-primary hover:bg-surface-hover"
/>
{hasNextPage ? (
<Button
type="button"
variant="link"
className="h-auto justify-start px-0 text-sm"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
</Button>
) : null}
</div>
<div className="flex justify-end gap-2 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
<Button type="button" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button onClick={saveProject} disabled={assignConversation.isLoading}>
<Button type="button" variant="submit" onClick={saveProject} disabled={!canSave}>
{assignConversation.isLoading ? <Spinner /> : localize('com_ui_save')}
</Button>
</div>
Expand Down
Loading
Loading