diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 6d3f32de76f..d3077103b59 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -29,7 +29,9 @@ import { interface ChatRowProps { chat: OrganizationChat isCurrentRoute: boolean + isSelected: boolean isMenuOpen: boolean + onSelectChat: (chatId: string, shiftKey: boolean) => void onContextMenu: (e: React.MouseEvent, chatId: string) => void onMorePointerDown: () => void onMoreClick: (e: React.MouseEvent, chatId: string) => void @@ -38,7 +40,9 @@ interface ChatRowProps { function ChatRow({ chat, isCurrentRoute, + isSelected, isMenuOpen, + onSelectChat, onContextMenu, onMorePointerDown, onMoreClick, @@ -56,10 +60,11 @@ function ChatRow({ chatId={chat.id} isCurrentRoute={isCurrentRoute} className={cn( - chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true }), + chipVariants({ active: isCurrentRoute || isSelected || isMenuOpen, fullWidth: true }), rowActionsGroupClass )} onContextMenu={(e) => onContextMenu(e, chat.id)} + onSelectChat={onSelectChat} > { void rename.saveRename() } @@ -137,6 +141,8 @@ export function ChatsSection({ key={chat.id} chat={chat} isCurrentRoute={pathname === chat.href} + isSelected={selectedChats.size > 1 && selectedChats.has(chat.id)} + onSelectChat={actions.handleChatClick} isMenuOpen={menuOpenChatId === chat.id} isEditing={rename.editingId === chat.id} editValue={rename.value} @@ -179,7 +185,9 @@ export function ChatsSection({ key={chat.id} chat={chat} isCurrentRoute={pathname === chat.href} + isSelected={selectedChats.size > 1 && selectedChats.has(chat.id)} isMenuOpen={menuOpenChatId === chat.id} + onSelectChat={actions.handleChatClick} onContextMenu={actions.onContextMenu} onMorePointerDown={actions.onMorePointerDown} onMoreClick={actions.onMoreClick} @@ -197,29 +205,30 @@ export function ChatsSection({ menuRef={menu.menuRef} onClose={menu.closeMenu} onOpenInNewTab={actions.openInNewTab} - onCopyLink={actions.copyLink} + onCopyLink={selectedChat ? actions.copyLink : undefined} onRename={actions.startRename} renameInputRef={rename.inputRef} onTogglePin={actions.togglePin} onMarkAsRead={actions.markRead} onMarkAsUnread={actions.markUnread} - showOpenInNewTab + showOpenInNewTab={Boolean(selectedChat)} showRename={Boolean(selectedChat)} showPin={Boolean(selectedChat)} isPinned={Boolean(selectedChat?.isPinned)} showMarkAsRead={Boolean(selectedChat?.isUnread)} showMarkAsUnread={Boolean(selectedChat) && !selectedChat?.isUnread} onDelete={actions.startDelete} - showDelete={Boolean(selectedChat)} + showDelete={actions.selectedCount > 0} + selectedCount={actions.selectedCount} showDuplicate={false} /> 0} onClose={actions.cancelDelete} onConfirm={actions.confirmDelete} isDeleting={actions.isDeleting} itemType='task' - itemName={actions.chatToDelete?.name} + itemName={actions.chatsToDelete.map((chat) => chat.name)} /> ) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts index 1ad28686c39..0825cc2af8c 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts @@ -1,19 +1,23 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { organizationRoutes } from '@/lib/navigation/paths' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats' +import { useChatSelection } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection' import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename' import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' import { + MothershipChatDeleteError, useDeleteMothershipChat, + useDeleteMothershipChats, useMarkMothershipChatRead, useMarkMothershipChatUnread, useRenameMothershipChat, useSetMothershipChatPinned, } from '@/hooks/queries/mothership-chats' import { useContextMenu } from '@/hooks/use-context-menu' +import { useFolderStore } from '@/stores/folders/store' interface UseOrganizationChatActionsProps { organizationId: string @@ -26,16 +30,28 @@ export function useOrganizationChatActions({ }: UseOrganizationChatActionsProps) { const router = useRouter() const owner = { organizationId } - const { mutate: deleteChat, isPending: isDeleting } = useDeleteMothershipChat(owner) + const deleteChat = useDeleteMothershipChat(owner) + const deleteChats = useDeleteMothershipChats(owner) + const isDeleting = deleteChat.isPending || deleteChats.isPending const { mutateAsync: renameChat } = useRenameMothershipChat(owner) const { mutate: pinChat } = useSetMothershipChatPinned(owner) const { mutate: readChat } = useMarkMothershipChatRead(owner) const { mutate: unreadChat } = useMarkMothershipChatUnread(owner) const menu = useContextMenu() const hover = useHoverMenu() - const [selectedChatId, setSelectedChatId] = useState(null) - const [chatToDelete, setChatToDelete] = useState(null) - const selectedChat = chats.find((chat) => chat.id === selectedChatId) + const chatIds = useMemo(() => chats.map((chat) => chat.id), [chats]) + const { selectedChats, handleChatClick } = useChatSelection({ chatIds }) + const [menuSelection, setMenuSelection] = useState<{ + chatId: string + chatIds: string[] + } | null>(null) + const [chatIdsToDelete, setChatIdsToDelete] = useState([]) + const deleteTargetIds = new Set(chatIdsToDelete) + const chatsToDelete = chats.filter((chat) => deleteTargetIds.has(chat.id)) + const selectedChat = + menuSelection?.chatIds.length === 1 + ? chats.find((chat) => chat.id === menuSelection.chatId) + : undefined const rename = useFlyoutInlineRename({ itemType: 'chat', onSave: async (chatId, title) => { @@ -53,14 +69,27 @@ export function useOrganizationChatActions({ hover.setLocked(menu.isOpen || rename.editingId !== null) }, [menu.isOpen, rename.editingId, hover.setLocked]) + const captureSelection = useCallback( + (chatId: string) => { + const { selectedChats, selectChatOnly } = useFolderStore.getState() + if (selectedChats.has(chatId)) { + setMenuSelection({ chatId, chatIds: chatIds.filter((id) => selectedChats.has(id)) }) + } else { + selectChatOnly(chatId) + setMenuSelection({ chatId, chatIds: [chatId] }) + } + }, + [chatIds] + ) + const onContextMenu = useCallback( (event: React.MouseEvent, chatId: string) => { - setSelectedChatId(chatId) + captureSelection(chatId) hover.setLocked(true) menu.preventDismiss() menu.handleContextMenu(event) }, - [hover.setLocked, menu.preventDismiss, menu.handleContextMenu] + [captureSelection, hover.setLocked, menu.preventDismiss, menu.handleContextMenu] ) const onMorePointerDown = useCallback(() => { @@ -69,16 +98,16 @@ export function useOrganizationChatActions({ const onMoreClick = useCallback( (event: React.MouseEvent, chatId: string) => { - if (menu.isOpen && selectedChatId === chatId) { + if (menu.isOpen && menuSelection?.chatId === chatId) { menu.closeMenu() return } const rect = event.currentTarget.getBoundingClientRect() - setSelectedChatId(chatId) + captureSelection(chatId) hover.setLocked(true) menu.openMenuAt({ x: rect.right, y: rect.top }) }, - [menu.isOpen, menu.closeMenu, menu.openMenuAt, hover.setLocked, selectedChatId] + [menu.isOpen, menu.closeMenu, menu.openMenuAt, hover.setLocked, menuSelection, captureSelection] ) const chatId = selectedChat?.id @@ -87,25 +116,54 @@ export function useOrganizationChatActions({ const chatPinned = selectedChat?.isPinned const startDelete = useCallback(() => { - if (selectedChat) setChatToDelete(selectedChat) - }, [selectedChat]) + setChatIdsToDelete(menuSelection?.chatIds ?? []) + }, [menuSelection]) const cancelDelete = useCallback(() => { - if (!isDeleting) setChatToDelete(null) + if (!isDeleting) setChatIdsToDelete([]) }, [isDeleting]) const confirmDelete = useCallback(() => { - if (!chatToDelete || isDeleting) return - deleteChat(chatToDelete.id, { + if (chatsToDelete.length === 0 || isDeleting) return + const redirectIfDeleted = (ids: string[]) => { + if ( + ids.some((id) => window.location.pathname === organizationRoutes(organizationId).chat(id)) + ) { + router.push(organizationRoutes(organizationId).home) + } + } + const options = { onSuccess: () => { - setChatToDelete(null) - if (window.location.pathname === chatToDelete.href) { - router.push(organizationRoutes(organizationId).home) + setChatIdsToDelete([]) + useFolderStore.getState().clearChatSelection() + redirectIfDeleted(chatIdsToDelete) + }, + onError: (error: Error) => { + if (error instanceof MothershipChatDeleteError) { + const deletedIds = new Set(error.deletedChatIds) + setChatIdsToDelete((ids) => ids.filter((id) => !deletedIds.has(id))) + redirectIfDeleted(error.deletedChatIds) } + toast.error(error.message) }, - onError: (error) => toast.error(error.message), - }) - }, [chatToDelete, deleteChat, isDeleting, organizationId, router]) + } + if (chatsToDelete.length === 1) { + deleteChat.mutate(chatsToDelete[0].id, options) + } else { + deleteChats.mutate( + chatsToDelete.map((chat) => chat.id), + options + ) + } + }, [ + chatIdsToDelete, + chatsToDelete, + deleteChat.mutate, + deleteChats.mutate, + isDeleting, + organizationId, + router, + ]) const startRename = useCallback(() => { if (chatId && chatName !== undefined) rename.startRename({ id: chatId, name: chatName }) @@ -142,11 +200,15 @@ export function useOrganizationChatActions({ hover, rename, selectedChat, + selectedChats, + handleChatClick, + menuOpenChatId: menu.isOpen ? menuSelection?.chatId : null, + selectedCount: menuSelection?.chatIds.length ?? 0, onContextMenu, onMorePointerDown, onMoreClick, startRename, - chatToDelete, + chatsToDelete, isDeleting, startDelete, cancelDelete, diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index 98a56a4c27e..890439e11b4 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -37,7 +37,9 @@ import { SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { isSidebarBackgroundClick } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useContextMenu } from '@/hooks/use-context-menu' +import { useFolderStore } from '@/stores/folders/store' import { useSidebarStore } from '@/stores/sidebar/store' const logger = createLogger('OrganizationSidebar') @@ -144,6 +146,11 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { } } + const handleSidebarClick = (event: React.MouseEvent) => { + if (!isSidebarBackgroundClick(event)) return + useFolderStore.getState().clearChatSelection() + } + useRegisterGlobalCommands(() => createCommands([ { @@ -161,6 +168,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { className='group/rail sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)] [&_.group.cursor-pointer]:duration-0' data-collapsed={isCollapsed || undefined} aria-label='Organization sidebar' + onClick={handleSidebarClick} >
{/* The peek card already sits below the lane; reserving it again doubles the offset. */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx index 3b676835670..2d8add732f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx @@ -33,6 +33,7 @@ interface ChatNavigationLinkProps extends Omit, 'hre chatId: string href: string isCurrentRoute?: boolean + onSelectChat?: (chatId: string, shiftKey: boolean) => void } export function ChatNavigationLink(props: ChatNavigationLinkProps) { @@ -46,6 +47,7 @@ function IntentAwareChatNavigationLink({ isCurrentRoute = false, onBlur, onClick, + onSelectChat, onFocus, onMouseEnter, onMouseLeave, @@ -135,7 +137,16 @@ function IntentAwareChatNavigationLink({ }} onTouchStart={onTouchStart} onClick={(event) => { + const isSelectionClick = + !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.altKey + if (isSelectionClick && event.shiftKey && onSelectChat) { + event.preventDefault() + onSelectChat(chatId, true) + } onClick?.(event) + if (isSelectionClick && !event.defaultPrevented) { + onSelectChat?.(chatId, false) + } if ( !event.defaultPrevented && !event.metaKey && diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index d2fe6b34a71..f1ee1cebea3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -156,6 +156,8 @@ type CollapsedSidebarMenuProps = { interface CollapsedChatFlyoutItemProps { chat: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean } isCurrentRoute: boolean + isSelected?: boolean + onSelectChat?: (chatId: string, shiftKey: boolean) => void isMenuOpen?: boolean isEditing?: boolean editValue?: string @@ -284,6 +286,8 @@ export function CollapsedSidebarMenu({ export function CollapsedChatFlyoutItem({ chat, isCurrentRoute, + isSelected = false, + onSelectChat, isMenuOpen = false, isEditing = false, editValue, @@ -330,7 +334,7 @@ export function CollapsedChatFlyoutItem({ return ( onContextMenu(e, chat.id) : undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection.ts index baaf03c4b70..1eb8fbe2c11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection.ts @@ -16,7 +16,8 @@ interface UseChatSelectionProps { * Selecting chats clears workflow/folder selections and vice versa. */ export function useChatSelection({ chatIds }: UseChatSelectionProps) { - const selectedChats = useFolderStore((s) => s.selectedChats) + const allSelectedChats = useFolderStore((s) => s.selectedChats) + const selectedChats = new Set(chatIds.filter((id) => allSelectedChats.has(id))) const handleChatClick = useCallback( (chatId: string, shiftKey: boolean) => { @@ -26,7 +27,9 @@ export function useChatSelection({ chatIds }: UseChatSelectionProps) { toggleChatSelection, lastSelectedChatId: anchor, } = useFolderStore.getState() - if (shiftKey && anchor && anchor !== chatId) { + if (!anchor || !chatIds.includes(anchor)) { + selectChatOnly(chatId) + } else if (shiftKey && anchor !== chatId) { selectChatRange(chatIds, anchor, chatId) } else if (shiftKey) { toggleChatSelection(chatId) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 9d68e0d0df0..c111d8ae785 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -115,6 +115,7 @@ import { compareByOrder, createSidebarDragGhost, groupWorkflowsByFolder, + isSidebarBackgroundClick, } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' @@ -124,6 +125,7 @@ import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { type LogFilters, useLogsList } from '@/hooks/queries/logs' import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats' import { + MothershipChatDeleteError, useDeleteMothershipChat, useDeleteMothershipChats, useMarkMothershipChatRead, @@ -247,15 +249,7 @@ const SidebarChatItem = memo(function SidebarChatItem({ }), rowActionsGroupClass )} - onClick={(e) => { - if (e.metaKey || e.ctrlKey) return - if (e.shiftKey) { - e.preventDefault() - onMultiSelectClick(chat.id, true) - } else { - useFolderStore.getState().selectChatOnly(chat.id) - } - }} + onSelectChat={onMultiSelectClick} onContextMenu={(e) => onContextMenu(e, chat.id)} draggable onDragStart={handleDragStart} @@ -894,7 +888,19 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) if (chatIdsToDelete.length === 1) { deleteChatMutation.mutate(chatIdsToDelete[0], { onSuccess: onDeleteSuccess }) } else { - deleteChatsMutation.mutate(chatIdsToDelete, { onSuccess: onDeleteSuccess }) + deleteChatsMutation.mutate(chatIdsToDelete, { + onSuccess: onDeleteSuccess, + onError: (error) => { + if ( + error instanceof MothershipChatDeleteError && + error.deletedChatIds.some( + (id) => window.location.pathname === `/workspace/${workspaceId}/chat/${id}` + ) + ) { + router.push(`/workspace/${workspaceId}/home`) + } + }, + }) } setIsChatDeleteModalOpen(false) } @@ -1109,10 +1115,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) ) const handleSidebarClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement - if (target.tagName === 'BUTTON' || target.closest('button, [role="button"], a')) { - return - } + if (!isSidebarBackgroundClick(e)) return const { selectOnly, clearAllSelection } = useFolderStore.getState() workflowId ? selectOnly(workflowId) : clearAllSelection() } @@ -1455,6 +1458,8 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps) key={chat.id} chat={chat} isCurrentRoute={pathname === chat.href} + isSelected={hasChatMultiSelection && selectedChats.has(chat.id)} + onSelectChat={handleChatClick} isMenuOpen={menuOpenChatId === chat.id} isEditing={chat.id === chatFlyoutRename.editingId} editValue={chatFlyoutRename.value} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts index 234df995058..62f818b88bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -1,9 +1,21 @@ +import type { MouseEvent } from 'react' import type { MothershipResource } from '@/lib/mothership/resource-types' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +/** Distinguishes sidebar background clicks from controls and portaled overlays. */ +export function isSidebarBackgroundClick(event: MouseEvent): boolean { + return ( + event.target instanceof Element && + event.currentTarget.contains(event.target) && + !event.target.closest( + 'a, button, input, textarea, select, [role="button"], [contenteditable]:not([contenteditable="false"])' + ) + ) +} + /** * Builds a `MothershipResource` array from a sidebar drag selection so it can * be set as `application/x-sim-resources` drag data and dropped into the chat. diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 6cfa43945aa..b4eb830978f 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -1,3 +1,4 @@ +import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { keepPreviousData, @@ -64,6 +65,17 @@ export interface MothershipChatHistory { export type MothershipChatOwner = string | { organizationId: string } +/** Reports which chats were deleted when another request in the batch failed. */ +export class MothershipChatDeleteError extends Error { + constructor( + readonly deletedChatIds: string[], + cause: Error + ) { + super(cause.message, { cause }) + this.name = 'MothershipChatDeleteError' + } +} + export const mothershipChatKeys = { all: ['mothership-chats'] as const, lists: () => [...mothershipChatKeys.all, 'list'] as const, @@ -377,7 +389,10 @@ export function useDeleteMothershipChats(owner?: MothershipChatOwner) { }) ) const failed = results.find((result) => result.status === 'rejected') - if (failed) throw failed.reason + if (failed) { + const deletedChatIds = chatIds.filter((_, index) => results[index].status === 'fulfilled') + throw new MothershipChatDeleteError(deletedChatIds, toError(failed.reason)) + } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: mothershipChatKeys.ownerLists(owner) })