Skip to content

Commit 3ab8ed0

Browse files
authored
fix(chats): align organization shift-click selection with workspaces (#8389)
* fix(chats): align organization shift-click selection with workspaces * fix(chats): preserve selection for modified and overlay clicks * fix(chats): reconcile partial deletes and collapsed selection
1 parent d12e31c commit 3ab8ed0

9 files changed

Lines changed: 178 additions & 48 deletions

File tree

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ import {
2929
interface ChatRowProps {
3030
chat: OrganizationChat
3131
isCurrentRoute: boolean
32+
isSelected: boolean
3233
isMenuOpen: boolean
34+
onSelectChat: (chatId: string, shiftKey: boolean) => void
3335
onContextMenu: (e: React.MouseEvent, chatId: string) => void
3436
onMorePointerDown: () => void
3537
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, chatId: string) => void
@@ -38,7 +40,9 @@ interface ChatRowProps {
3840
function ChatRow({
3941
chat,
4042
isCurrentRoute,
43+
isSelected,
4144
isMenuOpen,
45+
onSelectChat,
4246
onContextMenu,
4347
onMorePointerDown,
4448
onMoreClick,
@@ -56,10 +60,11 @@ function ChatRow({
5660
chatId={chat.id}
5761
isCurrentRoute={isCurrentRoute}
5862
className={cn(
59-
chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true }),
63+
chipVariants({ active: isCurrentRoute || isSelected || isMenuOpen, fullWidth: true }),
6064
rowActionsGroupClass
6165
)}
6266
onContextMenu={(e) => onContextMenu(e, chat.id)}
67+
onSelectChat={onSelectChat}
6368
>
6469
<OverflowText label={chat.name} className='flex-1 text-[var(--text-body)]' />
6570
<RowActions
@@ -108,8 +113,7 @@ export function ChatsSection({
108113
pathname,
109114
}: ChatsSectionProps) {
110115
const actions = useOrganizationChatActions({ organizationId, chats })
111-
const { menu, hover, rename, selectedChat } = actions
112-
const menuOpenChatId = menu.isOpen ? selectedChat?.id : null
116+
const { menu, hover, rename, selectedChat, selectedChats, menuOpenChatId } = actions
113117
const saveRename = () => {
114118
void rename.saveRename()
115119
}
@@ -137,6 +141,8 @@ export function ChatsSection({
137141
key={chat.id}
138142
chat={chat}
139143
isCurrentRoute={pathname === chat.href}
144+
isSelected={selectedChats.size > 1 && selectedChats.has(chat.id)}
145+
onSelectChat={actions.handleChatClick}
140146
isMenuOpen={menuOpenChatId === chat.id}
141147
isEditing={rename.editingId === chat.id}
142148
editValue={rename.value}
@@ -179,7 +185,9 @@ export function ChatsSection({
179185
key={chat.id}
180186
chat={chat}
181187
isCurrentRoute={pathname === chat.href}
188+
isSelected={selectedChats.size > 1 && selectedChats.has(chat.id)}
182189
isMenuOpen={menuOpenChatId === chat.id}
190+
onSelectChat={actions.handleChatClick}
183191
onContextMenu={actions.onContextMenu}
184192
onMorePointerDown={actions.onMorePointerDown}
185193
onMoreClick={actions.onMoreClick}
@@ -197,29 +205,30 @@ export function ChatsSection({
197205
menuRef={menu.menuRef}
198206
onClose={menu.closeMenu}
199207
onOpenInNewTab={actions.openInNewTab}
200-
onCopyLink={actions.copyLink}
208+
onCopyLink={selectedChat ? actions.copyLink : undefined}
201209
onRename={actions.startRename}
202210
renameInputRef={rename.inputRef}
203211
onTogglePin={actions.togglePin}
204212
onMarkAsRead={actions.markRead}
205213
onMarkAsUnread={actions.markUnread}
206-
showOpenInNewTab
214+
showOpenInNewTab={Boolean(selectedChat)}
207215
showRename={Boolean(selectedChat)}
208216
showPin={Boolean(selectedChat)}
209217
isPinned={Boolean(selectedChat?.isPinned)}
210218
showMarkAsRead={Boolean(selectedChat?.isUnread)}
211219
showMarkAsUnread={Boolean(selectedChat) && !selectedChat?.isUnread}
212220
onDelete={actions.startDelete}
213-
showDelete={Boolean(selectedChat)}
221+
showDelete={actions.selectedCount > 0}
222+
selectedCount={actions.selectedCount}
214223
showDuplicate={false}
215224
/>
216225
<DeleteModal
217-
isOpen={actions.chatToDelete !== null}
226+
isOpen={actions.chatsToDelete.length > 0}
218227
onClose={actions.cancelDelete}
219228
onConfirm={actions.confirmDelete}
220229
isDeleting={actions.isDeleting}
221230
itemType='task'
222-
itemName={actions.chatToDelete?.name}
231+
itemName={actions.chatsToDelete.map((chat) => chat.name)}
223232
/>
224233
</>
225234
)

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts‎

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useState } from 'react'
22
import { toast } from '@sim/emcn'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { useRouter } from 'next/navigation'
55
import { organizationRoutes } from '@/lib/navigation/paths'
66
import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats'
7+
import { useChatSelection } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-chat-selection'
78
import { useFlyoutInlineRename } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename'
89
import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu'
910
import {
11+
MothershipChatDeleteError,
1012
useDeleteMothershipChat,
13+
useDeleteMothershipChats,
1114
useMarkMothershipChatRead,
1215
useMarkMothershipChatUnread,
1316
useRenameMothershipChat,
1417
useSetMothershipChatPinned,
1518
} from '@/hooks/queries/mothership-chats'
1619
import { useContextMenu } from '@/hooks/use-context-menu'
20+
import { useFolderStore } from '@/stores/folders/store'
1721

1822
interface UseOrganizationChatActionsProps {
1923
organizationId: string
@@ -26,16 +30,28 @@ export function useOrganizationChatActions({
2630
}: UseOrganizationChatActionsProps) {
2731
const router = useRouter()
2832
const owner = { organizationId }
29-
const { mutate: deleteChat, isPending: isDeleting } = useDeleteMothershipChat(owner)
33+
const deleteChat = useDeleteMothershipChat(owner)
34+
const deleteChats = useDeleteMothershipChats(owner)
35+
const isDeleting = deleteChat.isPending || deleteChats.isPending
3036
const { mutateAsync: renameChat } = useRenameMothershipChat(owner)
3137
const { mutate: pinChat } = useSetMothershipChatPinned(owner)
3238
const { mutate: readChat } = useMarkMothershipChatRead(owner)
3339
const { mutate: unreadChat } = useMarkMothershipChatUnread(owner)
3440
const menu = useContextMenu()
3541
const hover = useHoverMenu()
36-
const [selectedChatId, setSelectedChatId] = useState<string | null>(null)
37-
const [chatToDelete, setChatToDelete] = useState<OrganizationChat | null>(null)
38-
const selectedChat = chats.find((chat) => chat.id === selectedChatId)
42+
const chatIds = useMemo(() => chats.map((chat) => chat.id), [chats])
43+
const { selectedChats, handleChatClick } = useChatSelection({ chatIds })
44+
const [menuSelection, setMenuSelection] = useState<{
45+
chatId: string
46+
chatIds: string[]
47+
} | null>(null)
48+
const [chatIdsToDelete, setChatIdsToDelete] = useState<string[]>([])
49+
const deleteTargetIds = new Set(chatIdsToDelete)
50+
const chatsToDelete = chats.filter((chat) => deleteTargetIds.has(chat.id))
51+
const selectedChat =
52+
menuSelection?.chatIds.length === 1
53+
? chats.find((chat) => chat.id === menuSelection.chatId)
54+
: undefined
3955
const rename = useFlyoutInlineRename({
4056
itemType: 'chat',
4157
onSave: async (chatId, title) => {
@@ -53,14 +69,27 @@ export function useOrganizationChatActions({
5369
hover.setLocked(menu.isOpen || rename.editingId !== null)
5470
}, [menu.isOpen, rename.editingId, hover.setLocked])
5571

72+
const captureSelection = useCallback(
73+
(chatId: string) => {
74+
const { selectedChats, selectChatOnly } = useFolderStore.getState()
75+
if (selectedChats.has(chatId)) {
76+
setMenuSelection({ chatId, chatIds: chatIds.filter((id) => selectedChats.has(id)) })
77+
} else {
78+
selectChatOnly(chatId)
79+
setMenuSelection({ chatId, chatIds: [chatId] })
80+
}
81+
},
82+
[chatIds]
83+
)
84+
5685
const onContextMenu = useCallback(
5786
(event: React.MouseEvent, chatId: string) => {
58-
setSelectedChatId(chatId)
87+
captureSelection(chatId)
5988
hover.setLocked(true)
6089
menu.preventDismiss()
6190
menu.handleContextMenu(event)
6291
},
63-
[hover.setLocked, menu.preventDismiss, menu.handleContextMenu]
92+
[captureSelection, hover.setLocked, menu.preventDismiss, menu.handleContextMenu]
6493
)
6594

6695
const onMorePointerDown = useCallback(() => {
@@ -69,16 +98,16 @@ export function useOrganizationChatActions({
6998

7099
const onMoreClick = useCallback(
71100
(event: React.MouseEvent<HTMLButtonElement>, chatId: string) => {
72-
if (menu.isOpen && selectedChatId === chatId) {
101+
if (menu.isOpen && menuSelection?.chatId === chatId) {
73102
menu.closeMenu()
74103
return
75104
}
76105
const rect = event.currentTarget.getBoundingClientRect()
77-
setSelectedChatId(chatId)
106+
captureSelection(chatId)
78107
hover.setLocked(true)
79108
menu.openMenuAt({ x: rect.right, y: rect.top })
80109
},
81-
[menu.isOpen, menu.closeMenu, menu.openMenuAt, hover.setLocked, selectedChatId]
110+
[menu.isOpen, menu.closeMenu, menu.openMenuAt, hover.setLocked, menuSelection, captureSelection]
82111
)
83112

84113
const chatId = selectedChat?.id
@@ -87,25 +116,54 @@ export function useOrganizationChatActions({
87116
const chatPinned = selectedChat?.isPinned
88117

89118
const startDelete = useCallback(() => {
90-
if (selectedChat) setChatToDelete(selectedChat)
91-
}, [selectedChat])
119+
setChatIdsToDelete(menuSelection?.chatIds ?? [])
120+
}, [menuSelection])
92121

93122
const cancelDelete = useCallback(() => {
94-
if (!isDeleting) setChatToDelete(null)
123+
if (!isDeleting) setChatIdsToDelete([])
95124
}, [isDeleting])
96125

97126
const confirmDelete = useCallback(() => {
98-
if (!chatToDelete || isDeleting) return
99-
deleteChat(chatToDelete.id, {
127+
if (chatsToDelete.length === 0 || isDeleting) return
128+
const redirectIfDeleted = (ids: string[]) => {
129+
if (
130+
ids.some((id) => window.location.pathname === organizationRoutes(organizationId).chat(id))
131+
) {
132+
router.push(organizationRoutes(organizationId).home)
133+
}
134+
}
135+
const options = {
100136
onSuccess: () => {
101-
setChatToDelete(null)
102-
if (window.location.pathname === chatToDelete.href) {
103-
router.push(organizationRoutes(organizationId).home)
137+
setChatIdsToDelete([])
138+
useFolderStore.getState().clearChatSelection()
139+
redirectIfDeleted(chatIdsToDelete)
140+
},
141+
onError: (error: Error) => {
142+
if (error instanceof MothershipChatDeleteError) {
143+
const deletedIds = new Set(error.deletedChatIds)
144+
setChatIdsToDelete((ids) => ids.filter((id) => !deletedIds.has(id)))
145+
redirectIfDeleted(error.deletedChatIds)
104146
}
147+
toast.error(error.message)
105148
},
106-
onError: (error) => toast.error(error.message),
107-
})
108-
}, [chatToDelete, deleteChat, isDeleting, organizationId, router])
149+
}
150+
if (chatsToDelete.length === 1) {
151+
deleteChat.mutate(chatsToDelete[0].id, options)
152+
} else {
153+
deleteChats.mutate(
154+
chatsToDelete.map((chat) => chat.id),
155+
options
156+
)
157+
}
158+
}, [
159+
chatIdsToDelete,
160+
chatsToDelete,
161+
deleteChat.mutate,
162+
deleteChats.mutate,
163+
isDeleting,
164+
organizationId,
165+
router,
166+
])
109167

110168
const startRename = useCallback(() => {
111169
if (chatId && chatName !== undefined) rename.startRename({ id: chatId, name: chatName })
@@ -142,11 +200,15 @@ export function useOrganizationChatActions({
142200
hover,
143201
rename,
144202
selectedChat,
203+
selectedChats,
204+
handleChatClick,
205+
menuOpenChatId: menu.isOpen ? menuSelection?.chatId : null,
206+
selectedCount: menuSelection?.chatIds.length ?? 0,
145207
onContextMenu,
146208
onMorePointerDown,
147209
onMoreClick,
148210
startRename,
149-
chatToDelete,
211+
chatsToDelete,
150212
isDeleting,
151213
startDelete,
152214
cancelDelete,

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import {
3737
SIDEBAR_SECTION_GAP_CLASS,
3838
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
3939
import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
40+
import { isSidebarBackgroundClick } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
4041
import { useContextMenu } from '@/hooks/use-context-menu'
42+
import { useFolderStore } from '@/stores/folders/store'
4143
import { useSidebarStore } from '@/stores/sidebar/store'
4244

4345
const logger = createLogger('OrganizationSidebar')
@@ -144,6 +146,11 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() {
144146
}
145147
}
146148

149+
const handleSidebarClick = (event: React.MouseEvent<HTMLElement>) => {
150+
if (!isSidebarBackgroundClick(event)) return
151+
useFolderStore.getState().clearChatSelection()
152+
}
153+
147154
useRegisterGlobalCommands(() =>
148155
createCommands([
149156
{
@@ -161,6 +168,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() {
161168
className='group/rail sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)] [&_.group.cursor-pointer]:duration-0'
162169
data-collapsed={isCollapsed || undefined}
163170
aria-label='Organization sidebar'
171+
onClick={handleSidebarClick}
164172
>
165173
<div className='flex h-full flex-col'>
166174
{/* The peek card already sits below the lane; reserving it again doubles the offset. */}

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/chat-navigation-link/chat-navigation-link.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ChatNavigationLinkProps extends Omit<ComponentProps<typeof Link>, 'hre
3333
chatId: string
3434
href: string
3535
isCurrentRoute?: boolean
36+
onSelectChat?: (chatId: string, shiftKey: boolean) => void
3637
}
3738

3839
export function ChatNavigationLink(props: ChatNavigationLinkProps) {
@@ -46,6 +47,7 @@ function IntentAwareChatNavigationLink({
4647
isCurrentRoute = false,
4748
onBlur,
4849
onClick,
50+
onSelectChat,
4951
onFocus,
5052
onMouseEnter,
5153
onMouseLeave,
@@ -135,7 +137,16 @@ function IntentAwareChatNavigationLink({
135137
}}
136138
onTouchStart={onTouchStart}
137139
onClick={(event) => {
140+
const isSelectionClick =
141+
!event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.altKey
142+
if (isSelectionClick && event.shiftKey && onSelectChat) {
143+
event.preventDefault()
144+
onSelectChat(chatId, true)
145+
}
138146
onClick?.(event)
147+
if (isSelectionClick && !event.defaultPrevented) {
148+
onSelectChat?.(chatId, false)
149+
}
139150
if (
140151
!event.defaultPrevented &&
141152
!event.metaKey &&

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ type CollapsedSidebarMenuProps = {
156156
interface CollapsedChatFlyoutItemProps {
157157
chat: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
158158
isCurrentRoute: boolean
159+
isSelected?: boolean
160+
onSelectChat?: (chatId: string, shiftKey: boolean) => void
159161
isMenuOpen?: boolean
160162
isEditing?: boolean
161163
editValue?: string
@@ -284,6 +286,8 @@ export function CollapsedSidebarMenu({
284286
export function CollapsedChatFlyoutItem({
285287
chat,
286288
isCurrentRoute,
289+
isSelected = false,
290+
onSelectChat,
287291
isMenuOpen = false,
288292
isEditing = false,
289293
editValue,
@@ -330,7 +334,7 @@ export function CollapsedChatFlyoutItem({
330334
return (
331335
<DropdownMenuItem
332336
asChild
333-
active={isCurrentRoute || isMenuOpen}
337+
active={isCurrentRoute || isSelected || isMenuOpen}
334338
actionOpen={isMenuOpen}
335339
action={
336340
showActions ? (
@@ -348,6 +352,7 @@ export function CollapsedChatFlyoutItem({
348352
chatId={chat.id}
349353
href={chat.href}
350354
isCurrentRoute={isCurrentRoute}
355+
onSelectChat={onSelectChat}
351356
onContextMenu={
352357
chat.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, chat.id) : undefined
353358
}

0 commit comments

Comments
 (0)