diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx index b9885a26e0..0efc790eaa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx @@ -103,7 +103,7 @@ export function CreditBalance({ {canPurchase && ( - diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/plan-card/plan-card.tsx index aef21f626b..cea5d60ce7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/plan-card/plan-card.tsx @@ -131,7 +131,7 @@ export function PlanCard({ -
- {redeemCode.error && ( - {redeemCode.error.message} - )} -
+ {redeemCode.error && ( + + {redeemCode.error.message} + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts index ba3421b84f..2d3f51580e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/plan-configs.ts @@ -4,6 +4,7 @@ import { HeadphonesIcon, Server, ShieldCheck, + Table2, Timer, Users, Zap, @@ -16,6 +17,7 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [ { icon: Clock, text: '1,000 runs/min (async)' }, { icon: Timer, text: '50 min sync execution limit' }, { icon: HardDrive, text: '50GB file storage' }, + { icon: Table2, text: '25 tables · 5,000 rows each' }, ] export const MAX_PLAN_FEATURES: PlanFeature[] = [ @@ -23,12 +25,14 @@ export const MAX_PLAN_FEATURES: PlanFeature[] = [ { icon: Clock, text: '2,500 runs/min (async)' }, { icon: Timer, text: '50 min sync execution limit' }, { icon: HardDrive, text: '500GB file storage' }, + { icon: Table2, text: '25 tables · 5,000 rows each' }, ] export const TEAM_INLINE_FEATURES: PlanFeature[] = [ { icon: Users, text: 'Shared credit pool' }, { icon: Zap, text: 'Max plan rate limits' }, { icon: HardDrive, text: 'Max plan file storage' }, + { icon: Table2, text: '100 tables · 10,000 rows each' }, { icon: ShieldCheck, text: 'Access controls' }, { icon: SlackMonoIcon, text: 'Dedicated Slack channel' }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx index c91cec26a9..ffcd95a8e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx @@ -912,80 +912,82 @@ export function Subscription() { /> {/* Billing details section */} -
- {subscription.isPaid && permissions.canViewUsageInfo && ( - refetchSubscription()} - /> - )} - - {!subscription.isEnterprise && ( - refetchSubscription()} /> - )} - - {subscription.isPaid && - subscriptionData?.data?.periodEnd && - !permissions.showTeamMemberView && - !permissions.isEnterpriseMember && ( -
- - - {new Date(subscriptionData.data.periodEnd).toLocaleDateString()} - -
+ {(subscription.isPaid || (!isLoading && isTeamAdmin)) && ( +
+ {subscription.isPaid && permissions.canViewUsageInfo && ( + refetchSubscription()} + /> )} - {subscription.isPaid && permissions.canViewUsageInfo && } - - {!isLoading && isTeamAdmin && ( -
-
- - - - - - - Usage from this workspace will be billed to this account - - -
- {workspaceAdmins.length === 0 ? ( -
- No admins available -
- ) : ( -
- { - if (value && value !== billedAccountUserId) { - try { - await updateWorkspaceSettings({ billedAccountUserId: value }) - } catch { - /* logged above */ - } - } - }} - disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending} - placeholder='Select admin' - options={workspaceAdmins.map((admin) => ({ - label: admin.email, - value: admin.userId, - }))} - /> + {subscription.isPaid && + subscriptionData?.data?.periodEnd && + !permissions.showTeamMemberView && + !permissions.isEnterpriseMember && ( +
+ + + {new Date(subscriptionData.data.periodEnd).toLocaleDateString()} +
)} -
- )} -
+ + {subscription.isPaid && permissions.canViewUsageInfo && ( + + )} + + {!isLoading && isTeamAdmin && ( +
+
+ + + + + + + Usage from this workspace will be billed to this account + + +
+ {workspaceAdmins.length === 0 ? ( +
+ No admins available +
+ ) : ( +
+ { + if (value && value !== billedAccountUserId) { + try { + await updateWorkspaceSettings({ billedAccountUserId: value }) + } catch { + /* logged above */ + } + } + }} + disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending} + placeholder='Select admin' + options={workspaceAdmins.map((admin) => ({ + label: admin.email, + value: admin.userId, + }))} + /> +
+ )} +
+ )} +
+ )} {/* Enterprise */} {hasEnterprise && ( @@ -998,6 +1000,11 @@ export function Subscription() { inlineButton /> )} + + {/* Referral Code */} + {!subscription.isEnterprise && ( + refetchSubscription()} /> + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx index c55d8a10fc..bdc0b0118a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/nav-item-context-menu/nav-item-context-menu.tsx @@ -9,10 +9,6 @@ interface NavItemContextMenuProps { onClose: () => void onOpenInNewTab: () => void onCopyLink: () => void - onRename?: () => void - onDelete?: () => void - disableRename?: boolean - disableDelete?: boolean } export function NavItemContextMenu({ @@ -22,10 +18,6 @@ export function NavItemContextMenu({ onClose, onOpenInNewTab, onCopyLink, - onRename, - onDelete, - disableRename = false, - disableDelete = false, }: NavItemContextMenuProps) { return ( Copy link - {onRename && ( - { - onRename() - onClose() - }} - disabled={disableRename} - > - Rename - - )} - {onDelete && ( - { - onDelete() - onClose() - }} - disabled={disableDelete} - className='text-[var(--color-error)]' - > - Delete - - )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx index f649862917..d79f7ab54f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx @@ -23,7 +23,7 @@ interface DeleteModalProps { * Type of item being deleted * - 'mixed' is used when both workflows and folders are selected */ - itemType: 'workflow' | 'folder' | 'workspace' | 'mixed' + itemType: 'workflow' | 'folder' | 'workspace' | 'mixed' | 'task' /** * Name(s) of the item(s) being deleted (optional, for display) * Can be a single name or an array of names for multiple items @@ -56,6 +56,8 @@ export function DeleteModal({ title = isMultiple ? 'Delete Workflows' : 'Delete Workflow' } else if (itemType === 'folder') { title = isMultiple ? 'Delete Folders' : 'Delete Folder' + } else if (itemType === 'task') { + title = isMultiple ? 'Delete Tasks' : 'Delete Task' } else if (itemType === 'mixed') { title = 'Delete Items' } else { @@ -112,6 +114,30 @@ export function DeleteModal({ return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.' } + if (itemType === 'task') { + if (isMultiple) { + return ( + <> + Are you sure you want to delete{' '} + + {displayNames.length} tasks + + ? This will permanently remove all conversation history. + + ) + } + if (isSingle && displayNames.length > 0) { + return ( + <> + Are you sure you want to delete{' '} + {displayNames[0]}? This + will permanently remove all conversation history. + + ) + } + return 'Are you sure you want to delete this task? This will permanently remove all conversation history.' + } + if (itemType === 'mixed') { if (displayNames.length > 0) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts index 84c1d3f96f..5c23e7afe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts @@ -12,6 +12,7 @@ export { useSidebarDragContextValue, } from './use-sidebar-drag-context' export { useSidebarResize } from './use-sidebar-resize' +export { useTaskSelection } from './use-task-selection' export { useWorkflowOperations } from './use-workflow-operations' export { useWorkflowSelection } from './use-workflow-selection' export { useWorkspaceManagement, type Workspace } from './use-workspace-management' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts new file mode 100644 index 0000000000..df9d8bc590 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-task-selection.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import { useFolderStore } from '@/stores/folders/store' + +interface UseTaskSelectionProps { + /** + * Flat array of all task IDs in display order + */ + taskIds: string[] +} + +/** + * Hook for managing task selection with support for single, range, and toggle selection. + * Handles shift-click for range selection and cmd/ctrl-click for toggle. + * Uses the last selected task as the anchor point for range selections. + * Selecting tasks clears workflow/folder selections and vice versa. + */ +export function useTaskSelection({ taskIds }: UseTaskSelectionProps) { + const selectedTasks = useFolderStore((s) => s.selectedTasks) + + const handleTaskClick = useCallback( + (taskId: string, shiftKey: boolean, metaKey: boolean) => { + const { + selectTaskOnly, + selectTaskRange, + toggleTaskSelection, + lastSelectedTaskId: anchor, + } = useFolderStore.getState() + if (metaKey) { + toggleTaskSelection(taskId) + } else if (shiftKey && anchor && anchor !== taskId) { + selectTaskRange(taskIds, anchor, taskId) + } else if (shiftKey) { + toggleTaskSelection(taskId) + } else { + selectTaskOnly(taskId) + } + }, + [taskIds] + ) + + return { + selectedTasks, + handleTaskClick, + } +} 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 6a3dde094b..631a3c2925 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -43,10 +43,13 @@ import { WorkflowList, WorkspaceHeader, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' +import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { useContextMenu, useFolderOperations, useSidebarResize, + useTaskSelection, useWorkflowOperations, useWorkspaceManagement, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -56,7 +59,7 @@ import { useImportWorkflow, useImportWorkspace, } from '@/app/workspace/[workspaceId]/w/hooks' -import { useDeleteTask, useRenameTask, useTasks } from '@/hooks/queries/tasks' +import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks' import { usePermissionConfig } from '@/hooks/use-permission-config' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' @@ -195,7 +198,6 @@ export const Sidebar = memo(function Sidebar() { }) const [activeNavItemHref, setActiveNavItemHref] = useState(null) - const [activeTaskId, setActiveTaskId] = useState(null) const { isOpen: isNavContextMenuOpen, position: navContextMenuPosition, @@ -204,22 +206,9 @@ export const Sidebar = memo(function Sidebar() { closeMenu: closeNavContextMenu, } = useContextMenu() - const deleteTaskMutation = useDeleteTask(workspaceId) - const renameTaskMutation = useRenameTask(workspaceId) - const handleNavItemContextMenu = useCallback( (e: React.MouseEvent, href: string) => { setActiveNavItemHref(href) - setActiveTaskId(null) - handleNavContextMenuBase(e) - }, - [handleNavContextMenuBase] - ) - - const handleTaskContextMenu = useCallback( - (e: React.MouseEvent, href: string, taskId: string) => { - setActiveNavItemHref(href) - setActiveTaskId(taskId) handleNavContextMenuBase(e) }, [handleNavContextMenuBase] @@ -228,7 +217,6 @@ export const Sidebar = memo(function Sidebar() { const handleNavContextMenuClose = useCallback(() => { closeNavContextMenu() setActiveNavItemHref(null) - setActiveTaskId(null) }, [closeNavContextMenu]) const handleNavOpenInNewTab = useCallback(() => { @@ -248,17 +236,39 @@ export const Sidebar = memo(function Sidebar() { } }, [activeNavItemHref]) - const handleDeleteTask = useCallback(() => { - if (!activeTaskId) return - const isViewingDeletedTask = pathname === `/workspace/${workspaceId}/task/${activeTaskId}` - deleteTaskMutation.mutate(activeTaskId, { - onSuccess: () => { - if (isViewingDeletedTask) { - router.push(`/workspace/${workspaceId}/home`) + const deleteTaskMutation = useDeleteTask(workspaceId) + const deleteTasksMutation = useDeleteTasks(workspaceId) + const renameTaskMutation = useRenameTask(workspaceId) + + const { + isOpen: isTaskContextMenuOpen, + position: taskContextMenuPosition, + menuRef: taskMenuRef, + handleContextMenu: handleTaskContextMenuBase, + closeMenu: closeTaskContextMenu, + } = useContextMenu() + + const contextMenuSelectionRef = useRef<{ taskIds: string[]; names: string[] }>({ + taskIds: [], + names: [], + }) + + const handleTaskContextMenu = useCallback( + (e: React.MouseEvent, taskId: string) => { + const { selectedTasks, selectTaskOnly } = useFolderStore.getState() + if (selectedTasks.size > 0 && selectedTasks.has(taskId)) { + contextMenuSelectionRef.current = { + taskIds: Array.from(selectedTasks), + names: [], } - }, - }) - }, [activeTaskId, pathname, workspaceId, deleteTaskMutation, router]) + } else { + selectTaskOnly(taskId) + contextMenuSelectionRef.current = { taskIds: [taskId], names: [] } + } + handleTaskContextMenuBase(e) + }, + [handleTaskContextMenuBase] + ) const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({ workspaceId, @@ -381,6 +391,46 @@ export const Sidebar = memo(function Sidebar() { [fetchedTasks, workspaceId] ) + const taskIds = useMemo(() => tasks.map((t) => t.id).filter((id) => id !== 'new'), [tasks]) + + const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds }) + + const isMultiTaskContextMenu = contextMenuSelectionRef.current.taskIds.length > 1 + + const [isTaskDeleteModalOpen, setIsTaskDeleteModalOpen] = useState(false) + + const handleDeleteTask = useCallback(() => { + const { taskIds: ids } = contextMenuSelectionRef.current + if (ids.length === 0) return + const names = ids.map((id) => tasks.find((t) => t.id === id)?.name).filter(Boolean) as string[] + contextMenuSelectionRef.current = { taskIds: ids, names } + setIsTaskDeleteModalOpen(true) + }, [tasks]) + + const handleConfirmDeleteTasks = useCallback(() => { + const { taskIds: taskIdsToDelete } = contextMenuSelectionRef.current + if (taskIdsToDelete.length === 0) return + + const currentPath = pathname ?? '' + const isViewingDeletedTask = taskIdsToDelete.some( + (id) => currentPath === `/workspace/${workspaceId}/task/${id}` + ) + + const onDeleteSuccess = () => { + useFolderStore.getState().clearTaskSelection() + if (isViewingDeletedTask) { + router.push(`/workspace/${workspaceId}/home`) + } + } + + if (taskIdsToDelete.length === 1) { + deleteTaskMutation.mutate(taskIdsToDelete[0], { onSuccess: onDeleteSuccess }) + } else { + deleteTasksMutation.mutate(taskIdsToDelete, { onSuccess: onDeleteSuccess }) + } + setIsTaskDeleteModalOpen(false) + }, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, router]) + const [visibleTaskCount, setVisibleTaskCount] = useState(5) const [renamingTaskId, setRenamingTaskId] = useState(null) const [renameValue, setRenameValue] = useState('') @@ -394,14 +444,22 @@ export const Sidebar = memo(function Sidebar() { } }, [renamingTaskId]) + const handleTaskOpenInNewTab = useCallback(() => { + const { taskIds: ids } = contextMenuSelectionRef.current + if (ids.length !== 1) return + window.open(`/workspace/${workspaceId}/task/${ids[0]}`, '_blank', 'noopener,noreferrer') + }, [workspaceId]) + const handleStartTaskRename = useCallback(() => { - if (!activeTaskId || activeTaskId === 'new') return - const task = tasks.find((t) => t.id === activeTaskId) + const { taskIds: ids } = contextMenuSelectionRef.current + if (ids.length !== 1) return + const taskId = ids[0] + const task = tasks.find((t) => t.id === taskId) if (!task) return renameCanceledRef.current = false - setRenamingTaskId(activeTaskId) + setRenamingTaskId(taskId) setRenameValue(task.name) - }, [activeTaskId, tasks]) + }, [tasks]) const handleSaveTaskRename = useCallback(() => { if (renameCanceledRef.current) { @@ -522,8 +580,8 @@ export const Sidebar = memo(function Sidebar() { if (target.tagName === 'BUTTON' || target.closest('button, [role="button"], a')) { return } - const { selectOnly, clearSelection } = useFolderStore.getState() - workflowId ? selectOnly(workflowId) : clearSelection() + const { selectOnly, clearAllSelection } = useFolderStore.getState() + workflowId ? selectOnly(workflowId) : clearAllSelection() }, [workflowId] ) @@ -808,6 +866,7 @@ export const Sidebar = memo(function Sidebar() { {tasks.slice(0, visibleTaskCount).map((task) => { const active = task.id !== 'new' && pathname === task.href const isRenaming = renamingTaskId === task.id + const isSelected = task.id !== 'new' && selectedTasks.has(task.id) if (isRenaming) { return ( @@ -832,8 +891,24 @@ export const Sidebar = memo(function Sidebar() { handleTaskContextMenu(e, task.href, task.id)} + className={cn( + 'mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]', + (active || isSelected) && 'bg-[var(--surface-active)]' + )} + onClick={(e) => { + if (task.id === 'new') return + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault() + handleTaskClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey) + } else { + handleTaskClick(task.id, false, false) + } + }} + onContextMenu={ + task.id !== 'new' + ? (e) => handleTaskContextMenu(e, task.id) + : undefined + } >
@@ -982,7 +1057,7 @@ export const Sidebar = memo(function Sidebar() { })}
- {/* Nav Item Context Menu */} + {/* Nav Item Context Menu (Home, Tables, Files, etc.) */} + + {/* Task Context Menu */} + + + {/* Task Delete Confirmation Modal */} + setIsTaskDeleteModalOpen(false)} + onConfirm={handleConfirmDeleteTasks} + isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending} + itemType='task' + itemName={contextMenuSelectionRef.current.names} + /> )} diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index eb7826f7c2..b3f5b2e9ab 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -137,7 +137,22 @@ export function useDeleteTask(workspaceId?: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: deleteTask, - onSuccess: () => { + onSettled: () => { + queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) + }, + }) +} + +/** + * Deletes multiple mothership chat tasks and invalidates the task list. + */ +export function useDeleteTasks(workspaceId?: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (chatIds: string[]) => { + await Promise.all(chatIds.map(deleteTask)) + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) }) }, }) diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index 20ed66d392..4a01b29ae8 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -11,6 +11,8 @@ interface FolderState { selectedWorkflows: Set selectedFolders: Set lastSelectedFolderId: string | null + selectedTasks: Set + lastSelectedTaskId: string | null setFolders: (folders: WorkflowFolder[]) => void toggleExpanded: (folderId: string) => void @@ -34,6 +36,13 @@ interface FolderState { selectFolderRange: (folderIds: string[], fromId: string, toId: string) => void isFolderSelected: (folderId: string) => boolean + // Task selection actions + selectTaskOnly: (taskId: string) => void + toggleTaskSelection: (taskId: string) => void + selectTaskRange: (taskIds: string[], fromId: string, toId: string) => void + clearTaskSelection: () => void + isTaskSelected: (taskId: string) => boolean + // Unified selection helpers getFullSelection: () => { workflowIds: string[]; folderIds: string[] } hasAnySelection: () => boolean @@ -55,6 +64,8 @@ export const useFolderStore = create()( selectedWorkflows: new Set(), selectedFolders: new Set(), lastSelectedFolderId: null, + selectedTasks: new Set(), + lastSelectedTaskId: null, setFolders: (folders) => set(() => ({ @@ -112,7 +123,13 @@ export const useFolderStore = create()( } else { newSelected.add(workflowId) } - return { selectedWorkflows: newSelected } + return { + selectedWorkflows: newSelected, + ...(state.selectedTasks.size > 0 && { + selectedTasks: new Set(), + lastSelectedTaskId: null, + }), + } }), clearSelection: () => set({ selectedWorkflows: new Set() }), @@ -122,6 +139,8 @@ export const useFolderStore = create()( selectedWorkflows: new Set([workflowId]), selectedFolders: new Set(), lastSelectedFolderId: null, + selectedTasks: new Set(), + lastSelectedTaskId: null, }), selectRange: (workflowIds, fromId, toId) => { @@ -174,13 +193,25 @@ export const useFolderStore = create()( // Always update anchor to the most recently clicked folder newLastSelected = folderId } - return { selectedFolders: newSelected, lastSelectedFolderId: newLastSelected } + return { + selectedFolders: newSelected, + lastSelectedFolderId: newLastSelected, + ...(state.selectedTasks.size > 0 && { + selectedTasks: new Set(), + lastSelectedTaskId: null, + }), + } }), clearFolderSelection: () => set({ selectedFolders: new Set(), lastSelectedFolderId: null }), selectFolderOnly: (folderId) => - set({ selectedFolders: new Set([folderId]), lastSelectedFolderId: folderId }), + set({ + selectedFolders: new Set([folderId]), + lastSelectedFolderId: folderId, + selectedTasks: new Set(), + lastSelectedTaskId: null, + }), selectFolderRange: (folderIds, fromId, toId) => { const fromIndex = folderIds.indexOf(fromId) @@ -197,13 +228,78 @@ export const useFolderStore = create()( isFolderSelected: (folderId) => get().selectedFolders.has(folderId), + // Task selection actions + selectTaskOnly: (taskId) => + set((state) => ({ + selectedTasks: new Set([taskId]), + lastSelectedTaskId: taskId, + ...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set() }), + ...(state.selectedFolders.size > 0 && { + selectedFolders: new Set(), + lastSelectedFolderId: null, + }), + })), + + toggleTaskSelection: (taskId) => + set((state) => { + const newSelected = new Set(state.selectedTasks) + let newLastSelected: string | null + if (newSelected.has(taskId)) { + newSelected.delete(taskId) + newLastSelected = + state.lastSelectedTaskId === taskId + ? (Array.from(newSelected)[0] ?? null) + : state.lastSelectedTaskId + } else { + newSelected.add(taskId) + newLastSelected = taskId + } + return { + selectedTasks: newSelected, + lastSelectedTaskId: newLastSelected, + ...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set() }), + ...(state.selectedFolders.size > 0 && { + selectedFolders: new Set(), + lastSelectedFolderId: null, + }), + } + }), + + selectTaskRange: (taskIds, fromId, toId) => { + const fromIndex = taskIds.indexOf(fromId) + const toIndex = taskIds.indexOf(toId) + + if (fromIndex === -1 || toIndex === -1) return + + const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex] + const rangeIds = taskIds.slice(start, end + 1) + + const state = get() + set({ + selectedTasks: new Set(rangeIds), + lastSelectedTaskId: toId, + ...(state.selectedWorkflows.size > 0 && { selectedWorkflows: new Set() }), + ...(state.selectedFolders.size > 0 && { + selectedFolders: new Set(), + lastSelectedFolderId: null, + }), + }) + }, + + clearTaskSelection: () => set({ selectedTasks: new Set(), lastSelectedTaskId: null }), + + isTaskSelected: (taskId) => get().selectedTasks.has(taskId), + // Unified selection helpers getFullSelection: () => ({ workflowIds: Array.from(get().selectedWorkflows), folderIds: Array.from(get().selectedFolders), }), - hasAnySelection: () => get().selectedWorkflows.size > 0 || get().selectedFolders.size > 0, + hasAnySelection: () => + get().selectedWorkflows.size > 0 || + get().selectedFolders.size > 0 || + get().selectedTasks.size > 0, isMixedSelection: () => get().selectedWorkflows.size > 0 && get().selectedFolders.size > 0, @@ -212,6 +308,8 @@ export const useFolderStore = create()( selectedWorkflows: new Set(), selectedFolders: new Set(), lastSelectedFolderId: null, + selectedTasks: new Set(), + lastSelectedTaskId: null, }), getFolderTree: (workspaceId) => { @@ -261,3 +359,6 @@ export const useIsWorkflowSelected = (workflowId: string) => export const useIsFolderSelected = (folderId: string) => useFolderStore((state) => state.selectedFolders.has(folderId)) + +export const useIsTaskSelected = (taskId: string) => + useFolderStore((state) => state.selectedTasks.has(taskId))