From 87c36c760d381602becdbc0d2d0d73e809854fdc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 17:33:29 -0700 Subject: [PATCH 1/6] Add restore endpoints and ui --- apps/sim/app/_styles/globals.css | 22 ++ .../app/api/knowledge/[id]/restore/route.ts | 61 ++++ .../app/api/table/[tableId]/restore/route.ts | 45 +++ .../app/api/workflows/[id]/restore/route.ts | 53 +++ .../[id]/files/[fileId]/restore/route.ts | 40 +++ .../workspace/[workspaceId]/files/files.tsx | 4 +- .../[workspaceId]/knowledge/[id]/base.tsx | 8 +- .../delete-knowledge-base-modal.tsx | 8 +- .../[workspaceId]/settings/[section]/page.tsx | 1 + .../settings/[section]/settings.tsx | 54 +-- .../recently-deleted/recently-deleted.tsx | 308 ++++++++++++++++++ .../[workspaceId]/settings/navigation.ts | 3 + .../[tableId]/components/table/table.tsx | 4 +- .../workspace/[workspaceId]/tables/tables.tsx | 6 +- .../w/[workflowId]/components/panel/panel.tsx | 6 +- .../components/delete-modal/delete-modal.tsx | 18 +- apps/sim/components/emcn/components/index.ts | 1 + .../emcn/components/toast/toast.tsx | 224 +++++++++++++ apps/sim/components/emcn/icons/index.ts | 1 + .../components/emcn/icons/trash-outline.tsx | 28 ++ apps/sim/hooks/queries/kb/knowledge.ts | 18 + apps/sim/hooks/queries/tables.ts | 18 + apps/sim/hooks/queries/workflows.ts | 18 + apps/sim/hooks/queries/workspace-files.ts | 21 ++ apps/sim/lib/knowledge/service.ts | 71 ++++ apps/sim/lib/table/service.ts | 29 ++ .../workspace/workspace-file-manager.ts | 35 ++ apps/sim/lib/workflows/lifecycle.ts | 73 +++++ 28 files changed, 1139 insertions(+), 39 deletions(-) create mode 100644 apps/sim/app/api/knowledge/[id]/restore/route.ts create mode 100644 apps/sim/app/api/table/[tableId]/restore/route.ts create mode 100644 apps/sim/app/api/workflows/[id]/restore/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx create mode 100644 apps/sim/components/emcn/components/toast/toast.tsx create mode 100644 apps/sim/components/emcn/icons/trash-outline.tsx diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 91b267d72d9..e6feaef5684 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -700,6 +700,28 @@ input[type="search"]::-ms-clear { } } +@keyframes toast-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toast-exit { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.97); + } +} + /** * @depricated * Legacy globals (light/dark) kept for backward-compat with old classes. diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts new file mode 100644 index 00000000000..f247d6aa276 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -0,0 +1,61 @@ +import { db } from '@sim/db' +import { knowledgeBase } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { restoreKnowledgeBase } from '@/lib/knowledge/service' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreKnowledgeBaseAPI') + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = generateRequestId() + const { id } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const [kb] = await db + .select({ + id: knowledgeBase.id, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, id)) + .limit(1) + + if (!kb) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + + if (kb.workspaceId) { + const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + } else if (kb.userId !== auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + await restoreKnowledgeBase(id, requestId) + + logger.info(`[${requestId}] Restored knowledge base ${id}`) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts new file mode 100644 index 00000000000..8622f849f1d --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -0,0 +1,45 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getTableById, restoreTable } from '@/lib/table' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreTableAPI') + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ tableId: string }> } +) { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + + const permission = await getUserEntityPermissions(auth.userId, 'workspace', table.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + await restoreTable(tableId, requestId) + + logger.info(`[${requestId}] Restored table ${tableId}`) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error restoring table ${tableId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts new file mode 100644 index 00000000000..d50f95dea27 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -0,0 +1,53 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { restoreWorkflow } from '@/lib/workflows/lifecycle' +import { getWorkflowById } from '@/lib/workflows/utils' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreWorkflowAPI') + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + const { id: workflowId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workflowData = await getWorkflowById(workflowId, { includeArchived: true }) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + if (workflowData.workspaceId) { + const permission = await getUserEntityPermissions( + auth.userId, + 'workspace', + workflowData.workspaceId + ) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + } + + const result = await restoreWorkflow(workflowId, { requestId }) + + if (!result.restored) { + return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) + } + + logger.info(`[${requestId}] Restored workflow ${workflowId}`) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..eae4bae4368 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -0,0 +1,40 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('RestoreWorkspaceFileAPI') + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; fileId: string }> } +) { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin' && userPermission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + await restoreWorkspaceFile(workspaceId, fileId) + + logger.info(`[${requestId}] Restored workspace file ${fileId}`) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 8cbc8014fc3..328c2b265b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -792,7 +792,9 @@ function DeleteConfirmModal({

Are you sure you want to delete{' '} {fileName}?{' '} - This action cannot be undone. + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 48d7e47c6c5..a772505bbc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -1105,9 +1105,11 @@ export function KnowledgeBase({

Are you sure you want to delete{' '} {knowledgeBaseName}? - This will permanently delete the knowledge base and all {pagination.total} document - {pagination.total === 1 ? '' : 's'} within it.{' '} - This action cannot be undone. + The knowledge base and all {pagination.total} document + {pagination.total === 1 ? '' : 's'} within it will be removed.{' '} + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx index 4ce0610aa13..7d1655c3dc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx @@ -46,12 +46,14 @@ export function DeleteKnowledgeBaseModal({ <> Are you sure you want to delete{' '} {knowledgeBaseName}? - This will permanently remove all associated documents, chunks, and embeddings. + All associated documents, chunks, and embeddings will be removed. ) : ( - 'Are you sure you want to delete this knowledge base? This will permanently remove all associated documents, chunks, and embeddings.' + 'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.' )}{' '} - This action cannot be undone. + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 6a5ada99058..36c1f97867a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -22,6 +22,7 @@ const SECTION_TITLES: Record = { skills: 'Skills', 'workflow-mcp-servers': 'MCP Servers', 'credential-sets': 'Email Polling', + 'recently-deleted': 'Recently Deleted', debug: 'Debug', } as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index fc8dbbaa664..e561302be54 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic' import { useSearchParams } from 'next/navigation' -import { Skeleton } from '@/components/emcn' +import { Skeleton, ToastProvider } from '@/components/emcn' import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton' import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton' @@ -135,6 +135,13 @@ const Debug = dynamic( import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug), { loading: () => } ) +const RecentlyDeleted = dynamic( + () => + import( + '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' + ).then((m) => m.RecentlyDeleted), + { loading: () => } +) const AccessControl = dynamic( () => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl), { loading: () => } @@ -158,26 +165,29 @@ export function SettingsPage({ section }: SettingsPageProps) { allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection return ( -
-

{label}

- {effectiveSection === 'general' && } - {effectiveSection === 'integrations' && } - {effectiveSection === 'secrets' && } - {effectiveSection === 'template-profile' && } - {effectiveSection === 'credential-sets' && } - {effectiveSection === 'access-control' && } - {effectiveSection === 'apikeys' && } - {isBillingEnabled && effectiveSection === 'subscription' && } - {isBillingEnabled && effectiveSection === 'team' && } - {effectiveSection === 'sso' && } - {effectiveSection === 'byok' && } - {effectiveSection === 'copilot' && } - {effectiveSection === 'mcp' && } - {effectiveSection === 'custom-tools' && } - {effectiveSection === 'skills' && } - {effectiveSection === 'workflow-mcp-servers' && } - {effectiveSection === 'inbox' && } - {effectiveSection === 'debug' && } -
+ +
+

{label}

+ {effectiveSection === 'general' && } + {effectiveSection === 'integrations' && } + {effectiveSection === 'secrets' && } + {effectiveSection === 'template-profile' && } + {effectiveSection === 'credential-sets' && } + {effectiveSection === 'access-control' && } + {effectiveSection === 'apikeys' && } + {isBillingEnabled && effectiveSection === 'subscription' && } + {isBillingEnabled && effectiveSection === 'team' && } + {effectiveSection === 'sso' && } + {effectiveSection === 'byok' && } + {effectiveSection === 'copilot' && } + {effectiveSection === 'mcp' && } + {effectiveSection === 'custom-tools' && } + {effectiveSection === 'skills' && } + {effectiveSection === 'workflow-mcp-servers' && } + {effectiveSection === 'inbox' && } + {effectiveSection === 'recently-deleted' && } + {effectiveSection === 'debug' && } +
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx new file mode 100644 index 00000000000..c54612a9d24 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -0,0 +1,308 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Loader2, Search } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { + Button, + SModalTabs, + SModalTabsList, + SModalTabsTrigger, + toast, + Undo, +} from '@/components/emcn' +import { Input } from '@/components/ui' +import { formatDate } from '@/lib/core/utils/formatting' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { useRestoreTable, useTablesList } from '@/hooks/queries/tables' +import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows' + +function getResourceHref(workspaceId: string, type: Exclude, id: string): string { + const base = `/workspace/${workspaceId}` + switch (type) { + case 'workflow': + return `${base}/w/${id}` + case 'table': + return `${base}/tables/${id}` + case 'knowledge': + return `${base}/knowledge/${id}` + case 'file': + return `${base}/files` + } +} + +type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file' + +const ICON_CLASS = 'h-[14px] w-[14px]' + +const RESOURCE_TYPE_TO_MOTHERSHIP: Record, MothershipResourceType> = { + workflow: 'workflow', + table: 'table', + knowledge: 'knowledgebase', + file: 'file', +} + +interface DeletedResource { + id: string + name: string + type: Exclude + deletedAt: Date + workspaceId: string + color?: string +} + +const TABS: { id: ResourceType; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'workflow', label: 'Workflows' }, + { id: 'table', label: 'Tables' }, + { id: 'knowledge', label: 'Knowledge Bases' }, + { id: 'file', label: 'Files' }, +] + +const TYPE_LABEL: Record, string> = { + workflow: 'Workflow', + table: 'Table', + knowledge: 'Knowledge Base', + file: 'File', +} + +function ResourceIcon({ resource }: { resource: DeletedResource }) { + if (resource.type === 'workflow') { + const color = resource.color ?? '#888' + return ( +
+ ) + } + + const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type] + const config = RESOURCE_REGISTRY[mothershipType] + return ( + <> + {config.renderTabIcon( + { type: mothershipType, id: resource.id, title: resource.name }, + ICON_CLASS + )} + + ) +} + +export function RecentlyDeleted() { + const params = useParams() + const router = useRouter() + const workspaceId = params?.workspaceId as string + const [activeTab, setActiveTab] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + const [restoringIds, setRestoringIds] = useState>(new Set()) + + const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' }) + const tablesQuery = useTablesList(workspaceId, 'archived') + const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) + const filesQuery = useWorkspaceFiles(workspaceId, 'archived') + + const restoreWorkflow = useRestoreWorkflow() + const restoreTable = useRestoreTable() + const restoreKnowledgeBase = useRestoreKnowledgeBase() + const restoreWorkspaceFile = useRestoreWorkspaceFile() + + const isLoading = + workflowsQuery.isLoading || + tablesQuery.isLoading || + knowledgeQuery.isLoading || + filesQuery.isLoading + + const resources = useMemo(() => { + const items: DeletedResource[] = [] + + for (const wf of workflowsQuery.data ?? []) { + items.push({ + id: wf.id, + name: wf.name, + type: 'workflow', + deletedAt: new Date(wf.lastModified), + workspaceId: wf.workspaceId ?? workspaceId, + color: wf.color, + }) + } + + for (const t of tablesQuery.data ?? []) { + items.push({ + id: t.id, + name: t.name, + type: 'table', + deletedAt: new Date(t.archivedAt ?? t.updatedAt), + workspaceId: t.workspaceId, + }) + } + + for (const kb of knowledgeQuery.data ?? []) { + items.push({ + id: kb.id, + name: kb.name, + type: 'knowledge', + deletedAt: new Date(kb.updatedAt), + workspaceId: kb.workspaceId ?? workspaceId, + }) + } + + for (const f of filesQuery.data ?? []) { + items.push({ + id: f.id, + name: f.name, + type: 'file', + deletedAt: new Date(f.deletedAt ?? f.uploadedAt), + workspaceId: f.workspaceId, + }) + } + + items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime()) + return items + }, [workflowsQuery.data, tablesQuery.data, knowledgeQuery.data, filesQuery.data, workspaceId]) + + const filtered = useMemo(() => { + let items = activeTab === 'all' ? resources : resources.filter((r) => r.type === activeTab) + if (searchTerm.trim()) { + const normalized = searchTerm.toLowerCase() + items = items.filter((r) => r.name.toLowerCase().includes(normalized)) + } + return items + }, [resources, activeTab, searchTerm]) + + const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0 + + function handleRestore(resource: DeletedResource) { + setRestoringIds((prev) => new Set(prev).add(resource.id)) + + const onSettled = () => { + setRestoringIds((prev) => { + const next = new Set(prev) + next.delete(resource.id) + return next + }) + } + + const onSuccess = () => { + const href = getResourceHref(resource.workspaceId, resource.type, resource.id) + toast.success(`${resource.name} restored`, { + action: { label: 'View', onClick: () => router.push(href) }, + }) + } + + switch (resource.type) { + case 'workflow': + restoreWorkflow.mutate(resource.id, { onSettled, onSuccess }) + break + case 'table': + restoreTable.mutate(resource.id, { onSettled, onSuccess }) + break + case 'knowledge': + restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess }) + break + case 'file': + restoreWorkspaceFile.mutate( + { workspaceId: resource.workspaceId, fileId: resource.id }, + { onSettled, onSuccess } + ) + break + } + } + + return ( +
+

+ Items you delete are kept here for 30 days before being permanently removed. +

+ +
+ + setSearchTerm(e.target.value)} + disabled={isLoading} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ + setActiveTab(v as ResourceType)} + > + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + + {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+

+ {showNoResults + ? `No items found matching \u201c${searchTerm}\u201d` + : 'No deleted items'} +

+
+ ) : ( +
+ {filtered.map((resource) => { + const isRestoring = restoringIds.has(resource.id) + + return ( +
+ + +
+ + {resource.name} + + + {TYPE_LABEL[resource.type]} + {' \u00b7 '} + Deleted {formatDate(resource.deletedAt)} + +
+ + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 59f305c6913..482d7d9a98a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -12,6 +12,7 @@ import { Settings, ShieldCheck, TerminalWindow, + TrashOutline, User, Users, Wrench, @@ -39,6 +40,7 @@ export type SettingsSection = | 'inbox' | 'docs' | 'debug' + | 'recently-deleted' export type NavigationSection = | 'account' @@ -143,6 +145,7 @@ export const allNavigationItems: NavigationItem[] = [ requiresHosted: true, selfHostedOverride: isCredentialSetsEnabled, }, + { id: 'recently-deleted', label: 'Recently Deleted', icon: TrashOutline, section: 'system' }, { id: 'sso', label: 'Single Sign-On', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index b0554883206..db4aac84403 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1454,7 +1454,9 @@ export function Table({

Are you sure you want to delete{' '} {tableData?.name}?{' '} - This action cannot be undone. + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 4a22fdfdb3a..e32c890e00b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -211,8 +211,10 @@ export function Tables() {

Are you sure you want to delete{' '} {activeTable?.name}? - This will permanently delete all {activeTable?.rowCount} rows.{' '} - This action cannot be undone. + All {activeTable?.rowCount} rows will be removed.{' '} + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 816768e7294..eee761a21f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -590,8 +590,10 @@ export const Panel = memo(function Panel() { {currentWorkflow?.name ?? 'this workflow'} - ? This will permanently remove all associated blocks, executions, and configuration.{' '} - This action cannot be undone. + ? All associated blocks, executions, and configuration will be removed.{' '} + + You can restore it from Recently Deleted in Settings. +

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx index 47ce7a31227..7773ae13720 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx @@ -64,6 +64,8 @@ export function DeleteModal({ title = 'Delete Workspace' } + const restorableTypes = new Set(['workflow']) + const renderDescription = () => { if (itemType === 'workflow') { if (isMultiple) { @@ -73,7 +75,7 @@ export function DeleteModal({ {displayNames.join(', ')} - ? This will permanently remove all associated blocks, executions, and configuration. + ? All associated blocks, executions, and configuration will be removed. ) } @@ -81,12 +83,12 @@ export function DeleteModal({ return ( <> Are you sure you want to delete{' '} - {displayNames[0]}? This - will permanently remove all associated blocks, executions, and configuration. + {displayNames[0]}? All + associated blocks, executions, and configuration will be removed. ) } - return 'Are you sure you want to delete this workflow? This will permanently remove all associated blocks, executions, and configuration.' + return 'Are you sure you want to delete this workflow? All associated blocks, executions, and configuration will be removed.' } if (itemType === 'folder') { @@ -174,7 +176,13 @@ export function DeleteModal({

{renderDescription()}{' '} - This action cannot be undone. + {restorableTypes.has(itemType) ? ( + + You can restore it from Recently Deleted in Settings. + + ) : ( + This action cannot be undone. + )}

diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index dcaefa1082b..dad5a5459b4 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -141,5 +141,6 @@ export { tagVariants, } from './tag-input/tag-input' export { Textarea } from './textarea/textarea' +export { toast, ToastProvider, useToast } from './toast/toast' export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker' export { Tooltip } from './tooltip/tooltip' diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx new file mode 100644 index 00000000000..c392ec38a47 --- /dev/null +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -0,0 +1,224 @@ +'use client' + +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { cn } from '@/lib/core/utils/cn' + +const AUTO_DISMISS_MS = 0 +const EXIT_ANIMATION_MS = 200 +const MAX_VISIBLE = 20 + +type ToastVariant = 'default' | 'success' | 'error' + +interface ToastAction { + label: string + onClick: () => void +} + +interface ToastData { + id: string + message: string + description?: string + variant: ToastVariant + action?: ToastAction + duration: number +} + +type ToastInput = { + message: string + description?: string + variant?: ToastVariant + action?: ToastAction + duration?: number +} + +type ToastFn = { + (input: ToastInput): string + success: (message: string, options?: Omit) => string + error: (message: string, options?: Omit) => string +} + +interface ToastContextValue { + toast: ToastFn + dismiss: (id: string) => void +} + +const ToastContext = createContext(null) + +let globalToast: ToastFn | null = null + +function createToastFn(add: (input: ToastInput) => string): ToastFn { + const fn = ((input: ToastInput) => add(input)) as ToastFn + fn.success = (message, options) => add({ ...options, message, variant: 'success' }) + fn.error = (message, options) => add({ ...options, message, variant: 'error' }) + return fn +} + +/** + * Imperative toast function. Requires `` to be mounted. + * + * @example + * ```tsx + * toast.success('Item restored', { action: { label: 'View', onClick: () => router.push('/item') } }) + * toast.error('Something went wrong') + * toast({ message: 'Hello', variant: 'default' }) + * ``` + */ +export const toast: ToastFn = createToastFn((input) => { + if (!globalToast) { + throw new Error('toast() called before mounted') + } + return globalToast(input) +}) + +/** + * Hook to access the toast function from context. + */ +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) throw new Error('useToast must be used within ') + return ctx +} + +const VARIANT_STYLES: Record = { + default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]', + success: + 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200', + error: + 'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200', +} + +function ToastItem({ + toast: t, + onDismiss, +}: { + toast: ToastData + onDismiss: (id: string) => void +}) { + const [exiting, setExiting] = useState(false) + const timerRef = useRef>(undefined) + + const dismiss = useCallback(() => { + setExiting(true) + setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS) + }, [onDismiss, t.id]) + + useEffect(() => { + if (t.duration > 0) { + timerRef.current = setTimeout(dismiss, t.duration) + return () => clearTimeout(timerRef.current) + } + }, [dismiss, t.duration]) + + return ( +
+
+

{t.message}

+ {t.description && ( +

{t.description}

+ )} +
+ {t.action && ( + + )} + +
+ ) +} + +/** + * Toast container that renders toasts via portal. + * Mount once in your root layout. + * + * @example + * ```tsx + * + * ``` + */ +export function ToastProvider({ children }: { children?: ReactNode }) { + const [toasts, setToasts] = useState([]) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + const addToast = useCallback((input: ToastInput): string => { + const id = crypto.randomUUID() + const data: ToastData = { + id, + message: input.message, + description: input.description, + variant: input.variant ?? 'default', + action: input.action, + duration: input.duration ?? AUTO_DISMISS_MS, + } + setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE)) + return id + }, []) + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, []) + + const toastFn = useRef(createToastFn(addToast)) + + useEffect(() => { + toastFn.current = createToastFn(addToast) + globalToast = toastFn.current + return () => { + globalToast = null + } + }, [addToast]) + + const ctx: ToastContextValue = { toast: toastFn.current, dismiss: dismissToast } + + return ( + + {children} + {mounted && + createPortal( +
+ {toasts.map((t) => ( + + ))} +
, + document.body + )} +
+ ) +} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index f573411bf64..4c89ce8ea95 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -71,6 +71,7 @@ export { Tag } from './tag' export { TerminalWindow } from './terminal-window' export { Trash } from './trash' export { Trash2 } from './trash2' +export { TrashOutline } from './trash-outline' export { TypeBoolean } from './type-boolean' export { TypeJson } from './type-json' export { TypeNumber } from './type-number' diff --git a/apps/sim/components/emcn/icons/trash-outline.tsx b/apps/sim/components/emcn/icons/trash-outline.tsx new file mode 100644 index 00000000000..075574ae4b5 --- /dev/null +++ b/apps/sim/components/emcn/icons/trash-outline.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * Outline-style trash icon matching the stroke-based nav icon convention + * @param props - SVG properties including className, fill, etc. + */ +export function TrashOutline(props: SVGProps) { + return ( + + + + + + + + ) +} diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 44360e0f25b..28bd6a05596 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -1219,6 +1219,24 @@ export async function deleteDocumentTagDefinitions({ } } +export function useRestoreKnowledgeBase() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (knowledgeBaseId: string) => { + const res = await fetch(`/api/knowledge/${knowledgeBaseId}/restore`, { method: 'POST' }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to restore knowledge base') + } + return res.json() + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.all }) + }, + }) +} + export function useDeleteDocumentTagDefinitions() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 984f5ec88cd..2c75b3f378a 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -716,6 +716,24 @@ export function useUpdateTableMetadata({ workspaceId, tableId }: RowMutationCont /** * Delete a column from a table. */ +export function useRestoreTable() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (tableId: string) => { + const res = await fetch(`/api/table/${tableId}/restore`, { method: 'POST' }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to restore table') + } + return res.json() + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + }, + }) +} + export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 95070070a90..d3181becfb2 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -767,3 +767,21 @@ export function useImportWorkflow() { }, }) } + +export function useRestoreWorkflow() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (workflowId: string) => { + const res = await fetch(`/api/workflows/${workflowId}/restore`, { method: 'POST' }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to restore workflow') + } + return res.json() + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + }, + }) +} diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 5c543191e19..224f074f7ad 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -310,3 +310,24 @@ export function useDeleteWorkspaceFile() { }, }) } + +export function useRestoreWorkspaceFile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, fileId }: { workspaceId: string; fileId: string }) => { + const res = await fetch(`/api/workspaces/${workspaceId}/files/${fileId}/restore`, { + method: 'POST', + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to restore file') + } + return res.json() + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) + }, + }) +} diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 58af6e310da..cb4169e5705 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -366,3 +366,74 @@ export async function deleteKnowledgeBase( logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } + +/** + * Restore a soft-deleted knowledge base and its graph children. + * Clears archivedAt on children that were archived as part of the KB snapshot. + * Does NOT revive children that were directly deleted (deletedAt set). + */ +export async function restoreKnowledgeBase( + knowledgeBaseId: string, + requestId: string +): Promise { + const [kb] = await db + .select({ + id: knowledgeBase.id, + deletedAt: knowledgeBase.deletedAt, + workspaceId: knowledgeBase.workspaceId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) + + if (!kb) { + throw new Error('Knowledge base not found') + } + + if (!kb.deletedAt) { + throw new Error('Knowledge base is not archived') + } + + if (kb.workspaceId) { + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(kb.workspaceId) + if (!ws || ws.archivedAt) { + throw new Error('Cannot restore knowledge base into an archived workspace') + } + } + + const now = new Date() + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + + await tx + .update(knowledgeBase) + .set({ deletedAt: null, updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + + await tx + .update(document) + .set({ archivedAt: null }) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + isNotNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + await tx + .update(knowledgeConnector) + .set({ archivedAt: null, updatedAt: now }) + .where( + and( + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNotNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + }) + + logger.info(`[${requestId}] Restored knowledge base: ${knowledgeBaseId}`) +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 23344dc6e48..e4f25689f19 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -432,6 +432,35 @@ export async function deleteTable(tableId: string, requestId: string): Promise { + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) { + throw new Error('Table not found') + } + + if (!table.archivedAt) { + throw new Error('Table is not archived') + } + + if (table.workspaceId) { + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(table.workspaceId) + if (!ws || ws.archivedAt) { + throw new Error('Cannot restore table into an archived workspace') + } + } + + await db + .update(userTableDefinitions) + .set({ archivedAt: null, updatedAt: new Date() }) + .where(eq(userTableDefinitions.id, tableId)) + + logger.info(`[${requestId}] Restored table ${tableId}`) +} + /** * Inserts a single row into a table. * diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index afc5e7918a1..af513dfe0f9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -526,3 +526,38 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): ) } } + +/** + * Restore a soft-deleted workspace file. + */ +export async function restoreWorkspaceFile(workspaceId: string, fileId: string): Promise { + logger.info(`Restoring workspace file: ${fileId}`) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId, { includeDeleted: true }) + if (!fileRecord) { + throw new Error('File not found') + } + + if (!fileRecord.deletedAt) { + throw new Error('File is not archived') + } + + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(workspaceId) + if (!ws || ws.archivedAt) { + throw new Error('Cannot restore file into an archived workspace') + } + + await db + .update(workspaceFiles) + .set({ deletedAt: null }) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace') + ) + ) + + logger.info(`Successfully restored workspace file: ${fileRecord.name}`) +} diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 4de83dac6eb..c5ecbb639c7 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -231,6 +231,79 @@ export async function archiveWorkflow( } } +interface RestoreWorkflowOptions { + requestId: string +} + +export async function restoreWorkflow( + workflowId: string, + options: RestoreWorkflowOptions +): Promise<{ restored: boolean; workflow: Awaited> | null }> { + const existingWorkflow = await getWorkflowById(workflowId, { includeArchived: true }) + + if (!existingWorkflow) { + return { restored: false, workflow: null } + } + + if (!existingWorkflow.archivedAt) { + return { restored: false, workflow: existingWorkflow } + } + + if (existingWorkflow.workspaceId) { + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(existingWorkflow.workspaceId) + if (!ws || ws.archivedAt) { + throw new Error('Cannot restore workflow into an archived workspace') + } + } + + const now = new Date() + + await db.transaction(async (tx) => { + await tx + .update(workflow) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(workflow.id, workflowId)) + + await tx + .update(workflowSchedule) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(workflowSchedule.workflowId, workflowId)) + + await tx + .update(webhook) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(webhook.workflowId, workflowId)) + + await tx + .update(chat) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(chat.workflowId, workflowId)) + + await tx + .update(form) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(form.workflowId, workflowId)) + + await tx + .update(workflowMcpTool) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(workflowMcpTool.workflowId, workflowId)) + + await tx + .update(a2aAgent) + .set({ archivedAt: null, updatedAt: now }) + .where(eq(a2aAgent.workflowId, workflowId)) + }) + + logger.info(`[${options.requestId}] Restored workflow ${workflowId}`) + + return { + restored: true, + workflow: await getWorkflowById(workflowId), + } +} + export async function archiveWorkflows( workflowIds: string[], options: ArchiveWorkflowOptions From 67011c996ee2294d54c88664db144398b43828f9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 17:55:13 -0700 Subject: [PATCH 2/6] Derive toast from notification --- apps/sim/app/_styles/globals.css | 21 -- .../app/workspace/[workspaceId]/layout.tsx | 5 +- .../settings/[section]/settings.tsx | 10 +- .../recently-deleted/recently-deleted.tsx | 2 + .../notifications/notifications.tsx | 300 ++++-------------- .../emcn/components/toast/toast.tsx | 290 ++++++++++++----- 6 files changed, 295 insertions(+), 333 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index e6feaef5684..a50c64c31a9 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -700,27 +700,6 @@ input[type="search"]::-ms-clear { } } -@keyframes toast-enter { - from { - opacity: 0; - transform: translateY(8px) scale(0.97); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes toast-exit { - from { - opacity: 1; - transform: translateY(0) scale(1); - } - to { - opacity: 0; - transform: translateY(8px) scale(0.97); - } -} /** * @depricated diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index de24323dd85..5abbde90ab6 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -1,5 +1,6 @@ 'use client' +import { ToastProvider } from '@/components/emcn' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' @@ -8,7 +9,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return ( - <> + @@ -25,6 +26,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
- + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index e561302be54..41fe0eb890a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic' import { useSearchParams } from 'next/navigation' -import { Skeleton, ToastProvider } from '@/components/emcn' +import { Skeleton } from '@/components/emcn' import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton' import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton' @@ -165,9 +165,8 @@ export function SettingsPage({ section }: SettingsPageProps) { allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection return ( - -
-

{label}

+
+

{label}

{effectiveSection === 'general' && } {effectiveSection === 'integrations' && } {effectiveSection === 'secrets' && } @@ -187,7 +186,6 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'inbox' && } {effectiveSection === 'recently-deleted' && } {effectiveSection === 'debug' && } -
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index c54612a9d24..44c60dc85a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -5,6 +5,7 @@ import { Loader2, Search } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Button, + Check, SModalTabs, SModalTabsList, SModalTabsTrigger, @@ -193,6 +194,7 @@ export function RecentlyDeleted() { const onSuccess = () => { const href = getResourceHref(resource.workspaceId, resource.type, resource.id) toast.success(`${resource.name} restored`, { + icon: , action: { label: 'View', onClick: () => router.push(href) }, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index 0dd388178d4..db46e4dd540 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -1,10 +1,8 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' -import { X } from 'lucide-react' -import { Button, Tooltip } from '@/components/emcn' +import { toast, useToast } from '@/components/emcn' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' -import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { type Notification, type NotificationAction, @@ -14,13 +12,6 @@ import { import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Notifications') -const MAX_VISIBLE_NOTIFICATIONS = 4 -const STACK_OFFSET_PX = 3 -const AUTO_DISMISS_MS = 10000 -const EXIT_ANIMATION_MS = 200 - -const RING_RADIUS = 5.5 -const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS const ACTION_LABELS: Record = { copilot: 'Fix in Copilot', @@ -28,120 +19,99 @@ const ACTION_LABELS: Record = { 'unlock-workflow': 'Unlock Workflow', } as const -function isAutoDismissable(n: Notification): boolean { - return n.level === 'error' && !!n.workflowId +function executeNotificationAction(action: NotificationAction) { + switch (action.type) { + case 'copilot': + openCopilotWithMessage(action.message) + break + case 'refresh': + window.location.reload() + break + case 'unlock-workflow': + window.dispatchEvent(new CustomEvent('unlock-workflow')) + break + default: + logger.warn('Unknown action type', { actionType: action.type }) + } } -function CountdownRing({ onPause }: { onPause: () => void }) { - return ( - - - - - -

Keep visible

-
-
- ) +function notificationToToast(n: Notification, removeNotification: (id: string) => void) { + const toastAction = n.action + ? { + label: ACTION_LABELS[n.action.type] ?? 'Take action', + onClick: () => { + executeNotificationAction(n.action!) + removeNotification(n.id) + }, + } + : undefined + + return { + message: n.message, + variant: n.level === 'error' ? ('error' as const) : ('default' as const), + action: toastAction, + duration: n.level === 'error' && n.workflowId ? 10_000 : 0, + } } /** - * Notifications display component. - * Positioned in the bottom-right workspace area, reactive to panel width and terminal height. - * Shows both global notifications and workflow-specific notifications. + * Headless bridge that syncs the notification Zustand store into the toast system. * - * Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown - * ring. Clicking the ring pauses all timers until the notification stack clears. + * Watches for new notifications scoped to the active workflow and shows them as toasts. + * When a toast is dismissed, the corresponding notification is removed from the store. */ export const Notifications = memo(function Notifications() { const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) - const allNotifications = useNotificationStore((state) => state.notifications) const removeNotification = useNotificationStore((state) => state.removeNotification) const clearNotifications = useNotificationStore((state) => state.clearNotifications) + const { dismissAll } = useToast() - const visibleNotifications = useMemo(() => { - if (!activeWorkflowId) return [] - return allNotifications - .filter((n) => !n.workflowId || n.workflowId === activeWorkflowId) - .slice(0, MAX_VISIBLE_NOTIFICATIONS) - }, [allNotifications, activeWorkflowId]) + const shownIdsRef = useRef(new Set()) - /** - * Executes a notification action and handles side effects. - * - * @param notificationId - The ID of the notification whose action is executed. - * @param action - The action configuration to execute. - */ - const executeAction = useCallback( - (notificationId: string, action: NotificationAction) => { - try { - logger.info('Executing notification action', { - notificationId, - actionType: action.type, - messageLength: action.message.length, - }) + const showNotification = useCallback( + (n: Notification) => { + if (shownIdsRef.current.has(n.id)) return + shownIdsRef.current.add(n.id) - switch (action.type) { - case 'copilot': - openCopilotWithMessage(action.message) - break - case 'refresh': - window.location.reload() - break - case 'unlock-workflow': - window.dispatchEvent(new CustomEvent('unlock-workflow')) - break - default: - logger.warn('Unknown action type', { notificationId, actionType: action.type }) - } + const input = notificationToToast(n, removeNotification) + toast(input) - removeNotification(notificationId) - } catch (error) { - logger.error('Failed to execute notification action', { - notificationId, - actionType: action.type, - error, - }) - } + logger.info('Notification shown as toast', { + id: n.id, + level: n.level, + workflowId: n.workflowId, + }) }, [removeNotification] ) + useEffect(() => { + if (!activeWorkflowId) return + + const visible = allNotifications.filter( + (n) => !n.workflowId || n.workflowId === activeWorkflowId + ) + + for (const n of visible) { + showNotification(n) + } + + const currentIds = new Set(allNotifications.map((n) => n.id)) + for (const id of shownIdsRef.current) { + if (!currentIds.has(id)) { + shownIdsRef.current.delete(id) + } + } + }, [allNotifications, activeWorkflowId, showNotification]) + useRegisterGlobalCommands(() => createCommands([ { id: 'clear-notifications', handler: () => { clearNotifications(activeWorkflowId ?? undefined) + dismissAll() }, overrides: { allowInEditable: false, @@ -150,135 +120,5 @@ export const Notifications = memo(function Notifications() { ]) ) - const preventZoomRef = usePreventZoom() - - const [isPaused, setIsPaused] = useState(false) - const [exitingIds, setExitingIds] = useState>(new Set()) - const timersRef = useRef(new Map>()) - - const pauseAll = useCallback(() => { - setIsPaused(true) - setExitingIds(new Set()) - for (const timer of timersRef.current.values()) clearTimeout(timer) - timersRef.current.clear() - }, []) - - /** - * Manages per-notification dismiss timers. - * Resets pause state when the notification stack empties so new arrivals get fresh timers. - */ - useEffect(() => { - if (visibleNotifications.length === 0) { - if (isPaused) setIsPaused(false) - return - } - if (isPaused) return - - const timers = timersRef.current - const activeIds = new Set() - - for (const n of visibleNotifications) { - if (!isAutoDismissable(n) || timers.has(n.id)) continue - activeIds.add(n.id) - - timers.set( - n.id, - setTimeout(() => { - timers.delete(n.id) - setExitingIds((prev) => new Set(prev).add(n.id)) - setTimeout(() => { - removeNotification(n.id) - setExitingIds((prev) => { - const next = new Set(prev) - next.delete(n.id) - return next - }) - }, EXIT_ANIMATION_MS) - }, AUTO_DISMISS_MS) - ) - } - - for (const [id, timer] of timers) { - if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) { - clearTimeout(timer) - timers.delete(id) - } - } - }, [visibleNotifications, removeNotification, isPaused]) - - useEffect(() => { - const timers = timersRef.current - return () => { - for (const timer of timers.values()) clearTimeout(timer) - } - }, []) - - if (visibleNotifications.length === 0) { - return null - } - - return ( -
- {[...visibleNotifications].reverse().map((notification, index, stacked) => { - const depth = stacked.length - index - 1 - const xOffset = depth * STACK_OFFSET_PX - const hasAction = Boolean(notification.action) - const showCountdown = !isPaused && isAutoDismissable(notification) - - return ( -
-
-
-
- {notification.level === 'error' && ( - - )} - {notification.message} -
-
- {showCountdown && } - - - - - - Clear all - - -
-
- {hasAction && ( - - )} -
-
- ) - })} -
- ) + return null }) diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index c392ec38a47..371a327aa0a 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -1,21 +1,29 @@ 'use client' import { + type ReactElement, type ReactNode, createContext, + memo, useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' -import { cn } from '@/lib/core/utils/cn' +import { Button } from '@/components/emcn/components/button/button' +import { Tooltip } from '@/components/emcn/components/tooltip/tooltip' -const AUTO_DISMISS_MS = 0 +const AUTO_DISMISS_MS = 10_000 const EXIT_ANIMATION_MS = 200 -const MAX_VISIBLE = 20 +const MAX_VISIBLE = 4 +const STACK_OFFSET_PX = 3 + +const RING_RADIUS = 5.5 +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS type ToastVariant = 'default' | 'success' | 'error' @@ -27,16 +35,17 @@ interface ToastAction { interface ToastData { id: string message: string - description?: string variant: ToastVariant + icon?: ReactElement action?: ToastAction duration: number + createdAt: number } type ToastInput = { message: string - description?: string variant?: ToastVariant + icon?: ReactElement action?: ToastAction duration?: number } @@ -50,6 +59,7 @@ type ToastFn = { interface ToastContextValue { toast: ToastFn dismiss: (id: string) => void + dismissAll: () => void } const ToastContext = createContext(null) @@ -89,85 +99,131 @@ export function useToast() { return ctx } -const VARIANT_STYLES: Record = { - default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]', - success: - 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200', - error: - 'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200', +function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) { + return ( + + + + + +

Keep visible

+
+
+ ) } -function ToastItem({ - toast: t, +const ToastItem = memo(function ToastItem({ + data, + depth, + isExiting, + showCountdown, onDismiss, + onPauseCountdown, + onAction, }: { - toast: ToastData + data: ToastData + depth: number + isExiting: boolean + showCountdown: boolean onDismiss: (id: string) => void + onPauseCountdown: () => void + onAction: (id: string) => void }) { - const [exiting, setExiting] = useState(false) - const timerRef = useRef>(undefined) - - const dismiss = useCallback(() => { - setExiting(true) - setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS) - }, [onDismiss, t.id]) - - useEffect(() => { - if (t.duration > 0) { - timerRef.current = setTimeout(dismiss, t.duration) - return () => clearTimeout(timerRef.current) - } - }, [dismiss, t.duration]) + const xOffset = depth * STACK_OFFSET_PX return (
-
-

{t.message}

- {t.description && ( -

{t.description}

+
+
+ {data.icon && ( + {data.icon} + )} +
+ {data.variant === 'error' && ( + + )} + {data.message} +
+
+ {showCountdown && ( + + )} + +
+
+ {data.action && ( + )}
- {t.action && ( - - )} -
) -} +}) /** * Toast container that renders toasts via portal. - * Mount once in your root layout. + * Mount once where you want toasts to appear. Renders stacked cards in the bottom-right. * - * @example - * ```tsx - * - * ``` + * Visual design matches the workflow notification component: 240px cards, stacked with + * offset, countdown ring on auto-dismissing items, enter/exit animations. */ export function ToastProvider({ children }: { children?: ReactNode }) { const [toasts, setToasts] = useState([]) const [mounted, setMounted] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [exitingIds, setExitingIds] = useState>(new Set()) + const timersRef = useRef(new Map>()) useEffect(() => { setMounted(true) @@ -178,17 +234,87 @@ export function ToastProvider({ children }: { children?: ReactNode }) { const data: ToastData = { id, message: input.message, - description: input.description, variant: input.variant ?? 'default', + icon: input.icon, action: input.action, duration: input.duration ?? AUTO_DISMISS_MS, + createdAt: Date.now(), } - setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE)) + setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE)) return id }, []) const dismissToast = useCallback((id: string) => { - setToasts((prev) => prev.filter((t) => t.id !== id)) + setExitingIds((prev) => new Set(prev).add(id)) + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + }, EXIT_ANIMATION_MS) + }, []) + + const dismissAll = useCallback(() => { + setToasts([]) + setExitingIds(new Set()) + for (const timer of timersRef.current.values()) clearTimeout(timer) + timersRef.current.clear() + }, []) + + const pauseAll = useCallback(() => { + setIsPaused(true) + setExitingIds(new Set()) + for (const timer of timersRef.current.values()) clearTimeout(timer) + timersRef.current.clear() + }, []) + + const handleAction = useCallback( + (id: string) => { + const t = toasts.find((toast) => toast.id === id) + if (t?.action) { + t.action.onClick() + dismissToast(id) + } + }, + [toasts, dismissToast] + ) + + useEffect(() => { + if (toasts.length === 0) { + if (isPaused) setIsPaused(false) + return + } + if (isPaused) return + + const timers = timersRef.current + + for (const t of toasts) { + if (t.duration <= 0 || timers.has(t.id)) continue + + timers.set( + t.id, + setTimeout(() => { + timers.delete(t.id) + dismissToast(t.id) + }, t.duration) + ) + } + + for (const [id, timer] of timers) { + if (!toasts.some((t) => t.id === id)) { + clearTimeout(timer) + timers.delete(id) + } + } + }, [toasts, isPaused, dismissToast]) + + useEffect(() => { + const timers = timersRef.current + return () => { + for (const timer of timers.values()) clearTimeout(timer) + } }, []) const toastFn = useRef(createToastFn(addToast)) @@ -201,21 +327,37 @@ export function ToastProvider({ children }: { children?: ReactNode }) { } }, [addToast]) - const ctx: ToastContextValue = { toast: toastFn.current, dismiss: dismissToast } + const ctx = useMemo( + () => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }), + [dismissToast, dismissAll] + ) + + const visibleToasts = toasts.slice(0, MAX_VISIBLE) return ( {children} {mounted && + visibleToasts.length > 0 && createPortal( -
- {toasts.map((t) => ( - - ))} +
+ {[...visibleToasts].reverse().map((t, index, stacked) => { + const depth = stacked.length - index - 1 + const showCountdown = !isPaused && t.duration > 0 + + return ( + + ) + })}
, document.body )} From 1a46796d5a8f9486e590c1e65a740e6a02c3d2db Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 17:58:20 -0700 Subject: [PATCH 3/6] Auth user if workspaceid not found --- apps/sim/app/_styles/globals.css | 33 ---------- .../app/api/workflows/[id]/restore/route.ts | 2 + .../emcn/components/toast/toast.tsx | 61 ++++++++++++------- apps/sim/lib/knowledge/service.ts | 2 +- 4 files changed, 42 insertions(+), 56 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index a50c64c31a9..3de43832e54 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -666,39 +666,6 @@ input[type="search"]::-ms-clear { } } -/** - * Notification toast enter animation — pop-open with stack offset - */ -@keyframes notification-enter { - from { - opacity: 0; - transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); - } - to { - opacity: 1; - transform: translateX(var(--stack-offset, 0px)) scale(1); - } -} - -@keyframes notification-countdown { - from { - stroke-dashoffset: 0; - } - to { - stroke-dashoffset: 34.56; - } -} - -@keyframes notification-exit { - from { - opacity: 1; - transform: translateX(var(--stack-offset, 0px)) scale(1); - } - to { - opacity: 0; - transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); - } -} /** diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index d50f95dea27..7e8a76e8a35 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -32,6 +32,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (permission !== 'admin' && permission !== 'write') { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + } else if (workflowData.userId !== auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const result = await restoreWorkflow(workflowId, { requestId }) diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index 371a327aa0a..185e569fe9e 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -25,6 +25,20 @@ const STACK_OFFSET_PX = 3 const RING_RADIUS = 5.5 const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS +const TOAST_KEYFRAMES = ` +@keyframes toast-enter { + from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); } + to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); } +} +@keyframes toast-exit { + from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); } + to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); } +} +@keyframes toast-countdown { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; } +}` + type ToastVariant = 'default' | 'success' | 'error' interface ToastAction { @@ -127,7 +141,7 @@ function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: ( strokeLinecap='round' strokeDasharray={RING_CIRCUMFERENCE} style={{ - animation: `notification-countdown ${durationMs}ms linear forwards`, + animation: `toast-countdown ${durationMs}ms linear forwards`, }} /> @@ -165,8 +179,8 @@ const ToastItem = memo(function ToastItem({ { '--stack-offset': `${xOffset}px`, animation: isExiting - ? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards` - : 'notification-enter 200ms ease-out forwards', + ? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards` + : 'toast-enter 200ms ease-out forwards', gridArea: '1 / 1', } as React.CSSProperties } @@ -340,25 +354,28 @@ export function ToastProvider({ children }: { children?: ReactNode }) { {mounted && visibleToasts.length > 0 && createPortal( -
- {[...visibleToasts].reverse().map((t, index, stacked) => { - const depth = stacked.length - index - 1 - const showCountdown = !isPaused && t.duration > 0 - - return ( - - ) - })} -
, + <> + +
+ {[...visibleToasts].reverse().map((t, index, stacked) => { + const depth = stacked.length - index - 1 + const showCountdown = !isPaused && t.duration > 0 + + return ( + + ) + })} +
+ , document.body )} diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index cb4169e5705..ea24e7ceeac 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -425,7 +425,7 @@ export async function restoreKnowledgeBase( await tx .update(knowledgeConnector) - .set({ archivedAt: null, updatedAt: now }) + .set({ archivedAt: null, status: 'active', updatedAt: now }) .where( and( eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), From 9e86e3d7eb9532183dc2e911aa82018dd744979d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 18:25:50 -0700 Subject: [PATCH 4/6] Fix recently deleted ui --- .../components/recently-deleted/recently-deleted.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 44c60dc85a1..b44376d6de4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -10,7 +10,6 @@ import { SModalTabsList, SModalTabsTrigger, toast, - Undo, } from '@/components/emcn' import { Input } from '@/components/ui' import { formatDate } from '@/lib/core/utils/formatting' @@ -242,7 +241,7 @@ export function RecentlyDeleted() { value={activeTab} onValueChange={(v) => setActiveTab(v as ResourceType)} > - + {TABS.map((tab) => ( {tab.label} @@ -287,7 +286,7 @@ export function RecentlyDeleted() {
) From 1967109ab68dfd6f76a0fa7be5cd25a29b6e44cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 18:34:50 -0700 Subject: [PATCH 5/6] Add restore error toast --- .../components/recently-deleted/recently-deleted.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index b44376d6de4..5eec49028c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -198,20 +198,24 @@ export function RecentlyDeleted() { }) } + const onError = () => { + toast.error(`Failed to restore ${resource.name}`) + } + switch (resource.type) { case 'workflow': - restoreWorkflow.mutate(resource.id, { onSettled, onSuccess }) + restoreWorkflow.mutate(resource.id, { onSettled, onSuccess, onError }) break case 'table': - restoreTable.mutate(resource.id, { onSettled, onSuccess }) + restoreTable.mutate(resource.id, { onSettled, onSuccess, onError }) break case 'knowledge': - restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess }) + restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess, onError }) break case 'file': restoreWorkspaceFile.mutate( { workspaceId: resource.workspaceId, fileId: resource.id }, - { onSettled, onSuccess } + { onSettled, onSuccess, onError } ) break } From aab13816fe73b271b7bc9711cdacc7fdad26e246 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 13 Mar 2026 18:48:57 -0700 Subject: [PATCH 6/6] Fix deleted at timestamp mismatch --- .../settings/components/recently-deleted/recently-deleted.tsx | 4 ++-- apps/sim/components/emcn/components/toast/toast.tsx | 2 +- apps/sim/hooks/queries/workflows.ts | 1 + apps/sim/lib/knowledge/service.ts | 4 ++++ apps/sim/lib/knowledge/types.ts | 2 ++ apps/sim/stores/workflows/registry/types.ts | 1 + 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 5eec49028c9..2ebc482508c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -128,7 +128,7 @@ export function RecentlyDeleted() { id: wf.id, name: wf.name, type: 'workflow', - deletedAt: new Date(wf.lastModified), + deletedAt: wf.archivedAt ? new Date(wf.archivedAt) : new Date(wf.lastModified), workspaceId: wf.workspaceId ?? workspaceId, color: wf.color, }) @@ -149,7 +149,7 @@ export function RecentlyDeleted() { id: kb.id, name: kb.name, type: 'knowledge', - deletedAt: new Date(kb.updatedAt), + deletedAt: kb.deletedAt ? new Date(kb.deletedAt) : new Date(kb.updatedAt), workspaceId: kb.workspaceId ?? workspaceId, }) } diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index 185e569fe9e..c9f75f3c46a 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -189,7 +189,7 @@ const ToastItem = memo(function ToastItem({
{data.icon && ( - {data.icon} + {data.icon} )}
{data.variant === 'error' && ( diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index d3181becfb2..88d19f2cb3f 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -76,6 +76,7 @@ function mapWorkflow(workflow: any): WorkflowMetadata { sortOrder: workflow.sortOrder ?? 0, createdAt: new Date(workflow.createdAt), lastModified: new Date(workflow.updatedAt || workflow.createdAt), + archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null, } } diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index ea24e7ceeac..271d4934d0d 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -41,6 +41,7 @@ export async function getKnowledgeBases( chunkingConfig: knowledgeBase.chunkingConfig, createdAt: knowledgeBase.createdAt, updatedAt: knowledgeBase.updatedAt, + deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, docCount: count(document.id), }) @@ -171,6 +172,7 @@ export async function createKnowledgeBase( chunkingConfig: data.chunkingConfig, createdAt: now, updatedAt: now, + deletedAt: null, workspaceId: data.workspaceId, docCount: 0, connectorTypes: [], @@ -237,6 +239,7 @@ export async function updateKnowledgeBase( chunkingConfig: knowledgeBase.chunkingConfig, createdAt: knowledgeBase.createdAt, updatedAt: knowledgeBase.updatedAt, + deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, docCount: count(document.id), }) @@ -286,6 +289,7 @@ export async function getKnowledgeBaseById( chunkingConfig: knowledgeBase.chunkingConfig, createdAt: knowledgeBase.createdAt, updatedAt: knowledgeBase.updatedAt, + deletedAt: knowledgeBase.deletedAt, workspaceId: knowledgeBase.workspaceId, docCount: count(document.id), }) diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts index f86fb1dc707..b761597c790 100644 --- a/apps/sim/lib/knowledge/types.ts +++ b/apps/sim/lib/knowledge/types.ts @@ -26,6 +26,7 @@ export interface KnowledgeBaseWithCounts { chunkingConfig: ChunkingConfig createdAt: Date updatedAt: Date + deletedAt: Date | null workspaceId: string | null docCount: number connectorTypes: string[] @@ -126,6 +127,7 @@ export interface KnowledgeBaseData { chunkingConfig: ExtendedChunkingConfig createdAt: string updatedAt: string + deletedAt?: string | null workspaceId?: string connectorTypes?: string[] } diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 3c49bf23362..a6bec226690 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -27,6 +27,7 @@ export interface WorkflowMetadata { workspaceId?: string folderId?: string | null sortOrder: number + archivedAt?: Date | null } export type HydrationPhase =