mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-25 09:34:44 +08:00
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:
co-authored by
Claude Opus 4.6
parent
a1f917d552
commit
e6c511a6f3
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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`}
|
||||
>
|
||||
|
||||
+9
-9
@@ -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' },
|
||||
]
|
||||
|
||||
+77
-70
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
-31
@@ -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>
|
||||
)
|
||||
|
||||
+27
-1
@@ -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>
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user