feat: add task multi-select, context menu, and subscription UI updates

Add shift-click range selection, cmd/ctrl-click toggle, and right-click
context menu for tasks in sidebar matching workflow/folder patterns.
Update subscription settings tab UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
waleed
2026-03-10 19:02:17 -07:00
co-authored by Claude Opus 4.6
parent a1f917d552
commit e6c511a6f3
12 changed files with 421 additions and 157 deletions
@@ -103,7 +103,7 @@ export function CreditBalance({
{canPurchase && (
<Modal open={isOpen} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant='active' className='h-[32px] rounded-[6px] text-[13px]'>
<Button variant='default' className='h-[32px] text-[13px]'>
Add Credits
</Button>
</ModalTrigger>
@@ -131,7 +131,7 @@ export function PlanCard({
</ul>
<Button
onClick={onButtonClick}
className={cn(!inlineButton && 'w-full')}
className={cn(inlineButton ? 'shrink-0 whitespace-nowrap' : 'w-full')}
variant={isError ? 'outline' : 'tertiary'}
aria-label={`${buttonText} ${name} plan`}
>
@@ -44,7 +44,7 @@ export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
}
return (
<div className='flex flex-col'>
<div className='flex flex-col gap-[4px]'>
<div className='flex items-center justify-between gap-[12px]'>
<Label className='shrink-0'>Referral Code</Label>
<div className='flex items-center gap-[8px]'>
@@ -59,12 +59,12 @@ export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
if (e.key === 'Enter') handleRedeem()
}}
placeholder='Enter code'
className='h-[32px] w-[140px] text-[13px]'
className='h-[32px] w-[140px] bg-[var(--surface-4)] text-[13px]'
disabled={redeemCode.isPending}
/>
<Button
variant='active'
className='h-[32px] shrink-0 rounded-[6px] text-[13px]'
variant='default'
className='h-[32px] shrink-0 text-[13px]'
onClick={handleRedeem}
disabled={redeemCode.isPending || !code.trim()}
>
@@ -72,11 +72,11 @@ export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
</Button>
</div>
</div>
<div className='mt-[4px] min-h-[18px] text-right'>
{redeemCode.error && (
<span className='text-[11px] text-[var(--text-error)]'>{redeemCode.error.message}</span>
)}
</div>
{redeemCode.error && (
<span className='text-right text-[11px] text-[var(--text-error)]'>
{redeemCode.error.message}
</span>
)}
</div>
)
}
@@ -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' },
]
@@ -912,80 +912,82 @@ export function Subscription() {
/>
{/* Billing details section */}
<div className='flex flex-col gap-[14px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[14px] py-[12px]'>
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
entityType={subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
)}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<Label>{isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
</span>
</div>
{(subscription.isPaid || (!isLoading && isTeamAdmin)) && (
<div className='flex flex-col gap-[14px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[14px] py-[12px]'>
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
entityType={
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
)}
{subscription.isPaid && permissions.canViewUsageInfo && <BillingUsageNotificationsToggle />}
{!isLoading && isTeamAdmin && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='billed-account'>Billed Account</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[12px] w-[12px] text-[var(--text-secondary)]' />
</Tooltip.Trigger>
<Tooltip.Content>
<span>Usage from this workspace will be billed to this account</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
{workspaceAdmins.length === 0 ? (
<div className='rounded-[6px] border border-[var(--border)] border-dashed px-[12px] py-[6px] text-[13px] text-[var(--text-muted)]'>
No admins available
</div>
) : (
<div className='w-[200px]'>
<Combobox
size='sm'
align='end'
dropdownWidth={200}
value={billedAccountUserId || ''}
onChange={async (value: string) => {
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 && (
<div className='flex items-center justify-between'>
<Label>{isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
</span>
</div>
)}
</div>
)}
</div>
{subscription.isPaid && permissions.canViewUsageInfo && (
<BillingUsageNotificationsToggle />
)}
{!isLoading && isTeamAdmin && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='billed-account'>Billed Account</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[12px] w-[12px] text-[var(--text-secondary)]' />
</Tooltip.Trigger>
<Tooltip.Content>
<span>Usage from this workspace will be billed to this account</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
{workspaceAdmins.length === 0 ? (
<div className='rounded-[6px] border border-[var(--border)] border-dashed px-[12px] py-[6px] text-[13px] text-[var(--text-muted)]'>
No admins available
</div>
) : (
<div className='w-[200px]'>
<Combobox
size='sm'
align='end'
dropdownWidth={200}
value={billedAccountUserId || ''}
onChange={async (value: string) => {
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,
}))}
/>
</div>
)}
</div>
)}
</div>
)}
{/* Enterprise */}
{hasEnterprise && (
@@ -998,6 +1000,11 @@ export function Subscription() {
inlineButton
/>
)}
{/* Referral Code */}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
</div>
)
}
@@ -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 (
<Popover
@@ -61,29 +53,6 @@ export function NavItemContextMenu({
>
Copy link
</PopoverItem>
{onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
disabled={disableRename}
>
Rename
</PopoverItem>
)}
{onDelete && (
<PopoverItem
onClick={() => {
onDelete()
onClose()
}}
disabled={disableDelete}
className='text-[var(--color-error)]'
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
@@ -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{' '}
<span className='font-medium text-[var(--text-primary)]'>
{displayNames.length} tasks
</span>
? This will permanently remove all conversation history.
</>
)
}
if (isSingle && displayNames.length > 0) {
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? 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 (
@@ -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'
@@ -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,
}
}
@@ -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<string | null>(null)
const [activeTaskId, setActiveTaskId] = useState<string | null>(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<string | null>(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() {
<Link
key={task.id}
href={task.href}
className={`mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)] ${active ? 'bg-[var(--surface-active)]' : ''}`}
onContextMenu={(e) => 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
}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
@@ -982,7 +1057,7 @@ export const Sidebar = memo(function Sidebar() {
})}
</div>
{/* Nav Item Context Menu */}
{/* Nav Item Context Menu (Home, Tables, Files, etc.) */}
<NavItemContextMenu
isOpen={isNavContextMenuOpen}
position={navContextMenuPosition}
@@ -990,13 +1065,34 @@ export const Sidebar = memo(function Sidebar() {
onClose={handleNavContextMenuClose}
onOpenInNewTab={handleNavOpenInNewTab}
onCopyLink={handleNavCopyLink}
onRename={
activeTaskId && activeTaskId !== 'new' ? handleStartTaskRename : undefined
}
onDelete={activeTaskId ? handleDeleteTask : undefined}
/>
{/* Task Context Menu */}
<ContextMenu
isOpen={isTaskContextMenuOpen}
position={taskContextMenuPosition}
menuRef={taskMenuRef}
onClose={closeTaskContextMenu}
onOpenInNewTab={handleTaskOpenInNewTab}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
showOpenInNewTab={!isMultiTaskContextMenu}
showRename={!isMultiTaskContextMenu}
showDuplicate={false}
showColorChange={false}
disableRename={!canEdit}
disableDelete={!canEdit}
/>
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}
onClose={() => setIsTaskDeleteModalOpen(false)}
onConfirm={handleConfirmDeleteTasks}
isDeleting={deleteTaskMutation.isPending || deleteTasksMutation.isPending}
itemType='task'
itemName={contextMenuSelectionRef.current.names}
/>
</>
)}
</div>
+16 -1
View File
@@ -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) })
},
})
+105 -4
View File
@@ -11,6 +11,8 @@ interface FolderState {
selectedWorkflows: Set<string>
selectedFolders: Set<string>
lastSelectedFolderId: string | null
selectedTasks: Set<string>
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<FolderState>()(
selectedWorkflows: new Set(),
selectedFolders: new Set(),
lastSelectedFolderId: null,
selectedTasks: new Set(),
lastSelectedTaskId: null,
setFolders: (folders) =>
set(() => ({
@@ -112,7 +123,13 @@ export const useFolderStore = create<FolderState>()(
} else {
newSelected.add(workflowId)
}
return { selectedWorkflows: newSelected }
return {
selectedWorkflows: newSelected,
...(state.selectedTasks.size > 0 && {
selectedTasks: new Set<string>(),
lastSelectedTaskId: null,
}),
}
}),
clearSelection: () => set({ selectedWorkflows: new Set() }),
@@ -122,6 +139,8 @@ export const useFolderStore = create<FolderState>()(
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<FolderState>()(
// 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<string>(),
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<FolderState>()(
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<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
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<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
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<string>() }),
...(state.selectedFolders.size > 0 && {
selectedFolders: new Set<string>(),
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<FolderState>()(
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))