Compare commits

..

24 Commits

Author SHA1 Message Date
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
26 changed files with 441 additions and 644 deletions

View File

@@ -108,7 +108,7 @@ export function Panel() {
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
workflowIds: activeWorkflowId || '',
getWorkflowIds: () => activeWorkflowId || '',
isActive: true,
onSuccess: () => setIsDeleteModalOpen(false),
})

View File

@@ -50,7 +50,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
!isPending &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-tertiary-2)]',
'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isDeletedBlock &&

View File

@@ -347,11 +347,11 @@ export function ContextMenu({
title={name}
onClick={(e) => {
e.stopPropagation()
setHexInput(color)
onColorChange(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
@@ -373,7 +373,7 @@ export function ContextMenu({
onKeyDown={handleHexKeyDown}
onFocus={handleHexFocus}
onClick={(e) => e.stopPropagation()}
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
/>
<button
type='button'

View File

@@ -20,7 +20,6 @@ import {
useCanDelete,
useDeleteFolder,
useDuplicateFolder,
useExportFolder,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
@@ -58,24 +57,23 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
const { canDeleteFolder } = useCanDelete({ workspaceId })
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
// Delete folder hook
const { isDeleting, handleDeleteFolder } = useDeleteFolder({
workspaceId,
folderIds: folder.id,
getFolderIds: () => folder.id,
onSuccess: () => setIsDeleteModalOpen(false),
})
// Duplicate folder hook
const { handleDuplicateFolder } = useDuplicateFolder({
workspaceId,
folderIds: folder.id,
})
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
workspaceId,
folderId: folder.id,
getFolderIds: () => folder.id,
})
// Folder expand hook - must be declared before callbacks that use expandFolder
const {
isExpanded,
handleToggleExpanded,
@@ -92,6 +90,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
*/
const handleCreateWorkflowInFolder = useCallback(async () => {
try {
// Generate name and color upfront for optimistic updates
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
@@ -104,12 +103,15 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
if (result.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
// Expand the parent folder so the new workflow is visible
expandFolder()
// Scroll to the newly created workflow
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
}
} catch (error) {
// Error already handled by mutation's onError callback
logger.error('Failed to create workflow in folder:', error)
}
}, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder])
@@ -126,7 +128,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
parentId: folder.id,
})
if (result.id) {
// Expand the parent folder so the new folder is visible
expandFolder()
// Scroll to the newly created folder
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
@@ -143,6 +147,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
// Don't start drag if editing
if (isEditing) {
e.preventDefault()
return
@@ -154,10 +159,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
[folder.id]
)
// Item drag hook
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
onDragStart,
})
// Context menu hook
const {
isOpen: isContextMenuOpen,
position,
@@ -167,6 +174,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
preventDismiss,
} = useContextMenu()
// Rename hook
const {
isEditing,
editValue,
@@ -250,6 +258,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
@@ -356,16 +365,13 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
onCreate={handleCreateWorkflowInFolder}
onCreateFolder={handleCreateFolderInFolder}
onDuplicate={handleDuplicateFolder}
onExport={handleExportFolder}
onDelete={() => setIsDeleteModalOpen(true)}
showCreate={true}
showCreateFolder={true}
showExport={true}
disableRename={!userPermissions.canEdit}
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
disableDuplicate={!userPermissions.canEdit || !hasWorkflows}
disableExport={!userPermissions.canEdit || isExporting || !hasWorkflows}
disableDuplicate={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDelete}
/>

View File

@@ -46,15 +46,19 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
// Can delete check hook
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
// Delete modal state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
// Presence avatars state
const [hasAvatars, setHasAvatars] = useState(false)
// Capture selection at right-click time (using ref to persist across renders)
const capturedSelectionRef = useRef<{
workflowIds: string[]
workflowNames: string | string[]
@@ -64,6 +68,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
* Handle opening the delete modal - uses pre-captured selection state
*/
const handleOpenDeleteModal = useCallback(() => {
// Use the selection captured at right-click time
if (capturedSelectionRef.current) {
setWorkflowIdsToDelete(capturedSelectionRef.current.workflowIds)
setDeleteModalNames(capturedSelectionRef.current.workflowNames)
@@ -71,32 +76,42 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
}
}, [])
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId,
workflowIds: workflowIdsToDelete,
getWorkflowIds: () => workflowIdsToDelete,
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
onSuccess: () => setIsDeleteModalOpen(false),
})
const { handleDuplicateWorkflow: duplicateWorkflow } = useDuplicateWorkflow({ workspaceId })
// Duplicate workflow hook
const { handleDuplicateWorkflow } = useDuplicateWorkflow({
workspaceId,
getWorkflowIds: () => {
// Use the selection captured at right-click time
return capturedSelectionRef.current?.workflowIds || []
},
})
const { handleExportWorkflow: exportWorkflow } = useExportWorkflow({ workspaceId })
const handleDuplicateWorkflow = useCallback(() => {
const workflowIds = capturedSelectionRef.current?.workflowIds || []
if (workflowIds.length === 0) return
duplicateWorkflow(workflowIds)
}, [duplicateWorkflow])
const handleExportWorkflow = useCallback(() => {
const workflowIds = capturedSelectionRef.current?.workflowIds || []
if (workflowIds.length === 0) return
exportWorkflow(workflowIds)
}, [exportWorkflow])
// Export workflow hook
const { handleExportWorkflow } = useExportWorkflow({
workspaceId,
getWorkflowIds: () => {
// Use the selection captured at right-click time
return capturedSelectionRef.current?.workflowIds || []
},
})
/**
* Opens the workflow in a new browser tab
*/
const handleOpenInNewTab = useCallback(() => {
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
}, [workspaceId, workflow.id])
/**
* Changes the workflow color
*/
const handleColorChange = useCallback(
(color: string) => {
updateWorkflow(workflow.id, { color })
@@ -111,6 +126,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
*/
const onDragStart = useCallback(
(e: React.DragEvent) => {
// Don't start drag if editing
if (isEditing) {
e.preventDefault()
return
@@ -125,10 +141,12 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
[isSelected, selectedWorkflows, workflow.id]
)
// Item drag hook
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
onDragStart,
})
// Context menu hook
const {
isOpen: isContextMenuOpen,
position,
@@ -197,12 +215,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
e.preventDefault()
e.stopPropagation()
// Toggle: close if open, open if closed
if (isContextMenuOpen) {
closeMenu()
return
}
captureSelectionState()
// Open context menu aligned with the button
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenuBase({
preventDefault: () => {},
@@ -214,6 +234,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
)
// Rename hook
const {
isEditing,
editValue,
@@ -260,10 +281,12 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
const isModifierClick = e.shiftKey || e.metaKey || e.ctrlKey
// Prevent default link behavior when using modifier keys
if (isModifierClick) {
e.preventDefault()
}
// Use metaKey (Cmd on Mac) or ctrlKey (Ctrl on Windows/Linux)
onWorkflowClick(workflow.id, e.shiftKey, e.metaKey || e.ctrlKey)
},
[shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing]

View File

@@ -9,6 +9,7 @@ import {
useDragDrop,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow'
import { useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
@@ -24,13 +25,15 @@ const TREE_SPACING = {
interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[]
isLoading?: boolean
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
isImporting: boolean
setIsImporting: (value: boolean) => void
fileInputRef: React.RefObject<HTMLInputElement | null>
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
* Uses the workflow import hook for handling JSON imports.
*
* @param props - Component props
* @returns Workflow list with folders and drag-drop support
@@ -38,7 +41,8 @@ interface WorkflowListProps {
export function WorkflowList({
regularWorkflows,
isLoading = false,
handleFileChange,
isImporting,
setIsImporting,
fileInputRef,
scrollContainerRef,
}: WorkflowListProps) {
@@ -61,6 +65,9 @@ export function WorkflowList({
createFolderHeaderHoverHandlers,
} = useDragDrop()
// Workflow import hook
const { handleFileChange } = useImportWorkflow({ workspaceId })
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {

View File

@@ -2,10 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import { ArrowDown, Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
import { Button, FolderPlus, Library, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
@@ -30,7 +30,6 @@ import {
import {
useDuplicateWorkspace,
useExportWorkspace,
useImportWorkflow,
useImportWorkspace,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -86,11 +85,9 @@ export function Sidebar() {
const isCollapsed = hasHydrated ? isCollapsedStore : false
const isOnWorkflowPage = !!workflowId
const [isImporting, setIsImporting] = useState(false)
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
workspaceId,
})
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
useImportWorkspace()
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
@@ -216,7 +213,7 @@ export function Sidebar() {
}, [activeNavItemHref])
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
workspaceId,
getWorkspaceId: () => workspaceId,
})
const searchModalWorkflows = useMemo(
@@ -568,31 +565,21 @@ export function Sidebar() {
Workflows
</div>
<div className='flex items-center justify-center gap-[10px]'>
{isImporting ? (
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
disabled={!canEdit || isImporting}
>
<Loader className='h-[14px] w-[14px]' animate />
</Button>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={!canEdit}
>
<Download className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Import workflows</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='translate-y-[-0.25px] p-[1px]'
onClick={handleImportWorkflow}
disabled={isImporting || !canEdit}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -635,7 +622,8 @@ export function Sidebar() {
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
handleFileChange={handleImportFileChange}
isImporting={isImporting}
setIsImporting={setIsImporting}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
/>

View File

@@ -4,7 +4,6 @@ export { useDeleteWorkflow } from './use-delete-workflow'
export { useDuplicateFolder } from './use-duplicate-folder'
export { useDuplicateWorkflow } from './use-duplicate-workflow'
export { useDuplicateWorkspace } from './use-duplicate-workspace'
export { useExportFolder } from './use-export-folder'
export { useExportWorkflow } from './use-export-workflow'
export { useExportWorkspace } from './use-export-workspace'
export { useImportWorkflow } from './use-import-workflow'

View File

@@ -11,9 +11,10 @@ interface UseDeleteFolderProps {
*/
workspaceId: string
/**
* The folder ID(s) to delete
* Function that returns the folder ID(s) to delete
* This function is called when deletion occurs to get fresh selection state
*/
folderIds: string | string[]
getFolderIds: () => string | string[]
/**
* Optional callback after successful deletion
*/
@@ -23,10 +24,17 @@ interface UseDeleteFolderProps {
/**
* Hook for managing folder deletion.
*
* Handles:
* - Single or bulk folder deletion
* - Calling delete API for each folder
* - Loading state management
* - Error handling and logging
* - Clearing selection after deletion
*
* @param props - Hook configuration
* @returns Delete folder handlers and state
*/
export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDeleteFolderProps) {
export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDeleteFolderProps) {
const deleteFolderMutation = useDeleteFolderMutation()
const [isDeleting, setIsDeleting] = useState(false)
@@ -38,18 +46,23 @@ export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDelete
return
}
if (!folderIds) {
return
}
setIsDeleting(true)
try {
const folderIdsToDelete = Array.isArray(folderIds) ? folderIds : [folderIds]
// Get fresh folder IDs at deletion time
const folderIdsOrId = getFolderIds()
if (!folderIdsOrId) {
return
}
// Normalize to array for consistent handling
const folderIdsToDelete = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
// Delete each folder sequentially
for (const folderId of folderIdsToDelete) {
await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId })
}
// Clear selection after successful deletion
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -61,7 +74,7 @@ export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDelete
} finally {
setIsDeleting(false)
}
}, [folderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
}, [getFolderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
return {
isDeleting,

View File

@@ -12,9 +12,10 @@ interface UseDeleteWorkflowProps {
*/
workspaceId: string
/**
* Workflow ID(s) to delete
* Function that returns the workflow ID(s) to delete
* This function is called when deletion occurs to get fresh selection state
*/
workflowIds: string | string[]
getWorkflowIds: () => string | string[]
/**
* Whether the active workflow is being deleted
* Can be a boolean or a function that receives the workflow IDs
@@ -29,12 +30,20 @@ interface UseDeleteWorkflowProps {
/**
* Hook for managing workflow deletion with navigation logic.
*
* Handles:
* - Single or bulk workflow deletion
* - Finding next workflow to navigate to
* - Navigating before deletion (if active workflow)
* - Removing workflow(s) from registry
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Delete workflow handlers and state
*/
export function useDeleteWorkflow({
workspaceId,
workflowIds,
getWorkflowIds,
isActive = false,
onSuccess,
}: UseDeleteWorkflowProps) {
@@ -50,21 +59,30 @@ export function useDeleteWorkflow({
return
}
if (!workflowIds) {
return
}
setIsDeleting(true)
try {
const workflowIdsToDelete = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
// Get fresh workflow IDs at deletion time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
// Normalize to array for consistent handling
const workflowIdsToDelete = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
// Determine if active workflow is being deleted
const isActiveWorkflowBeingDeleted =
typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive
// Find next workflow to navigate to (if active workflow is being deleted)
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
// Find which specific workflow is the active one (if any in the deletion list)
let activeWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') {
// Check each workflow being deleted to find which one is active
activeWorkflowId =
workflowIdsToDelete.find((id) => isActive([id])) || workflowIdsToDelete[0]
} else {
@@ -75,11 +93,13 @@ export function useDeleteWorkflow({
let nextWorkflowId: string | null = null
if (isActiveWorkflowBeingDeleted && sidebarWorkflows.length > workflowIdsToDelete.length) {
// Find the first workflow that's not being deleted
const remainingWorkflows = sidebarWorkflows.filter(
(w) => !workflowIdsToDelete.includes(w.id)
)
if (remainingWorkflows.length > 0) {
// Try to find the next workflow after the current one
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
return idx > currentIndex
@@ -88,11 +108,13 @@ export function useDeleteWorkflow({
if (workflowsAfterCurrent.length > 0) {
nextWorkflowId = workflowsAfterCurrent[0].id
} else {
// Otherwise, use the first remaining workflow
nextWorkflowId = remainingWorkflows[0].id
}
}
}
// Navigate first if this is the active workflow
if (isActiveWorkflowBeingDeleted) {
if (nextWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
@@ -101,8 +123,10 @@ export function useDeleteWorkflow({
}
}
// Delete all workflows
await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id)))
// Clear selection after successful deletion
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -114,7 +138,16 @@ export function useDeleteWorkflow({
} finally {
setIsDeleting(false)
}
}, [workflowIds, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess])
}, [
getWorkflowIds,
isDeleting,
workflows,
workspaceId,
isActive,
router,
removeWorkflow,
onSuccess,
])
return {
isDeleting,

View File

@@ -7,10 +7,7 @@ const logger = createLogger('useDuplicateFolder')
interface UseDuplicateFolderProps {
workspaceId: string
/**
* The folder ID(s) to duplicate
*/
folderIds: string | string[]
getFolderIds: () => string | string[]
onSuccess?: () => void
}
@@ -20,7 +17,11 @@ interface UseDuplicateFolderProps {
* @param props - Hook configuration
* @returns Duplicate folder handlers and state
*/
export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDuplicateFolderProps) {
export function useDuplicateFolder({
workspaceId,
getFolderIds,
onSuccess,
}: UseDuplicateFolderProps) {
const duplicateFolderMutation = useDuplicateFolderMutation()
const [isDuplicating, setIsDuplicating] = useState(false)
@@ -45,17 +46,21 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
return
}
if (!folderIds) {
return
}
setIsDuplicating(true)
try {
const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds]
// Get fresh folder IDs at duplication time
const folderIdsOrId = getFolderIds()
if (!folderIdsOrId) {
return
}
// Normalize to array for consistent handling
const folderIdsToDuplicate = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
const duplicatedIds: string[] = []
const folderStore = useFolderStore.getState()
// Duplicate each folder sequentially
for (const folderId of folderIdsToDuplicate) {
const folder = folderStore.getFolderById(folderId)
@@ -67,6 +72,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
const siblingNames = new Set(
folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name)
)
// Avoid colliding with the original folder name
siblingNames.add(folder.name)
const duplicateName = generateDuplicateName(folder.name, siblingNames)
@@ -84,6 +90,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
}
}
// Clear selection after successful duplication
const { clearSelection } = useFolderStore.getState()
clearSelection()
@@ -100,7 +107,7 @@ export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDup
setIsDuplicating(false)
}
}, [
folderIds,
getFolderIds,
generateDuplicateName,
isDuplicating,
duplicateFolderMutation,

View File

@@ -13,6 +13,11 @@ interface UseDuplicateWorkflowProps {
* Current workspace ID
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to duplicate
* This function is called when duplication occurs to get fresh selection state
*/
getWorkflowIds: () => string | string[]
/**
* Optional callback after successful duplication
*/
@@ -22,72 +27,89 @@ interface UseDuplicateWorkflowProps {
/**
* Hook for managing workflow duplication with optimistic updates.
*
* Handles:
* - Single or bulk workflow duplication
* - Optimistic UI updates (shows new workflow immediately)
* - Automatic rollback on failure
* - Loading state management
* - Error handling and logging
* - Clearing selection after duplication
* - Navigation to duplicated workflow (single only)
*
* @param props - Hook configuration
* @returns Duplicate workflow handlers and state
*/
export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWorkflowProps) {
export function useDuplicateWorkflow({
workspaceId,
getWorkflowIds,
onSuccess,
}: UseDuplicateWorkflowProps) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
const duplicateMutation = useDuplicateWorkflowMutation()
/**
* Duplicate the workflow(s)
* @param workflowIds - The workflow ID(s) to duplicate
*/
const handleDuplicateWorkflow = useCallback(
async (workflowIds: string | string[]) => {
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
return
}
const handleDuplicateWorkflow = useCallback(async () => {
if (duplicateMutation.isPending) {
return
}
if (duplicateMutation.isPending) {
return
}
// Get fresh workflow IDs at duplication time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
const workflowIdsToDuplicate = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
// Normalize to array for consistent handling
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
const duplicatedIds: string[] = []
const duplicatedIds: string[] = []
try {
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflows[sourceId]
if (!sourceWorkflow) {
logger.warn(`Workflow ${sourceId} not found, skipping`)
continue
}
const result = await duplicateMutation.mutateAsync({
workspaceId,
sourceId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
color: getNextWorkflowColor(),
folderId: sourceWorkflow.folderId,
})
duplicatedIds.push(result.id)
try {
// Duplicate each workflow sequentially
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflows[sourceId]
if (!sourceWorkflow) {
logger.warn(`Workflow ${sourceId} not found, skipping`)
continue
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) duplicated successfully', {
workflowIds: workflowIdsToDuplicate,
duplicatedIds,
const result = await duplicateMutation.mutateAsync({
workspaceId,
sourceId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
color: getNextWorkflowColor(),
folderId: sourceWorkflow.folderId,
})
if (duplicatedIds.length === 1) {
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
}
onSuccess?.()
} catch (error) {
logger.error('Error duplicating workflow(s):', { error })
throw error
duplicatedIds.push(result.id)
}
},
[duplicateMutation, workflows, workspaceId, router, onSuccess]
)
// Clear selection after successful duplication
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) duplicated successfully', {
workflowIds: workflowIdsToDuplicate,
duplicatedIds,
})
// Navigate to duplicated workflow if single duplication
if (duplicatedIds.length === 1) {
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
}
onSuccess?.()
} catch (error) {
logger.error('Error duplicating workflow(s):', { error })
throw error
}
}, [getWorkflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess])
return {
isDuplicating: duplicateMutation.isPending,

View File

@@ -6,9 +6,10 @@ const logger = createLogger('useDuplicateWorkspace')
interface UseDuplicateWorkspaceProps {
/**
* The workspace ID to duplicate
* Function that returns the workspace ID to duplicate
* This function is called when duplication occurs to get fresh state
*/
workspaceId: string | null
getWorkspaceId: () => string | null
/**
* Optional callback after successful duplication
*/
@@ -18,10 +19,17 @@ interface UseDuplicateWorkspaceProps {
/**
* Hook for managing workspace duplication.
*
* Handles:
* - Workspace duplication
* - Calling duplicate API
* - Loading state management
* - Error handling and logging
* - Navigation to duplicated workspace
*
* @param props - Hook configuration
* @returns Duplicate workspace handlers and state
*/
export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
const router = useRouter()
const [isDuplicating, setIsDuplicating] = useState(false)
@@ -30,12 +38,18 @@ export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWo
*/
const handleDuplicateWorkspace = useCallback(
async (workspaceName: string) => {
if (isDuplicating || !workspaceId) {
if (isDuplicating) {
return
}
setIsDuplicating(true)
try {
// Get fresh workspace ID at duplication time
const workspaceId = getWorkspaceId()
if (!workspaceId) {
return
}
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -56,6 +70,7 @@ export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWo
workflowsCount: duplicatedWorkspace.workflowsCount,
})
// Navigate to duplicated workspace
router.push(`/workspace/${duplicatedWorkspace.id}/w`)
onSuccess?.()
@@ -68,7 +83,7 @@ export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWo
setIsDuplicating(false)
}
},
[workspaceId, isDuplicating, router, onSuccess]
[getWorkspaceId, isDuplicating, router, onSuccess]
)
return {

View File

@@ -1,237 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import type { Variable } from '@/stores/workflows/workflow/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Current workspace ID
*/
workspaceId: string
/**
* The folder ID to export
*/
folderId: string
/**
* Optional callback after successful export
*/
onSuccess?: () => void
}
/**
* Recursively collects all workflow IDs within a folder and its subfolders.
*
* @param folderId - The folder ID to collect workflows from
* @param workflows - All workflows in the workspace
* @param folders - All folders in the workspace
* @returns Array of workflow IDs
*/
function collectWorkflowsInFolder(
folderId: string,
workflows: Record<string, WorkflowMetadata>,
folders: Record<string, WorkflowFolder>
): string[] {
const workflowIds: string[] = []
for (const workflow of Object.values(workflows)) {
if (workflow.folderId === folderId) {
workflowIds.push(workflow.id)
}
}
for (const folder of Object.values(folders)) {
if (folder.parentId === folderId) {
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
workflowIds.push(...childWorkflowIds)
}
}
return workflowIds
}
/**
* Hook for managing folder export to ZIP.
*
* @param props - Hook configuration
* @returns Export folder handlers and state
*/
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
const { workflows } = useWorkflowRegistry()
const { folders } = useFolderStore()
const [isExporting, setIsExporting] = useState(false)
/**
* Check if the folder has any workflows (recursively)
*/
const hasWorkflows = useMemo(() => {
if (!folderId) return false
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
}, [folderId, workflows, folders])
/**
* Download file helper
*/
const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => {
try {
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
/**
* Export all workflows in the folder (including nested subfolders) to ZIP
*/
const handleExportFolder = useCallback(async () => {
if (isExporting) {
return
}
if (!folderId) {
logger.warn('No folder ID provided for export')
return
}
setIsExporting(true)
try {
const folderStore = useFolderStore.getState()
const folder = folderStore.getFolderById(folderId)
if (!folder) {
logger.warn('Folder not found for export', { folderId })
return
}
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
if (workflowIdsToExport.length === 0) {
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
return
}
logger.info('Starting folder export', {
folderId,
folderName: folder.name,
workflowCount: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported from folder', {
folderId,
folderName: folder.name,
})
return
}
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Folder exported successfully', {
folderId,
folderName: folder.name,
workflowCount: exportedWorkflows.length,
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting folder:', { error })
throw error
} finally {
setIsExporting(false)
}
}, [folderId, isExporting, workflows, folders, onSuccess])
return {
isExporting,
hasWorkflows,
handleExportFolder,
}
}

View File

@@ -13,6 +13,11 @@ interface UseExportWorkflowProps {
* Current workspace ID
*/
workspaceId: string
/**
* Function that returns the workflow ID(s) to export
* This function is called when export occurs to get fresh selection state
*/
getWorkflowIds: () => string | string[]
/**
* Optional callback after successful export
*/
@@ -22,10 +27,23 @@ interface UseExportWorkflowProps {
/**
* Hook for managing workflow export to JSON.
*
* Handles:
* - Single or bulk workflow export
* - Fetching workflow data and variables from API
* - Sanitizing workflow state for export
* - Downloading as JSON file(s)
* - Loading state management
* - Error handling and logging
* - Clearing selection after export
*
* @param props - Hook configuration
* @returns Export workflow handlers and state
*/
export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) {
export function useExportWorkflow({
workspaceId,
getWorkflowIds,
onSuccess,
}: UseExportWorkflowProps) {
const { workflows } = useWorkflowRegistry()
const [isExporting, setIsExporting] = useState(false)
@@ -57,129 +75,130 @@ export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowP
* - Single workflow: exports as JSON file
* - Multiple workflows: exports as ZIP file containing all JSON files
* Fetches workflow data from API to support bulk export of non-active workflows
* @param workflowIds - The workflow ID(s) to export
*/
const handleExportWorkflow = useCallback(
async (workflowIds: string | string[]) => {
if (isExporting) {
const handleExportWorkflow = useCallback(async () => {
if (isExporting) {
return
}
setIsExporting(true)
try {
// Get fresh workflow IDs at export time
const workflowIdsOrId = getWorkflowIds()
if (!workflowIdsOrId) {
return
}
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
return
}
// Normalize to array for consistent handling
const workflowIdsToExport = Array.isArray(workflowIdsOrId)
? workflowIdsOrId
: [workflowIdsOrId]
setIsExporting(true)
try {
const workflowIdsToExport = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
logger.info('Starting workflow export', {
workflowIdsToExport,
count: workflowIdsToExport.length,
})
logger.info('Starting workflow export', {
workflowIdsToExport,
count: workflowIdsToExport.length,
})
const exportedWorkflows: Array<{ name: string; content: string }> = []
const exportedWorkflows: Array<{ name: string; content: string }> = []
// Export each workflow
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
for (const workflowId of workflowIdsToExport) {
try {
const workflow = workflows[workflowId]
if (!workflow) {
logger.warn(`Workflow ${workflowId} not found in registry`)
continue
}
// Fetch workflow state from API
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
if (!workflowResponse.ok) {
logger.error(`Failed to fetch workflow ${workflowId}`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
const { data: workflowData } = await workflowResponse.json()
if (!workflowData?.state) {
logger.warn(`Workflow ${workflowId} has no state`)
continue
}
// Fetch workflow variables (API returns Record format directly)
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
let workflowVariables: Record<string, Variable> | undefined
if (variablesResponse.ok) {
const variablesData = await variablesResponse.json()
workflowVariables = variablesData?.data
}
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
// Prepare export state
const workflowState = {
...workflowData.state,
metadata: {
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
}
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported')
return
}
if (exportedWorkflows.length === 1) {
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
} else {
const zip = new JSZip()
const seenFilenames = new Set<string>()
for (const exportedWorkflow of exportedWorkflows) {
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
let filename = `${baseName}.json`
let counter = 1
while (seenFilenames.has(filename.toLowerCase())) {
filename = `${baseName}-${counter}.json`
counter++
}
seenFilenames.add(filename.toLowerCase())
zip.file(filename, exportedWorkflow.content)
description: workflow.description,
color: workflow.color,
exportedAt: new Date().toISOString(),
},
variables: workflowVariables,
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `workflows-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
const exportState = sanitizeForExport(workflowState)
const jsonString = JSON.stringify(exportState, null, 2)
exportedWorkflows.push({
name: workflow.name,
content: jsonString,
})
logger.info(`Workflow ${workflowId} exported successfully`)
} catch (error) {
logger.error(`Failed to export workflow ${workflowId}:`, error)
}
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) exported successfully', {
workflowIds: workflowIdsToExport,
count: exportedWorkflows.length,
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting workflow(s):', { error })
throw error
} finally {
setIsExporting(false)
}
},
[isExporting, workflows, onSuccess]
)
if (exportedWorkflows.length === 0) {
logger.warn('No workflows were successfully exported')
return
}
// Download as single JSON or ZIP depending on count
if (exportedWorkflows.length === 1) {
// Single workflow - download as JSON
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
} else {
// Multiple workflows - download as ZIP
const zip = new JSZip()
for (const exportedWorkflow of exportedWorkflows) {
const filename = `${exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
zip.file(filename, exportedWorkflow.content)
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const zipFilename = `workflows-export-${Date.now()}.zip`
downloadFile(zipBlob, zipFilename, 'application/zip')
}
// Clear selection after successful export
const { clearSelection } = useFolderStore.getState()
clearSelection()
logger.info('Workflow(s) exported successfully', {
workflowIds: workflowIdsToExport,
count: exportedWorkflows.length,
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
})
onSuccess?.()
} catch (error) {
logger.error('Error exporting workflow(s):', { error })
throw error
} finally {
setIsExporting(false)
}
}, [getWorkflowIds, isExporting, workflows, onSuccess])
return {
isExporting,

View File

@@ -44,18 +44,21 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
try {
logger.info('Exporting workspace', { workspaceId })
// Fetch all workflows in workspace
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (!workflowsResponse.ok) {
throw new Error('Failed to fetch workflows')
}
const { data: workflows } = await workflowsResponse.json()
// Fetch all folders in workspace
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
if (!foldersResponse.ok) {
throw new Error('Failed to fetch folders')
}
const foldersData = await foldersResponse.json()
// Export each workflow
const workflowsToExport: WorkflowExportData[] = []
for (const workflow of workflows) {

View File

@@ -33,7 +33,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const createWorkflowMutation = useCreateWorkflow()
const queryClient = useQueryClient()
const createFolderMutation = useCreateFolder()
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
const [isImporting, setIsImporting] = useState(false)
/**
@@ -49,8 +48,9 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
const workflowName = extractWorkflowName(content, filename)
clearDiff()
useWorkflowDiffStore.getState().clearDiff()
// Extract color from metadata
const parsedContent = JSON.parse(content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
@@ -63,6 +63,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
})
const newWorkflowId = result.id
// Update workflow color if we extracted one
if (workflowColor !== '#3972F6') {
await fetch(`/api/workflows/${newWorkflowId}`, {
method: 'PATCH',
@@ -71,13 +72,16 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
})
}
// Save workflow state
await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
})
// Save variables if any (handle both legacy Array and current Record formats)
if (workflowData.variables) {
// Convert to Record format for API (handles backwards compatibility with old Array exports)
const variablesArray = Array.isArray(workflowData.variables)
? workflowData.variables
: Object.values(workflowData.variables)
@@ -110,7 +114,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
logger.info(`Imported workflow: ${workflowName}`)
return newWorkflowId
},
[clearDiff, createWorkflowMutation, workspaceId]
[createWorkflowMutation, workspaceId]
)
/**
@@ -130,6 +134,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
const importedWorkflowIds: string[] = []
if (hasZip && fileArray.length === 1) {
// Import from ZIP - preserves folder structure
const zipFile = fileArray[0]
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
@@ -144,6 +149,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
try {
let targetFolderId = importFolder.id
// Recreate nested folder structure
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
@@ -181,6 +187,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
}
} else if (jsonFiles.length > 0) {
// Import multiple JSON files or single JSON
const extractedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
for (const workflow of extractedWorkflows) {
@@ -193,21 +200,22 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
}
}
// Reload workflows and folders to show newly imported ones
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) })
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
// Navigate to first imported workflow if any
if (importedWorkflowIds.length > 0) {
router.push(
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
)
router.push(`/workspace/${workspaceId}/w/${importedWorkflowIds[0]}`)
}
} catch (error) {
logger.error('Failed to import workflows:', error)
} finally {
setIsImporting(false)
// Reset file input
if (event.target) {
event.target.value = ''
}

View File

@@ -21,6 +21,15 @@ interface UseImportWorkspaceProps {
/**
* Hook for managing workspace import from ZIP files.
*
* Handles:
* - Extracting workflows from ZIP file
* - Creating new workspace
* - Recreating folder structure
* - Importing all workflows with states and variables
* - Navigation to imported workspace
* - Loading state management
* - Error handling and logging
*
* @param props - Hook configuration
* @returns Import workspace handlers and state
*/
@@ -28,7 +37,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const router = useRouter()
const [isImporting, setIsImporting] = useState(false)
const createFolderMutation = useCreateFolder()
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
/**
* Handle workspace import from ZIP file
@@ -48,6 +56,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
try {
logger.info('Importing workspace from ZIP')
// Extract workflows from ZIP
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
if (extractedWorkflows.length === 0) {
@@ -55,6 +64,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
return
}
// Create new workspace
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
const createResponse = await fetch('/api/workspaces', {
method: 'POST',
@@ -71,6 +81,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const folderMap = new Map<string, string>()
// Import workflows
for (const workflow of extractedWorkflows) {
try {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
@@ -80,6 +91,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
continue
}
// Recreate folder structure
let targetFolderId: string | null = null
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
@@ -108,12 +120,14 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
}
const workflowName = extractWorkflowName(workflow.content, workflow.name)
clearDiff()
useWorkflowDiffStore.getState().clearDiff()
// Extract color from workflow metadata
const parsedContent = JSON.parse(workflow.content)
const workflowColor =
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
// Create workflow
const createWorkflowResponse = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -133,6 +147,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const newWorkflow = await createWorkflowResponse.json()
// Save workflow state
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -144,7 +159,9 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
continue
}
// Save variables if any (handle both legacy Array and current Record formats)
if (workflowData.variables) {
// Convert to Record format for API (handles backwards compatibility with old Array exports)
const variablesArray = Array.isArray(workflowData.variables)
? workflowData.variables
: Object.values(workflowData.variables)
@@ -182,6 +199,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
logger.info(`Workspace import complete. Imported ${extractedWorkflows.length} workflows`)
// Navigate to new workspace
router.push(`/workspace/${newWorkspace.id}/w`)
onSuccess?.()
@@ -192,7 +210,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
setIsImporting(false)
}
},
[isImporting, router, onSuccess, createFolderMutation, clearDiff]
[isImporting, router, onSuccess, createFolderMutation]
)
return {

View File

@@ -1,22 +0,0 @@
/**
* Download icon animation
* Subtle continuous animation for import/download states
* Arrow gently pulses down to suggest downloading motion
*/
@keyframes arrow-pulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(1.5px);
opacity: 0.7;
}
}
.animated-download-svg {
animation: arrow-pulse 1.5s ease-in-out infinite;
transform-origin: center center;
}

View File

@@ -1,42 +0,0 @@
import type { SVGProps } from 'react'
import styles from '@/components/emcn/icons/animate/download.module.css'
export interface DownloadProps extends SVGProps<SVGSVGElement> {
/**
* Enable animation on the download icon
* @default false
*/
animate?: boolean
}
/**
* Download icon component with optional CSS-based animation
* Based on lucide arrow-down icon structure.
* When animate is false, this is a lightweight static icon with no animation overhead.
* When animate is true, CSS module animations are applied for a subtle pulsing effect.
* @param props - SVG properties including className, animate, etc.
*/
export function Download({ animate = false, className, ...props }: DownloadProps) {
const svgClassName = animate
? `${styles['animated-download-svg']} ${className || ''}`.trim()
: className
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className={svgClassName}
{...props}
>
<path d='M12 5v14' />
<path d='m19 12-7 7-7-7' />
</svg>
)
}

View File

@@ -5,7 +5,6 @@ export { ChevronDown } from './chevron-down'
export { Connections } from './connections'
export { Copy } from './copy'
export { DocumentAttachment } from './document-attachment'
export { Download } from './download'
export { Duplicate } from './duplicate'
export { Eye } from './eye'
export { FolderCode } from './folder-code'

View File

@@ -1,50 +0,0 @@
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
export class SearchLibraryDocsClientTool extends BaseClientTool {
static readonly id = 'search_library_docs'
constructor(toolCallId: string) {
super(toolCallId, SearchLibraryDocsClientTool.id, SearchLibraryDocsClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle },
},
getDynamicText: (params, state) => {
const libraryName = params?.library_name
if (libraryName && typeof libraryName === 'string') {
switch (state) {
case ClientToolCallState.success:
return `Read ${libraryName} docs`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Reading ${libraryName} docs`
case ClientToolCallState.error:
return `Failed to read ${libraryName} docs`
case ClientToolCallState.aborted:
return `Aborted reading ${libraryName} docs`
case ClientToolCallState.rejected:
return `Skipped reading ${libraryName} docs`
}
}
return undefined
},
}
async execute(): Promise<void> {
return
}
}

View File

@@ -42,7 +42,6 @@ import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/rememb
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
@@ -117,7 +116,6 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id),
search_online: (id) => new SearchOnlineClientTool(id),
search_documentation: (id) => new SearchDocumentationClientTool(id),
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
search_patterns: (id) => new SearchPatternsClientTool(id),
search_errors: (id) => new SearchErrorsClientTool(id),
remember_debug: (id) => new RememberDebugClientTool(id),
@@ -176,7 +174,6 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
get_trigger_blocks: (GetTriggerBlocksClientTool as any)?.metadata,
search_online: (SearchOnlineClientTool as any)?.metadata,
search_documentation: (SearchDocumentationClientTool as any)?.metadata,
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
search_patterns: (SearchPatternsClientTool as any)?.metadata,
search_errors: (SearchErrorsClientTool as any)?.metadata,
remember_debug: (RememberDebugClientTool as any)?.metadata,

View File

@@ -97,18 +97,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
return acc
}, {})
set((state) => {
// Preserve hydration if workflow is loading or already ready and still exists
const shouldPreserveHydration =
state.hydration.phase === 'state-loading' ||
(state.hydration.phase === 'ready' &&
state.hydration.workflowId &&
mapped[state.hydration.workflowId])
return {
workflows: mapped,
error: null,
hydration: shouldPreserveHydration
set((state) => ({
workflows: mapped,
error: null,
hydration:
state.hydration.phase === 'state-loading'
? state.hydration
: {
phase: 'metadata-ready',
@@ -117,8 +110,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
requestId: null,
error: null,
},
}
})
}))
},
failMetadataLoad: (workspaceId: string | null, errorMessage: string) => {

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -12,7 +11,7 @@
"drizzle-kit": "^0.31.4",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.4",
"turbo": "2.7.3",
},
},
"apps/docs": {
@@ -3380,19 +3379,19 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.7.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.4", "turbo-darwin-arm64": "2.7.4", "turbo-linux-64": "2.7.4", "turbo-linux-arm64": "2.7.4", "turbo-windows-64": "2.7.4", "turbo-windows-arm64": "2.7.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g=="],
"turbo": ["turbo@2.7.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.3", "turbo-darwin-arm64": "2.7.3", "turbo-linux-64": "2.7.3", "turbo-linux-arm64": "2.7.3", "turbo-windows-64": "2.7.3", "turbo-windows-arm64": "2.7.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+HjKlP4OfYk+qzvWNETA3cUO5UuK6b5MSc2UJOKyvBceKucQoQGb2g7HlC2H1GHdkfKrk4YF1VPvROkhVZDDLQ=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkVrHSq+Bnhl9sX2LQgqQYVfLTWC2gvI74C4758OmU0djfrssDKU9d4YQF0AYXXhIIRZipSXfxClQziIMD+EAg=="],
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
"turbo-linux-64": ["turbo-linux-64@2.7.3", "", { "os": "linux", "cpu": "x64" }, "sha512-GqDsCNnzzr89kMaLGpRALyigUklzgxIrSy2pHZVXyifgczvYPnLglex78Aj3T2gu+T3trPPH2iJ+pWucVOCC2Q=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NdCDTfIcIo3dWjsiaAHlxu5gW61Ed/8maah1IAF/9E3EtX0aAHNiBMbuYLZaR4vRJ7BeVkYB6xKWRtdFLZ0y3g=="],
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
"turbo-windows-64": ["turbo-windows-64@2.7.3", "", { "os": "win32", "cpu": "x64" }, "sha512-7bVvO987daXGSJVYBoG8R4Q+csT1pKIgLJYZevXRQ0Hqw0Vv4mKme/TOjYXs9Qb1xMKh51Tb3bXKDbd8/4G08g=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-nTodweTbPmkvwMu/a55XvjMsPtuyUSC+sV7f/SR57K36rB2I0YG21qNETN+00LOTUW9B3omd8XkiXJkt4kx/cw=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],

View File

@@ -41,7 +41,7 @@
"drizzle-kit": "^0.31.4",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.7.4"
"turbo": "2.7.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [