diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index 09157d7de..153689912 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -5,7 +5,6 @@ import { GithubIcon, GoogleIcon } from '@/components/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { client } from '@/lib/auth-client' -import { useNotificationStore } from '@/stores/notifications/store' interface SocialLoginButtonsProps { githubAvailable: boolean @@ -22,7 +21,6 @@ export function SocialLoginButtons({ }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) - const { addNotification } = useNotificationStore() const [mounted, setMounted] = useState(false) // Set mounted state to true on client-side @@ -57,8 +55,6 @@ export function SocialLoginButtons({ } else if (err.message?.includes('rate limit')) { errorMessage = 'Too many attempts. Please try again later.' } - - addNotification('error', errorMessage, null) } finally { setIsGithubLoading(false) } @@ -89,8 +85,6 @@ export function SocialLoginButtons({ } else if (err.message?.includes('rate limit')) { errorMessage = 'Too many attempts. Please try again later.' } - - addNotification('error', errorMessage, null) } finally { setIsGoogleLoading(false) } diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx index d448a4eae..cdc4f93b9 100644 --- a/apps/sim/app/(auth)/layout.tsx +++ b/apps/sim/app/(auth)/layout.tsx @@ -3,7 +3,6 @@ import Image from 'next/image' import Link from 'next/link' import { GridPattern } from '../(landing)/components/grid-pattern' -import { NotificationList } from '../workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications' export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( @@ -31,11 +30,6 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
{children}
- - {/* Notifications */} -
- -
) } diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index b800cbd57..7b116d036 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -5,7 +5,6 @@ import { useRouter, useSearchParams } from 'next/navigation' import { client } from '@/lib/auth-client' import { env, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' -import { useNotificationStore } from '@/stores/notifications/store' const logger = createLogger('useVerification') @@ -35,7 +34,6 @@ export function useVerification({ }: UseVerificationParams): UseVerificationReturn { const router = useRouter() const searchParams = useSearchParams() - const { addNotification } = useNotificationStore() const [otp, setOtp] = useState('') const [email, setEmail] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -46,13 +44,6 @@ export function useVerification({ const [redirectUrl, setRedirectUrl] = useState(null) const [isInviteFlow, setIsInviteFlow] = useState(false) - // Debug notification store - useEffect(() => { - logger.info('Notification store state:', { - addNotification: !!addNotification, - }) - }, [addNotification]) - useEffect(() => { if (typeof window !== 'undefined') { // Get stored email diff --git a/apps/sim/app/api/marketplace/[id]/info/route.ts b/apps/sim/app/api/marketplace/[id]/info/route.ts deleted file mode 100644 index 6df13ec52..000000000 --- a/apps/sim/app/api/marketplace/[id]/info/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { db } from '@/db' -import { marketplace } from '@/db/schema' - -const logger = createLogger('MarketplaceInfoAPI') - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - - // Validate access to the workflow - const validation = await validateWorkflowAccess(request, id, false) - if (validation.error) { - logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) - return createErrorResponse(validation.error.message, validation.error.status) - } - - // Fetch marketplace data for the workflow - const marketplaceEntry = await db - .select() - .from(marketplace) - .where(eq(marketplace.workflowId, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found for workflow: ${id}`) - return createErrorResponse('Workflow is not published to marketplace', 404) - } - - logger.info(`[${requestId}] Retrieved marketplace info for workflow: ${id}`) - - return createSuccessResponse({ - id: marketplaceEntry.id, - name: marketplaceEntry.name, - description: marketplaceEntry.description, - category: marketplaceEntry.category, - authorName: marketplaceEntry.authorName, - views: marketplaceEntry.views, - createdAt: marketplaceEntry.createdAt, - updatedAt: marketplaceEntry.updatedAt, - }) - } catch (error) { - logger.error( - `[${requestId}] Error getting marketplace info for workflow: ${(await params).id}`, - error - ) - return createErrorResponse('Failed to get marketplace information', 500) - } -} diff --git a/apps/sim/app/api/marketplace/[id]/unpublish/route.ts b/apps/sim/app/api/marketplace/[id]/unpublish/route.ts deleted file mode 100644 index c36339195..000000000 --- a/apps/sim/app/api/marketplace/[id]/unpublish/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console-logger' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { db } from '@/db' -import { marketplace, workflow } from '@/db/schema' - -const logger = createLogger('MarketplaceUnpublishAPI') - -/** - * API endpoint to unpublish a workflow from the marketplace by its marketplace ID - * - * Security: - * - Requires authentication - * - Validates that the current user is the author of the marketplace entry - * - Only allows the owner to unpublish - */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - - // Get the session first for authorization - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized unpublish attempt for marketplace ID: ${id}`) - return createErrorResponse('Unauthorized', 401) - } - - const userId = session.user.id - - // Get the marketplace entry using the marketplace ID - const marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - authorId: marketplace.authorId, - name: marketplace.name, - }) - .from(marketplace) - .where(eq(marketplace.id, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`) - return createErrorResponse('Marketplace entry not found', 404) - } - - // Check if the user is the author of the marketplace entry - if (marketplaceEntry.authorId !== userId) { - logger.warn( - `[${requestId}] User ${userId} tried to unpublish marketplace entry they don't own: ${id}, author: ${marketplaceEntry.authorId}` - ) - return createErrorResponse('You do not have permission to unpublish this workflow', 403) - } - - const workflowId = marketplaceEntry.workflowId - - // Verify the workflow exists and belongs to the user - const workflowEntry = await db - .select({ - id: workflow.id, - userId: workflow.userId, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - .then((rows) => rows[0]) - - if (!workflowEntry) { - logger.warn(`[${requestId}] Associated workflow not found: ${workflowId}`) - // We'll still delete the marketplace entry even if the workflow is missing - } else if (workflowEntry.userId !== userId) { - logger.warn( - `[${requestId}] Workflow ${workflowId} belongs to user ${workflowEntry.userId}, not current user ${userId}` - ) - return createErrorResponse('You do not have permission to unpublish this workflow', 403) - } - - try { - // Delete the marketplace entry - this is the primary action - await db.delete(marketplace).where(eq(marketplace.id, id)) - - // Update the workflow to mark it as unpublished if it exists - if (workflowEntry) { - await db.update(workflow).set({ isPublished: false }).where(eq(workflow.id, workflowId)) - } - - logger.info( - `[${requestId}] Workflow "${marketplaceEntry.name}" unpublished from marketplace: ID=${id}, workflowId=${workflowId}` - ) - - return createSuccessResponse({ - success: true, - message: 'Workflow successfully unpublished from marketplace', - }) - } catch (dbError) { - logger.error(`[${requestId}] Database error unpublishing marketplace entry:`, dbError) - return createErrorResponse('Failed to unpublish workflow due to a database error', 500) - } - } catch (error) { - logger.error(`[${requestId}] Error unpublishing marketplace entry: ${(await params).id}`, error) - return createErrorResponse('Failed to unpublish workflow', 500) - } -} diff --git a/apps/sim/app/api/marketplace/[id]/view/route.ts b/apps/sim/app/api/marketplace/[id]/view/route.ts deleted file mode 100644 index 4a4e51286..000000000 --- a/apps/sim/app/api/marketplace/[id]/view/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { eq, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { db } from '@/db' -import { marketplace } from '@/db/schema' - -const logger = createLogger('MarketplaceViewAPI') - -/** - * POST handler for incrementing the view count when a workflow card is clicked - * This endpoint is called from the WorkflowCard component's onClick handler - * - * The ID parameter is the marketplace entry ID, not the workflow ID - */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const { id } = await params - - // Find the marketplace entry for this marketplace ID - const marketplaceEntry = await db - .select({ - id: marketplace.id, - }) - .from(marketplace) - .where(eq(marketplace.id, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`) - return createErrorResponse('Marketplace entry not found', 404) - } - - // Increment the view count for this workflow - await db - .update(marketplace) - .set({ - views: sql`${marketplace.views} + 1`, - }) - .where(eq(marketplace.id, id)) - - logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`) - - return createSuccessResponse({ - success: true, - }) - } catch (error) { - logger.error( - `[${requestId}] Error incrementing view count for marketplace entry: ${(await params).id}`, - error - ) - return createErrorResponse('Failed to track view', 500) - } -} diff --git a/apps/sim/app/api/marketplace/publish/route.ts b/apps/sim/app/api/marketplace/publish/route.ts deleted file mode 100644 index 381d065b6..000000000 --- a/apps/sim/app/api/marketplace/publish/route.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { marketplace, user, workflow } from '@/db/schema' - -// Create a logger for this module -const logger = createLogger('MarketplacePublishAPI') - -// No cache -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -// Schema for request body -const PublishRequestSchema = z.object({ - workflowId: z.string().uuid(), - name: z.string().min(3).max(50).optional(), - description: z.string().min(10).max(500).optional(), - category: z.string().min(1).optional(), - authorName: z.string().min(2).max(50).optional(), - workflowState: z.record(z.any()).optional(), -}) - -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - // Get the session directly in the API route - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized marketplace publish attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - try { - // Parse request body - const body = await request.json() - const { workflowId, name, description, category, authorName, workflowState } = - PublishRequestSchema.parse(body) - - // Check if the workflow belongs to the user - const userWorkflow = await db - .select({ id: workflow.id, name: workflow.name, description: workflow.description }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!userWorkflow.length || userWorkflow[0].id !== workflowId) { - logger.warn( - `[${requestId}] User ${userId} attempted to publish workflow they don't own: ${workflowId}` - ) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Get the user's name for attribution - const userData = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - - if (!userData.length) { - logger.error(`[${requestId}] User data not found for ID: ${userId}`) - return NextResponse.json({ error: 'User data not found' }, { status: 500 }) - } - - // Verify we have the workflow state - if (!workflowState) { - logger.error(`[${requestId}] No workflow state provided for ID: ${workflowId}`) - return NextResponse.json({ error: 'Workflow state is required' }, { status: 400 }) - } - - // Check if this workflow is already published - const existingPublication = await db - .select({ id: marketplace.id }) - .from(marketplace) - .where(eq(marketplace.workflowId, workflowId)) - .limit(1) - - let result - const marketplaceId = existingPublication.length ? existingPublication[0].id : uuidv4() - - // Prepare the marketplace entry - const marketplaceEntry = { - id: marketplaceId, - workflowId, - state: workflowState, - name: name || userWorkflow[0].name, - description: description || userWorkflow[0].description || '', - authorId: userId, - authorName: authorName || userData[0].name, - category: category || null, - updatedAt: new Date(), - } - - if (existingPublication.length) { - // Update existing entry - result = await db - .update(marketplace) - .set(marketplaceEntry) - .where(eq(marketplace.id, marketplaceId)) - .returning() - } else { - // Create new entry with createdAt - result = await db - .insert(marketplace) - .values({ - ...marketplaceEntry, - createdAt: new Date(), - views: 0, - }) - .returning() - } - - logger.info(`[${requestId}] Successfully published workflow to marketplace`, { - workflowId, - marketplaceId, - userId, - }) - - return NextResponse.json({ - message: 'Workflow published successfully', - data: { - id: result[0].id, - workflowId: result[0].workflowId, - name: result[0].name, - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid marketplace publish request parameters`, { - errors: validationError.errors, - }) - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationError.errors, - }, - { status: 400 } - ) - } - throw validationError - } - } catch (error: any) { - logger.error(`[${requestId}] Marketplace publish error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/marketplace/workflows/route.ts b/apps/sim/app/api/marketplace/workflows/route.ts deleted file mode 100644 index ad5c66c38..000000000 --- a/apps/sim/app/api/marketplace/workflows/route.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { desc, eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { CATEGORIES } from '@/app/workspace/[workspaceId]/marketplace/constants/categories' -import { db } from '@/db' -import { marketplace } from '@/db/schema' - -const logger = createLogger('MarketplaceWorkflowsAPI') - -// Cache for 1 minute but can be revalidated on-demand -export const revalidate = 60 - -/** - * Consolidated API endpoint for marketplace workflows - * - * Supports: - * - Getting featured/popular/recent workflows - * - Getting workflows by category - * - Getting workflow state - * - Getting workflow details - * - Incrementing view counts - * - * Query parameters: - * - section: 'popular', 'recent', 'byCategory', or specific category name - * - limit: Maximum number of items to return per section (default: 6) - * - includeState: Whether to include workflow state in the response (default: false) - * - workflowId: Specific workflow ID to fetch details for - * - marketplaceId: Specific marketplace entry ID to fetch details for - */ -export async function GET(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - // Parse query parameters - const url = new URL(request.url) - const sectionParam = url.searchParams.get('section') - const categoryParam = url.searchParams.get('category') - const limitParam = url.searchParams.get('limit') || '6' - const limit = Number.parseInt(limitParam, 10) - const includeState = url.searchParams.get('includeState') === 'true' - const workflowId = url.searchParams.get('workflowId') - const marketplaceId = url.searchParams.get('marketplaceId') - - // Handle single workflow request first (by workflow ID) - if (workflowId) { - let marketplaceEntry - - if (includeState) { - // Query with state included - marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - state: marketplace.state, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.workflowId, workflowId)) - .limit(1) - .then((rows) => rows[0]) - } else { - // Query without state - marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.workflowId, workflowId)) - .limit(1) - .then((rows) => rows[0]) - } - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found for workflow: ${workflowId}`) - return createErrorResponse('Workflow not found in marketplace', 404) - } - - // Transform response if state was requested - const responseData = - includeState && 'state' in marketplaceEntry - ? { - ...marketplaceEntry, - workflowState: marketplaceEntry.state, - state: undefined, - } - : marketplaceEntry - - logger.info(`[${requestId}] Retrieved marketplace data for workflow: ${workflowId}`) - return createSuccessResponse(responseData) - } - - // Handle single marketplace entry request (by marketplace ID) - if (marketplaceId) { - let marketplaceEntry - - if (includeState) { - // Query with state included - marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - state: marketplace.state, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.id, marketplaceId)) - .limit(1) - .then((rows) => rows[0]) - } else { - // Query without state - marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - }) - .from(marketplace) - .where(eq(marketplace.id, marketplaceId)) - .limit(1) - .then((rows) => rows[0]) - } - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found with ID: ${marketplaceId}`) - return createErrorResponse('Marketplace entry not found', 404) - } - - // Transform response if state was requested - const responseData = - includeState && 'state' in marketplaceEntry - ? { - ...marketplaceEntry, - workflowState: marketplaceEntry.state, - state: undefined, - } - : marketplaceEntry - - logger.info(`[${requestId}] Retrieved marketplace entry: ${marketplaceId}`) - return createSuccessResponse(responseData) - } - - // Handle featured/collection requests - const result: { - popular: any[] - recent: any[] - byCategory: Record - } = { - popular: [], - recent: [], - byCategory: {}, - } - - // Define common fields to select - const baseFields = { - id: marketplace.id, - workflowId: marketplace.workflowId, - name: marketplace.name, - description: marketplace.description, - authorName: marketplace.authorName, - views: marketplace.views, - category: marketplace.category, - createdAt: marketplace.createdAt, - updatedAt: marketplace.updatedAt, - } - - // Add state if requested - const selectFields = includeState ? { ...baseFields, state: marketplace.state } : baseFields - - // Determine which sections to fetch - const sections = sectionParam ? sectionParam.split(',') : ['popular', 'recent', 'byCategory'] - - // Get popular items if requested - if (sections.includes('popular')) { - result.popular = await db - .select(selectFields) - .from(marketplace) - .orderBy(desc(marketplace.views)) - .limit(limit) - } - - // Get recent items if requested - if (sections.includes('recent')) { - result.recent = await db - .select(selectFields) - .from(marketplace) - .orderBy(desc(marketplace.createdAt)) - .limit(limit) - } - - // Get categories if requested - if ( - sections.includes('byCategory') || - categoryParam || - sections.some((s) => CATEGORIES.some((c) => c.value === s)) - ) { - // Identify all requested categories - const requestedCategories = new Set() - - // Add explicitly requested category - if (categoryParam) { - requestedCategories.add(categoryParam) - } - - // Add categories from sections parameter - sections.forEach((section) => { - if (CATEGORIES.some((c) => c.value === section)) { - requestedCategories.add(section) - } - }) - - // Include byCategory section contents if requested - if (sections.includes('byCategory')) { - CATEGORIES.forEach((c) => requestedCategories.add(c.value)) - } - - // Log what we're fetching - const categoriesToFetch = Array.from(requestedCategories) - logger.info(`[${requestId}] Fetching specific categories: ${categoriesToFetch.join(', ')}`) - - // Process each requested category - await Promise.all( - categoriesToFetch.map(async (categoryValue) => { - const categoryItems = await db - .select(selectFields) - .from(marketplace) - .where(eq(marketplace.category, categoryValue)) - .orderBy(desc(marketplace.views)) - .limit(limit) - - // Always add the category to the result, even if empty - result.byCategory[categoryValue] = categoryItems - logger.info( - `[${requestId}] Category ${categoryValue}: found ${categoryItems.length} items` - ) - }) - ) - } - - // Transform the data if state was included to match the expected format - if (includeState) { - const transformSection = (section: any[]) => { - return section.map((item) => { - if ('state' in item) { - // Create a new object without the state field, but with workflowState - const { state, ...rest } = item - return { - ...rest, - workflowState: state, - } - } - return item - }) - } - - if (result.popular.length > 0) { - result.popular = transformSection(result.popular) - } - - if (result.recent.length > 0) { - result.recent = transformSection(result.recent) - } - - Object.keys(result.byCategory).forEach((category) => { - if (result.byCategory[category].length > 0) { - result.byCategory[category] = transformSection(result.byCategory[category]) - } - }) - } - - logger.info(`[${requestId}] Fetched marketplace items${includeState ? ' with state' : ''}`) - return NextResponse.json(result) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching marketplace items`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) - } -} - -/** - * POST handler for incrementing view counts - * - * Request body: - * - id: Marketplace entry ID to increment view count for - */ -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const body = await request.json() - const { id } = body - - if (!id) { - return createErrorResponse('Marketplace ID is required', 400) - } - - // Find the marketplace entry - const marketplaceEntry = await db - .select({ - id: marketplace.id, - }) - .from(marketplace) - .where(eq(marketplace.id, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - logger.warn(`[${requestId}] No marketplace entry found with ID: ${id}`) - return createErrorResponse('Marketplace entry not found', 404) - } - - // Increment the view count - await db - .update(marketplace) - .set({ - views: sql`${marketplace.views} + 1`, - }) - .where(eq(marketplace.id, id)) - - logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`) - - return createSuccessResponse({ - success: true, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error incrementing view count`, error) - return createErrorResponse(`Failed to track view: ${error.message}`, 500) - } -} diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts new file mode 100644 index 000000000..2cbf89f6a --- /dev/null +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -0,0 +1,65 @@ +import { eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { templates } from '@/db/schema' + +const logger = createLogger('TemplateByIdAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +// GET /api/templates/[id] - Retrieve a single template by ID +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.debug(`[${requestId}] Fetching template: ${id}`) + + // Fetch the template by ID + const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + + if (result.length === 0) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + const template = result[0] + + // Increment the view count + try { + await db + .update(templates) + .set({ + views: sql`${templates.views} + 1`, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + + logger.debug(`[${requestId}] Incremented view count for template: ${id}`) + } catch (viewError) { + // Log the error but don't fail the request + logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) + } + + logger.info(`[${requestId}] Successfully retrieved template: ${id}`) + + return NextResponse.json({ + data: { + ...template, + views: template.views + 1, // Return the incremented view count + }, + }) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/templates/[id]/star/route.ts b/apps/sim/app/api/templates/[id]/star/route.ts new file mode 100644 index 000000000..abf40bae1 --- /dev/null +++ b/apps/sim/app/api/templates/[id]/star/route.ts @@ -0,0 +1,173 @@ +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { templateStars, templates } from '@/db/schema' + +const logger = createLogger('TemplateStarAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +// GET /api/templates/[id]/star - Check if user has starred this template +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.debug( + `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}` + ) + + // Check if the user has starred this template + const starRecord = await db + .select({ id: templateStars.id }) + .from(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) + + const isStarred = starRecord.length > 0 + + logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) + + return NextResponse.json({ data: { isStarred } }) + } catch (error: any) { + logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// POST /api/templates/[id]/star - Add a star to the template +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`) + + // Verify the template exists + const templateExists = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) + + if (templateExists.length === 0) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + // Check if user has already starred this template + const existingStar = await db + .select({ id: templateStars.id }) + .from(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) + + if (existingStar.length > 0) { + logger.info(`[${requestId}] Template already starred: ${id}`) + return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) + } + + // Use a transaction to ensure consistency + await db.transaction(async (tx) => { + // Add the star record + await tx.insert(templateStars).values({ + id: uuidv4(), + userId: session.user.id, + templateId: id, + starredAt: new Date(), + createdAt: new Date(), + }) + + // Increment the star count + await tx + .update(templates) + .set({ + stars: sql`${templates.stars} + 1`, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + }) + + logger.info(`[${requestId}] Successfully starred template: ${id}`) + return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) + } catch (error: any) { + // Handle unique constraint violations gracefully + if (error.code === '23505') { + logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) + return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) + } + + logger.error(`[${requestId}] Error starring template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// DELETE /api/templates/[id]/star - Remove a star from the template +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`) + + // Check if the star exists + const existingStar = await db + .select({ id: templateStars.id }) + .from(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) + + if (existingStar.length === 0) { + logger.info(`[${requestId}] No star found to remove for template: ${id}`) + return NextResponse.json({ message: 'Template not starred' }, { status: 200 }) + } + + // Use a transaction to ensure consistency + await db.transaction(async (tx) => { + // Remove the star record + await tx + .delete(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + + // Decrement the star count (prevent negative values) + await tx + .update(templates) + .set({ + stars: sql`GREATEST(${templates.stars} - 1, 0)`, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + }) + + logger.info(`[${requestId}] Successfully unstarred template: ${id}`) + return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) + } catch (error: any) { + logger.error(`[${requestId}] Error unstarring template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts new file mode 100644 index 000000000..a356b4ab0 --- /dev/null +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -0,0 +1,202 @@ +import { eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { templates, workflow, workflowBlocks, workflowEdges } from '@/db/schema' + +const logger = createLogger('TemplateUseAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +// POST /api/templates/[id]/use - Use a template (increment views and create workflow) +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Get workspace ID from request body + const body = await request.json() + const { workspaceId } = body + + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId in request body`) + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + } + + logger.debug( + `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}` + ) + + // Get the template with its data + const template = await db + .select({ + id: templates.id, + name: templates.name, + description: templates.description, + state: templates.state, + }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) + + if (template.length === 0) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } + + const templateData = template[0] + + // Create a new workflow ID + const newWorkflowId = uuidv4() + + // Use a transaction to ensure consistency + const result = await db.transaction(async (tx) => { + // Increment the template views + await tx + .update(templates) + .set({ + views: sql`${templates.views} + 1`, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + + const now = new Date() + + // Create a new workflow from the template + const newWorkflow = await tx + .insert(workflow) + .values({ + id: newWorkflowId, + workspaceId: workspaceId, + name: `${templateData.name} (copy)`, + description: templateData.description, + state: templateData.state, + userId: session.user.id, + createdAt: now, + updatedAt: now, + lastSynced: now, + }) + .returning({ id: workflow.id }) + + // Create workflow_blocks entries from the template state + const templateState = templateData.state as any + if (templateState?.blocks) { + // Create a mapping from old block IDs to new block IDs for reference updates + const blockIdMap = new Map() + + const blockEntries = Object.values(templateState.blocks).map((block: any) => { + const newBlockId = uuidv4() + blockIdMap.set(block.id, newBlockId) + + return { + id: newBlockId, + workflowId: newWorkflowId, + type: block.type, + name: block.name, + positionX: block.position?.x?.toString() || '0', + positionY: block.position?.y?.toString() || '0', + enabled: block.enabled !== false, + horizontalHandles: block.horizontalHandles !== false, + isWide: block.isWide || false, + advancedMode: block.advancedMode || false, + height: block.height?.toString() || '0', + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + data: block.data || {}, + parentId: block.parentId ? blockIdMap.get(block.parentId) || null : null, + extent: block.extent || null, + createdAt: now, + updatedAt: now, + } + }) + + // Create edge entries with new IDs + const edgeEntries = (templateState.edges || []).map((edge: any) => ({ + id: uuidv4(), + workflowId: newWorkflowId, + sourceBlockId: blockIdMap.get(edge.source) || edge.source, + targetBlockId: blockIdMap.get(edge.target) || edge.target, + sourceHandle: edge.sourceHandle || null, + targetHandle: edge.targetHandle || null, + createdAt: now, + })) + + // Update the workflow state with new block IDs + const updatedState = { ...templateState } + if (updatedState.blocks) { + const newBlocks: any = {} + Object.entries(updatedState.blocks).forEach(([oldId, blockData]: [string, any]) => { + const newId = blockIdMap.get(oldId) + if (newId) { + newBlocks[newId] = { + ...blockData, + id: newId, + } + } + }) + updatedState.blocks = newBlocks + } + + // Update edges to use new block IDs + if (updatedState.edges) { + updatedState.edges = updatedState.edges.map((edge: any) => ({ + ...edge, + id: uuidv4(), + source: blockIdMap.get(edge.source) || edge.source, + target: blockIdMap.get(edge.target) || edge.target, + })) + } + + // Update the workflow with the corrected state + await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId)) + + // Insert blocks and edges + if (blockEntries.length > 0) { + await tx.insert(workflowBlocks).values(blockEntries) + } + if (edgeEntries.length > 0) { + await tx.insert(workflowEdges).values(edgeEntries) + } + } + + return newWorkflow[0] + }) + + logger.info( + `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}, database returned: ${result.id}` + ) + + // Verify the workflow was actually created + const verifyWorkflow = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, newWorkflowId)) + .limit(1) + + if (verifyWorkflow.length === 0) { + logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`) + return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) + } + + return NextResponse.json( + { + message: 'Template used successfully', + workflowId: newWorkflowId, + workspaceId: workspaceId, + }, + { status: 201 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error using template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts new file mode 100644 index 000000000..befcdf63f --- /dev/null +++ b/apps/sim/app/api/templates/route.ts @@ -0,0 +1,260 @@ +import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { templateStars, templates, workflow } from '@/db/schema' + +const logger = createLogger('TemplatesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +// Function to sanitize sensitive data from workflow state +function sanitizeWorkflowState(state: any): any { + const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone + + if (sanitizedState.blocks) { + Object.values(sanitizedState.blocks).forEach((block: any) => { + if (block.subBlocks) { + Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + // Clear OAuth credentials and API keys using regex patterns + if ( + /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) || + /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( + subBlock.type || '' + ) || + /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( + subBlock.value || '' + ) + ) { + subBlock.value = '' + } + }) + } + + // Also clear from data field if present + if (block.data) { + Object.entries(block.data).forEach(([key, value]: [string, any]) => { + if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { + block.data[key] = '' + } + }) + } + }) + } + + return sanitizedState +} + +// Schema for creating a template +const CreateTemplateSchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'), + description: z + .string() + .min(1, 'Description is required') + .max(500, 'Description must be less than 500 characters'), + author: z + .string() + .min(1, 'Author is required') + .max(100, 'Author must be less than 100 characters'), + category: z.string().min(1, 'Category is required'), + icon: z.string().min(1, 'Icon is required'), + color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'), + state: z.object({ + blocks: z.record(z.any()), + edges: z.array(z.any()), + loops: z.record(z.any()), + parallels: z.record(z.any()), + }), +}) + +// Schema for query parameters +const QueryParamsSchema = z.object({ + category: z.string().optional(), + limit: z.coerce.number().optional().default(50), + offset: z.coerce.number().optional().default(0), + search: z.string().optional(), +}) + +// GET /api/templates - Retrieve templates +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized templates access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + + logger.debug(`[${requestId}] Fetching templates with params:`, params) + + // Build query conditions + const conditions = [] + + // Apply category filter if provided + if (params.category) { + conditions.push(eq(templates.category, params.category)) + } + + // Apply search filter if provided + if (params.search) { + const searchTerm = `%${params.search}%` + conditions.push( + or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm)) + ) + } + + // Combine conditions + const whereCondition = conditions.length > 0 ? and(...conditions) : undefined + + // Apply ordering, limit, and offset with star information + const results = await db + .select({ + id: templates.id, + workflowId: templates.workflowId, + userId: templates.userId, + name: templates.name, + description: templates.description, + author: templates.author, + views: templates.views, + stars: templates.stars, + color: templates.color, + icon: templates.icon, + category: templates.category, + state: templates.state, + createdAt: templates.createdAt, + updatedAt: templates.updatedAt, + isStarred: sql`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, + }) + .from(templates) + .leftJoin( + templateStars, + and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) + ) + .where(whereCondition) + .orderBy(desc(templates.views), desc(templates.createdAt)) + .limit(params.limit) + .offset(params.offset) + + // Get total count for pagination + const totalCount = await db + .select({ count: sql`count(*)` }) + .from(templates) + .where(whereCondition) + + const total = totalCount[0]?.count || 0 + + logger.info(`[${requestId}] Successfully retrieved ${results.length} templates`) + + return NextResponse.json({ + data: results, + pagination: { + total, + limit: params.limit, + offset: params.offset, + page: Math.floor(params.offset / params.limit) + 1, + totalPages: Math.ceil(total / params.limit), + }, + }) + } catch (error: any) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid query parameters', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error fetching templates`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// POST /api/templates - Create a new template +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const data = CreateTemplateSchema.parse(body) + + logger.debug(`[${requestId}] Creating template:`, { + name: data.name, + category: data.category, + workflowId: data.workflowId, + }) + + // Verify the workflow exists and belongs to the user + const workflowExists = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, data.workflowId)) + .limit(1) + + if (workflowExists.length === 0) { + logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + // Create the template + const templateId = uuidv4() + const now = new Date() + + // Sanitize the workflow state to remove sensitive credentials + const sanitizedState = sanitizeWorkflowState(data.state) + + const newTemplate = { + id: templateId, + workflowId: data.workflowId, + userId: session.user.id, + name: data.name, + description: data.description || null, + author: data.author, + views: 0, + stars: 0, + color: data.color, + icon: data.icon, + category: data.category, + state: sanitizedState, + createdAt: now, + updatedAt: now, + } + + await db.insert(templates).values(newTemplate) + + logger.info(`[${requestId}] Successfully created template: ${templateId}`) + + return NextResponse.json( + { + id: templateId, + message: 'Template created successfully', + }, + { status: 201 } + ) + } catch (error: any) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid template data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error creating template`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index c93cb3884..668fb9453 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -79,23 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: variablesRecord[variable.id] = variable }) - // Get existing variables to merge with the incoming ones - const existingVariables = (workflowRecord[0].variables as Record) || {} - - // Create a timestamp based on the current request - - // Merge variables: Keep existing ones and update/add new ones - // This prevents variables from being deleted during race conditions - const mergedVariables = { - ...existingVariables, - ...variablesRecord, - } + // Replace variables completely with the incoming ones + // The frontend is the source of truth for what variables should exist + const updatedVariables = variablesRecord // Update workflow with variables await db .update(workflow) .set({ - variables: mergedVariables, + variables: updatedVariables, updatedAt: new Date(), }) .where(eq(workflow.id, workflowId)) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index cb7db1ba0..6c34dc44d 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getUserEntityPermissions } from '@/lib/permissions/utils' import { db } from '@/db' import { permissions, workflow, workflowBlocks, workspace, workspaceMember } from '@/db/schema' @@ -18,6 +19,7 @@ export async function GET() { .select({ workspace: workspace, role: workspaceMember.role, + membershipId: workspaceMember.id, }) .from(workspaceMember) .innerJoin(workspace, eq(workspaceMember.workspaceId, workspace.id)) @@ -37,13 +39,25 @@ export async function GET() { // If user has workspaces but might have orphaned workflows, migrate them await ensureWorkflowsHaveWorkspace(session.user.id, memberWorkspaces[0].workspace.id) - // Format the response - const workspaces = memberWorkspaces.map(({ workspace: workspaceDetails, role }) => ({ - ...workspaceDetails, - role, - })) + // Get permissions for each workspace and format the response + const workspacesWithPermissions = await Promise.all( + memberWorkspaces.map(async ({ workspace: workspaceDetails, role, membershipId }) => { + const userPermissions = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceDetails.id + ) - return NextResponse.json({ workspaces }) + return { + ...workspaceDetails, + role, + membershipId, + permissions: userPermissions, + } + }) + ) + + return NextResponse.json({ workspaces: workspacesWithPermissions }) } // POST /api/workspaces - Create a new workspace diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 6d31b94e6..27d1b85a1 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -2,7 +2,9 @@ @tailwind components; @tailwind utilities; -/* Z-index fixes for workflow components */ +/* ========================================================================== + WORKFLOW COMPONENT Z-INDEX FIXES + ========================================================================== */ .workflow-container .react-flow__edges { z-index: 0 !important; } @@ -24,81 +26,131 @@ z-index: 0 !important; } +/* ========================================================================== + THEME SYSTEM - CSS CUSTOM PROPERTIES + ========================================================================== */ @layer base { - :root { + /* Light Mode Theme */ + :root, + .light { + /* Core Colors */ --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + /* Card Colors */ + --card: 0 0% 99.2%; + --card-foreground: 0 0% 3.9%; + /* Popover Colors */ --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover-foreground: 0 0% 3.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; + /* Primary Colors */ + --primary: 0 0% 11.2%; + --primary-foreground: 0 0% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + /* Secondary Colors */ + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; + /* Muted Colors */ + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + /* Accent Colors */ + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 11.2%; + /* Destructive Colors */ --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --destructive-foreground: 0 0% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + /* Border & Input Colors */ + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + /* Border Radius */ --radius: 0.5rem; - /* Scrollbar Custom Properties */ + /* Scrollbar Properties */ --scrollbar-track: 0 0% 85%; --scrollbar-thumb: 0 0% 65%; --scrollbar-thumb-hover: 0 0% 55%; --scrollbar-size: 8px; + + /* Workflow Properties */ + --workflow-background: 0 0% 100%; + --workflow-dots: 0 0% 94.5%; + --card-background: 0 0% 99.2%; + --card-border: 0 0% 89.8%; + --card-text: 0 0% 3.9%; + --card-hover: 0 0% 96.1%; + + /* Base Component Properties */ + --base-muted-foreground: #737373; } + /* Dark Mode Theme */ .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + /* Core Colors */ + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + /* Card Colors */ + --card: 0 0% 9.0%; + --card-foreground: 0 0% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + /* Popover Colors */ + --popover: 0 0% 9.0%; + --popover-foreground: 0 0% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + /* Primary Colors */ + --primary: 0 0% 98%; + --primary-foreground: 0 0% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + /* Secondary Colors */ + --secondary: 0 0% 12.0%; + --secondary-foreground: 0 0% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; + /* Muted Colors */ + --muted: 0 0% 17.5%; + --muted-foreground: 0 0% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + /* Accent Colors */ + --accent: 0 0% 17.5%; + --accent-foreground: 0 0% 98%; + /* Destructive Colors */ --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive-foreground: 0 0% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; + /* Border & Input Colors */ + --border: 0 0% 22.7%; + --input: 0 0% 22.7%; + --ring: 0 0% 83.9%; - /* Dark Mode Scrollbar Custom Properties */ - --scrollbar-track: 217.2 32.6% 17.5%; - --scrollbar-thumb: 217.2 32.6% 30%; - --scrollbar-thumb-hover: 217.2 32.6% 40%; + /* Scrollbar Properties */ + --scrollbar-track: 0 0% 17.5%; + --scrollbar-thumb: 0 0% 30%; + --scrollbar-thumb-hover: 0 0% 40%; + + /* Workflow Properties */ + --workflow-background: 0 0% 3.9%; + --workflow-dots: 0 0% 7.5%; + --card-background: 0 0% 9.0%; + --card-border: 0 0% 22.7%; + --card-text: 0 0% 98%; + --card-hover: 0 0% 12.0%; + + /* Base Component Properties */ + --base-muted-foreground: #a3a3a3; } } +/* ========================================================================== + BASE STYLES + ========================================================================== */ @layer base { * { @apply border-border; @@ -143,28 +195,137 @@ } } -/* Custom Animations */ +/* ========================================================================== + TYPOGRAPHY + ========================================================================== */ +.font-geist-sans { + font-family: var(--font-geist-sans); +} + +.font-geist-mono { + font-family: var(--font-geist-mono); +} + +/* ========================================================================== + PANEL STYLES + ========================================================================== */ +.panel-tab-base { + color: var(--base-muted-foreground); +} + +.panel-tab-active { + /* Light Mode Panel Tab Active */ + background-color: #f5f5f5; + color: #1a1a1a; + border-color: #e5e5e5; +} + +/* Dark Mode Panel Tab Active */ +.dark .panel-tab-active { + background-color: #1f1f1f; + color: #ffffff; + border-color: #424242; +} + +.panel-tab-inactive:hover { + background-color: hsl(var(--secondary)); + color: hsl(var(--card-foreground)); +} + +/* ========================================================================== + DARK MODE OVERRIDES + ========================================================================== */ +.dark .error-badge { + background-color: hsl(0, 70%, 20%) !important; + /* Darker red background for dark mode */ + color: hsl(0, 0%, 100%) !important; + /* Pure white text for better contrast */ +} + +.dark .bg-red-500 { + @apply bg-red-700; +} + +/* ========================================================================== + BROWSER INPUT OVERRIDES + ========================================================================== */ +/* Chrome, Safari, Edge, Opera */ +input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; +} + +/* Firefox */ +input[type="search"]::-moz-search-cancel-button { + display: none; +} + +/* Microsoft Edge */ +input[type="search"]::-ms-clear { + display: none; +} + +/* ========================================================================== + LAYOUT UTILITIES + ========================================================================== */ +.main-content-overlay { + z-index: 40; + /* Higher z-index to appear above content */ +} + +/* ========================================================================== + ANIMATIONS & UTILITIES + ========================================================================== */ @layer utilities { - /* Animation containment to avoid layout shifts */ + /* Animation Performance */ .animation-container { contain: paint layout style; will-change: opacity, transform; } - @keyframes pulse-ring { - 0% { - box-shadow: 0 0 0 0 hsl(var(--border)); - } - - 50% { - box-shadow: 0 0 0 8px hsl(var(--border)); - } - - 100% { - box-shadow: 0 0 0 0 hsl(var(--border)); - } + /* Scrollbar Utilities */ + .scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; } + .scrollbar-none::-webkit-scrollbar { + display: none; + } + + .scrollbar-hide { + /* For Webkit browsers (Chrome, Safari, Edge) */ + -webkit-scrollbar: none; + -webkit-scrollbar-width: none; + -webkit-scrollbar-track: transparent; + -webkit-scrollbar-thumb: transparent; + + /* For Firefox */ + scrollbar-width: none; + scrollbar-color: transparent transparent; + + /* For Internet Explorer and Edge Legacy */ + -ms-overflow-style: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + background: transparent; + } + + .scrollbar-hide::-webkit-scrollbar-track { + display: none; + background: transparent; + } + + .scrollbar-hide::-webkit-scrollbar-thumb { + display: none; + background: transparent; + } + + /* Animation Classes */ .animate-pulse-ring { animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @@ -175,16 +336,6 @@ transition-duration: 300ms; } - /* Custom Scrollbar Utility Classes */ - .scrollbar-none { - -ms-overflow-style: none; - scrollbar-width: none; - } - - .scrollbar-none::-webkit-scrollbar { - display: none; - } - .animate-orbit { animation: orbit calc(var(--duration, 2) * 1s) linear infinite; } @@ -197,7 +348,58 @@ animation: marquee-vertical var(--duration) linear infinite; } - /* Code Editor Streaming Effect */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out forwards; + } + + /* Special Effects */ + .streaming-effect { + @apply relative overflow-hidden; + } + + .streaming-effect::after { + content: ""; + @apply pointer-events-none absolute left-0 top-0 h-full w-full; + background: linear-gradient( + 90deg, + rgba(128, 128, 128, 0) 0%, + rgba(128, 128, 128, 0.1) 50%, + rgba(128, 128, 128, 0) 100% + ); + animation: code-shimmer 1.5s infinite; + z-index: 10; + } + + .dark .streaming-effect::after { + background: linear-gradient( + 90deg, + rgba(180, 180, 180, 0) 0%, + rgba(180, 180, 180, 0.1) 50%, + rgba(180, 180, 180, 0) 100% + ); + } + + .loading-placeholder::placeholder { + animation: placeholder-pulse 1.5s ease-in-out infinite; + } + + /* ========================================================================== + KEYFRAME ANIMATIONS + ========================================================================== */ + @keyframes pulse-ring { + 0% { + box-shadow: 0 0 0 0 hsl(var(--border)); + } + + 50% { + box-shadow: 0 0 0 8px hsl(var(--border)); + } + + 100% { + box-shadow: 0 0 0 0 hsl(var(--border)); + } + } + @keyframes code-shimmer { 0% { transform: translateX(-100%); @@ -240,32 +442,6 @@ } } - .streaming-effect { - @apply relative overflow-hidden; - } - - .streaming-effect::after { - content: ""; - @apply pointer-events-none absolute left-0 top-0 h-full w-full; - background: linear-gradient( - 90deg, - rgba(128, 128, 128, 0) 0%, - rgba(128, 128, 128, 0.1) 50%, - rgba(128, 128, 128, 0) 100% - ); - animation: code-shimmer 1.5s infinite; - z-index: 10; - } - - .dark .streaming-effect::after { - background: linear-gradient( - 90deg, - rgba(180, 180, 180, 0) 0%, - rgba(180, 180, 180, 0.1) 50%, - rgba(180, 180, 180, 0) 100% - ); - } - @keyframes fadeIn { from { opacity: 0; @@ -276,68 +452,14 @@ } } - .animate-fade-in { - animation: fadeIn 0.3s ease-in-out forwards; + @keyframes placeholder-pulse { + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 0.8; + } } } - -/* Dark mode error badge styling */ -.dark .error-badge { - background-color: hsl(0, 70%, 20%) !important; - /* Darker red background for dark mode */ - color: hsl(0, 0%, 100%) !important; - /* Pure white text for better contrast */ -} - -/* Input Overrides */ -/* Chrome, Safari, Edge, Opera */ -input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; -} - -/* Firefox */ -input[type="search"]::-moz-search-cancel-button { - display: none; -} - -/* Microsoft Edge */ -input[type="search"]::-ms-clear { - display: none; -} - -/* Code Prompt Bar Placeholder Animation */ -@keyframes placeholder-pulse { - 0%, - 100% { - opacity: 0.5; - } - - 50% { - opacity: 0.8; - } -} - -.loading-placeholder::placeholder { - animation: placeholder-pulse 1.5s ease-in-out infinite; -} - -/* Dark mode error badge styling */ -.dark .bg-red-500 { - @apply bg-red-700; -} - -/* Font Variables */ -.font-geist-sans { - font-family: var(--font-geist-sans); -} - -.font-geist-mono { - font-family: var(--font-geist-mono); -} - -/* Sidebar overlay styles */ -.main-content-overlay { - z-index: 40; - /* Higher z-index to appear above content */ -} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx index 89f4f7a7f..a82cb168b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx @@ -3,7 +3,6 @@ import { Plus, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header' import { ChunkTableSkeleton } from '../../../components/skeletons/table-skeleton' @@ -18,11 +17,8 @@ export function DocumentLoading({ knowledgeBaseName, documentName, }: DocumentLoadingProps) { - const { mode, isExpanded } = useSidebarStore() const params = useParams() const workspaceId = params?.workspaceId as string - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' const breadcrumbs = [ { @@ -42,9 +38,7 @@ export function DocumentLoading({ ] return ( -
+
{/* Header with Breadcrumbs */} @@ -78,7 +72,7 @@ export function DocumentLoading({
{/* Table container */} - +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index b71155eea..638ef0cf9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -12,7 +12,6 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen import { SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components/search-input/search-input' import { useDocumentChunks } from '@/hooks/use-knowledge' import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' -import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header' import { CreateChunkModal } from './components/create-chunk-modal/create-chunk-modal' import { DeleteChunkModal } from './components/delete-chunk-modal/delete-chunk-modal' @@ -45,15 +44,10 @@ export function Document({ knowledgeBaseName, documentName, }: DocumentProps) { - const { mode, isExpanded } = useSidebarStore() const { getCachedKnowledgeBase, getCachedDocuments } = useKnowledgeStore() const { workspaceId } = useParams() const router = useRouter() const searchParams = useSearchParams() - - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' - const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10) const { @@ -247,7 +241,7 @@ export function Document({ const handleSelectAll = (checked: boolean) => { if (checked) { - setSelectedChunks(new Set(paginatedChunks.map((chunk) => chunk.id))) + setSelectedChunks(new Set(paginatedChunks.map((chunk: ChunkData) => chunk.id))) } else { setSelectedChunks(new Set()) } @@ -362,9 +356,7 @@ export function Document({ ] return ( -
+
@@ -382,9 +374,7 @@ export function Document({ } return ( -
+
{/* Fixed Header with Breadcrumbs */} @@ -463,7 +453,7 @@ export function Document({ - + @@ -509,7 +499,7 @@ export function Document({ - + @@ -602,7 +592,7 @@ export function Document({ )) ) : ( - paginatedChunks.map((chunk) => ( + paginatedChunks.map((chunk: ChunkData) => ( +
@@ -651,9 +644,7 @@ export function KnowledgeBase({ } return ( -
+
{/* Fixed Header with Breadcrumbs */} - + - + @@ -764,11 +755,11 @@ export function KnowledgeBase({ - + - + diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx index a189d2d19..f82696146 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx @@ -3,7 +3,6 @@ import { Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header' import { DocumentTableSkeleton } from '../../../components/skeletons/table-skeleton' @@ -12,11 +11,8 @@ interface KnowledgeBaseLoadingProps { } export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) { - const { mode, isExpanded } = useSidebarStore() const params = useParams() const workspaceId = params?.workspaceId as string - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' const breadcrumbs = [ { @@ -31,9 +27,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading ] return ( -
+
{/* Fixed Header with Breadcrumbs */} @@ -70,7 +64,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
{/* Table container */} - +
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 144f6dd8f..ab17b01c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from 'react' import { LibraryBig, Plus } from 'lucide-react' import { useKnowledgeBasesList } from '@/hooks/use-knowledge' import type { KnowledgeBaseData } from '@/stores/knowledge/store' -import { useSidebarStore } from '@/stores/sidebar/store' import { BaseOverview } from './components/base-overview/base-overview' import { CreateModal } from './components/create-modal/create-modal' import { EmptyStateCard } from './components/empty-state-card/empty-state-card' @@ -18,13 +17,9 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { } export function Knowledge() { - const { mode, isExpanded } = useSidebarStore() const { knowledgeBases, isLoading, error, addKnowledgeBase, refreshList } = useKnowledgeBasesList() - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' - const [searchQuery, setSearchQuery] = useState('') const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) @@ -56,9 +51,7 @@ export function Knowledge() { return ( <> -
+
{/* Header */} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx index d102d7e4b..8bd39d72c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx @@ -2,21 +2,14 @@ import { Plus, Search } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from './components/knowledge-header/knowledge-header' import { KnowledgeBaseCardSkeletonGrid } from './components/skeletons/knowledge-base-card-skeleton' export default function KnowledgeLoading() { - const { mode, isExpanded } = useSidebarStore() - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' - const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }] return ( -
+
{/* Header */} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7226b0307..47bd1528b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { AlertCircle, Info, Loader2 } from 'lucide-react' import { createLogger } from '@/lib/logs/console-logger' -import { useSidebarStore } from '@/stores/sidebar/store' import { ControlBar } from './components/control-bar/control-bar' import { Filters } from './components/filters/filters' import { Sidebar } from './components/sidebar/sidebar' @@ -55,9 +54,6 @@ export default function Logs() { const selectedRowRef = useRef(null) const loaderRef = useRef(null) const scrollContainerRef = useRef(null) - const { mode, isExpanded } = useSidebarStore() - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' const handleLogClick = (log: WorkflowLog) => { setSelectedLog(log) @@ -291,9 +287,7 @@ export default function Logs() { ]) return ( -
+
{/* Add the animation styles */} -) - -// Icon mapping for notification types -const NotificationIcon = { - error: ErrorIcon, - console: Terminal, - api: Rocket, - marketplace: Store, - info: Info, -} - -// Color schemes for different notification types -const NotificationColors = { - error: - 'border-red-500 bg-red-50 text-destructive dark:border-border dark:text-foreground dark:bg-background', - console: - 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', - api: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', - marketplace: - 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', - info: 'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background', -} - -// API deployment status styling -const ApiStatusStyles = { - active: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400', - inactive: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400', -} - -/** - * AlertDialog component for API deletion confirmation - */ -function DeleteApiConfirmation({ - isOpen, - onClose, - onConfirm, - workflowId, -}: { - isOpen: boolean - onClose: () => void - onConfirm: () => void - workflowId: string | null -}) { - return ( - - - - Delete API Deployment - - Are you sure you want to delete this API deployment? This action cannot be undone. - - - - Cancel - - Delete - - - - - ) -} - -/** - * NotificationList component displays all active notifications as alerts - * with support for auto-dismissal, copying content, and different styling per type - */ -export function NotificationList() { - // Store access - const { - notifications, - hideNotification, - markAsRead, - removeNotification, - setNotificationFading, - getVisibleNotificationCount, - } = useNotificationStore() - const { activeWorkflowId } = useWorkflowRegistry() - - // Local state - const [removedIds, setRemovedIds] = useState>(new Set()) - const [animatingIds, setAnimatingIds] = useState>(new Set()) - - // Filter to only show: - // 1. Visible notifications for the current workflow - // 2. That are either unread OR marked as persistent - // 3. And have not been marked for removal - const visibleNotifications = notifications.filter( - (n) => - n.isVisible && - n.workflowId === activeWorkflowId && - (!n.read || n.options?.isPersistent) && - !removedIds.has(n.id) - ) - - // Check if we're over the limit of visible notifications - const visibleCount = activeWorkflowId ? getVisibleNotificationCount(activeWorkflowId) : 0 - const _isOverLimit = visibleCount > MAX_VISIBLE_NOTIFICATIONS - - // Reset removedIds whenever a notification's visibility changes from false to true - useEffect(() => { - const newlyVisibleNotifications = notifications.filter( - (n) => n.isVisible && removedIds.has(n.id) - ) - - if (newlyVisibleNotifications.length > 0) { - setRemovedIds((prev) => { - const next = new Set(prev) - newlyVisibleNotifications.forEach((n) => next.delete(n.id)) - return next - }) - } - }, [notifications, removedIds]) - - // Handle fading notifications created by the store - useEffect(() => { - // This effect watches for notifications that are fading - // and handles the DOM removal after animation completes - - const timers: Record> = {} - - visibleNotifications.forEach((notification) => { - // For notifications that have started fading, set up cleanup timers - if (notification.isFading && !animatingIds.has(notification.id)) { - // Start slide up animation after fade animation - const slideTimer = setTimeout(() => { - setAnimatingIds((prev) => new Set([...prev, notification.id])) - - // After slide animation, remove from DOM - setTimeout(() => { - hideNotification(notification.id) - markAsRead(notification.id) - setRemovedIds((prev) => new Set([...prev, notification.id])) - - // Remove from animating set - setAnimatingIds((prev) => { - const next = new Set(prev) - next.delete(notification.id) - return next - }) - }, 300) - }, FADE_DURATION) - - timers[notification.id] = slideTimer - } - }) - - return () => { - Object.values(timers).forEach(clearTimeout) - } - }, [visibleNotifications, animatingIds, hideNotification, markAsRead]) - - // Early return if no notifications to show - if (visibleNotifications.length === 0) return null - - return ( - <> - -
- {visibleNotifications.map((notification) => ( -
- { - // For persistent notifications like API, just hide immediately without animations - if (notification.options?.isPersistent) { - hideNotification(id) - markAsRead(id) - setRemovedIds((prev) => new Set([...prev, id])) - return - } - - // For regular notifications, use the animation sequence - // Start the fade out animation when manually closing - setNotificationFading(id) - - // Start slide up animation after fade completes - setTimeout(() => { - setAnimatingIds((prev) => new Set([...prev, id])) - }, FADE_DURATION) - - // Remove from DOM after all animations complete - setTimeout(() => { - hideNotification(id) - markAsRead(id) - setRemovedIds((prev) => new Set([...prev, id])) - setAnimatingIds((prev) => { - const next = new Set(prev) - next.delete(id) - return next - }) - }, FADE_DURATION + 300) // Fade + slide durations - }} - /> -
- ))} -
- - ) -} - -/** - * Individual notification alert component - */ -interface NotificationAlertProps { - notification: Notification - isFading: boolean - onHide: (id: string) => void -} - -export function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) { - const { id, type, message, options, workflowId } = notification - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [showApiKey, setShowApiKey] = useState(false) - const { setDeploymentStatus } = useWorkflowRegistry() - - // Get deployment status from registry using notification's workflowId, not activeWorkflowId - const deploymentStatus = useWorkflowRegistry((state) => - state.getWorkflowDeploymentStatus(workflowId || null) - ) - const isDeployed = deploymentStatus?.isDeployed || false - - // Create a function to clear the redeployment flag and update deployment status - const updateDeploymentStatus = (isDeployed: boolean, deployedAt?: Date) => { - // Update deployment status in workflow store - setDeploymentStatus(workflowId || null, isDeployed, deployedAt) - - // Manually update the needsRedeployment flag in workflow store - useWorkflowStore.getState().setNeedsRedeploymentFlag(false) - } - - const Icon = NotificationIcon[type] - - const handleDeleteApi = async () => { - if (!workflowId) return - - try { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'DELETE', - }) - - if (!response.ok) throw new Error('Failed to delete API deployment') - - // Update deployment status in the store - updateDeploymentStatus(false) - - // Close the notification - onHide(id) - - // Close the dialog - setIsDeleteDialogOpen(false) - } catch (error) { - logger.error('Error deleting API deployment:', { error }) - } - } - - // Function to mask API key with asterisks but keep first and last 4 chars visible - const maskApiKey = (key: string) => { - if (!key || key.includes('No API key found')) return key - if (key.length <= 8) return key - return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}` - } - - // Modify the curl command to use a placeholder for the API key - const formatCurlCommand = (command: string, apiKey: string) => { - if (!command.includes('curl')) return command - - // Replace the actual API key with a placeholder in the command - const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY') - - // Format the command with line breaks for better readability - return sanitizedCommand - .replace(' -H ', '\n -H ') - .replace(' -d ', '\n -d ') - .replace(' http', '\n http') - } - - return ( - <> - - {type === 'api' ? ( - // Special layout for API notifications with equal spacing -
- {/* Left icon */} -
- -
- - {/* Content area with equal margins */} -
- - API - - - -

{!isDeployed ? 'Workflow currently not deployed' : message}

- - {/* Optional sections with copyable content */} - {options?.sections?.map((section, index) => { - // Get the API key from the sections to use in curl command formatting - const apiKey = - options.sections?.find((s) => s.label === 'x-api-key')?.content || '' - - return ( -
-
- {section.label} -
- - {/* Copyable code block */} -
- {section.label === 'x-api-key' ? ( - <> -
 setShowApiKey(!showApiKey)}
-                              title={
-                                showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
-                              }
-                            >
-                              {showApiKey ? section.content : maskApiKey(section.content)}
-                            
-
- -
- - ) : section.label === 'Example curl command' ? ( - <> -
-                              {formatCurlCommand(section.content, apiKey)}
-                            
- - - ) : ( - <> -
-                              {section.content}
-                            
- - - )} -
-
- ) - })} - - {/* Status and Delete button row - with pulsing green indicator */} -
-
- Status: -
-
- {isDeployed ? ( - options?.needsRedeployment ? ( - <> -
-
- - ) : ( - <> -
-
- - ) - ) : ( - <> -
-
- - )} -
- - {isDeployed - ? options?.needsRedeployment - ? 'Changes Detected' - : 'Active' - : 'Inactive'} - -
-
-
- {options?.needsRedeployment && ( - - )} - {isDeployed && ( - - )} -
-
- -
- - {/* Absolute positioned close button in the top right */} - {options?.isPersistent && ( -
- -
- )} -
- ) : ( - // Original layout for error, console and marketplace notifications -
- {/* Icon with proper vertical alignment */} -
- -
- - {/* Content area with right margin for balance */} -
- - - {type === 'error' - ? 'Error' - : type === 'marketplace' - ? 'Marketplace' - : type === 'info' - ? 'Info' - : 'Console'} - - - {/* Close button for persistent notifications */} - {options?.isPersistent && ( - - )} - - - - {/* Message with auto-expanding and max height */} -

- {message} -

- - {/* Optional sections with copyable content */} - {options?.sections?.map((section, index) => ( -
-
{section.label}
- - {/* Copyable code block with max height */} -
-
-                        {section.content}
-                      
- -
-
- ))} -
-
-
- )} - - - {/* Delete API confirmation dialog */} - setIsDeleteDialogOpen(false)} - onConfirm={handleDeleteApi} - workflowId={workflowId} - /> - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx index 3beedb3b8..ac61bb9ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx @@ -315,7 +315,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { return (
{/* Output Source Dropdown */} -
+
{/* Chat messages section - Scrollable area */}
- -
- {workflowMessages.length === 0 ? ( -
- No messages yet -
- ) : ( - workflowMessages.map((message) => ( - - )) - )} -
+ {workflowMessages.length === 0 ? ( +
+ No messages yet
- + ) : ( + +
+ {workflowMessages.map((message) => ( + + ))} +
+
+ + )}
{/* Input section - Fixed height */} -
+
setChatMessage(e.target.value)} onKeyDown={handleKeyPress} placeholder='Type a message...' - className='h-10 flex-1 focus-visible:ring-0 focus-visible:ring-offset-0' + className='h-9 flex-1 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground shadow-xs focus-visible:ring-0 focus-visible:ring-offset-0 dark:border-[#414141] dark:bg-[#202020]' disabled={!activeWorkflowId || isExecuting} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx index c2abb318b..2709da199 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx @@ -1,7 +1,4 @@ import { useMemo } from 'react' -import { formatDistanceToNow } from 'date-fns' -import { Clock } from 'lucide-react' -import { JSONView } from '../../../console/components/json-view/json-view' interface ChatMessageProps { message: { @@ -11,7 +8,6 @@ interface ChatMessageProps { type: 'user' | 'workflow' isStreaming?: boolean } - containerWidth: number } // Maximum character length for a word before it's broken up @@ -49,57 +45,42 @@ const WordWrap = ({ text }: { text: string }) => { ) } -export function ChatMessage({ message, containerWidth }: ChatMessageProps) { - const messageDate = useMemo(() => new Date(message.timestamp), [message.timestamp]) - - const relativeTime = useMemo(() => { - return formatDistanceToNow(messageDate, { addSuffix: true }) - }, [messageDate]) - - // Check if content is a JSON object - const isJsonObject = useMemo(() => { - return typeof message.content === 'object' && message.content !== null +export function ChatMessage({ message }: ChatMessageProps) { + // Format message content as text + const formattedContent = useMemo(() => { + if (typeof message.content === 'object' && message.content !== null) { + return JSON.stringify(message.content, null, 2) + } + return String(message.content || '') }, [message.content]) - // Format message content based on type - const formattedContent = useMemo(() => { - if (isJsonObject) { - return JSON.stringify(message.content) // Return stringified version for type safety - } - - return String(message.content || '') - }, [message.content, isJsonObject]) - - return ( -
- {/* Header with time on left and message type on right */} -
-
- - {relativeTime} -
-
- {message.type !== 'user' && Workflow} - {message.isStreaming && ( - - ••• - - )} + // Render human messages as chat bubbles + if (message.type === 'user') { + return ( +
+
+
+
+
+ +
+
+
+ ) + } - {/* Message content with proper word wrapping */} -
- {isJsonObject ? ( - - ) : ( -
- - {message.isStreaming && ( - - )} -
- )} + // Render agent/workflow messages as full-width text + return ( +
+
+
+ + {message.isStreaming && ( + + )} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx index f0b8d9498..c29b30882 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx @@ -31,7 +31,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
{isJsonObject ? ( - + ) : ( {message.content} )} @@ -50,11 +50,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
- {isJsonObject ? ( - - ) : ( - {message.content} - )} + {isJsonObject ? : {message.content}}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx index a4768fd70..d1f8d75de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' -import { Button } from '@/components/ui/button' import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' import { cn } from '@/lib/utils' import { getBlock } from '@/blocks' @@ -289,19 +288,19 @@ export function OutputSelect({ } return ( -
+
))}
))}
- - {/* Done button to close dropdown */} -
- -
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/audio-player/audio-player.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/audio-player/audio-player.tsx deleted file mode 100644 index e5a4380a8..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/audio-player/audio-player.tsx +++ /dev/null @@ -1,115 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' -import { Download, Pause, Play } from 'lucide-react' -import { createLogger } from '@/lib/logs/console-logger' - -const logger = createLogger('AudioPlayer') - -interface AudioPlayerProps { - audioUrl: string -} - -export function AudioPlayer({ audioUrl }: AudioPlayerProps) { - const [isPlaying, setIsPlaying] = useState(false) - const [progress, setProgress] = useState(0) - const audioRef = useRef(null) - - useEffect(() => { - if (!audioRef.current) { - audioRef.current = new Audio(audioUrl) - - audioRef.current.addEventListener('ended', () => setIsPlaying(false)) - audioRef.current.addEventListener('pause', () => setIsPlaying(false)) - audioRef.current.addEventListener('play', () => setIsPlaying(true)) - audioRef.current.addEventListener('timeupdate', updateProgress) - } else { - audioRef.current.src = audioUrl - setProgress(0) - } - - return () => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.removeEventListener('ended', () => setIsPlaying(false)) - audioRef.current.removeEventListener('pause', () => setIsPlaying(false)) - audioRef.current.removeEventListener('play', () => setIsPlaying(true)) - audioRef.current.removeEventListener('timeupdate', updateProgress) - } - } - }, [audioUrl]) - - const updateProgress = () => { - if (audioRef.current) { - const value = (audioRef.current.currentTime / audioRef.current.duration) * 100 - setProgress(Number.isNaN(value) ? 0 : value) - } - } - - const togglePlay = () => { - if (!audioRef.current) return - - if (isPlaying) { - audioRef.current.pause() - } else { - audioRef.current.play() - } - } - - const downloadAudio = async () => { - try { - const response = await fetch(audioUrl) - const blob = await response.blob() - - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `tts-audio-${Date.now()}.mp3` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - URL.revokeObjectURL(url) - } catch (error) { - logger.error('Error downloading audio:', error) - } - } - - const seekAudio = (e: React.MouseEvent) => { - if (!audioRef.current) return - - const container = e.currentTarget - const rect = container.getBoundingClientRect() - const x = e.clientX - rect.left - const percent = x / rect.width - - audioRef.current.currentTime = percent * audioRef.current.duration - } - - return ( -
- - -
-
-
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/code-display/code-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/code-display/code-display.tsx new file mode 100644 index 000000000..6c276791c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/code-display/code-display.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from 'react' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' + +// Add dark mode fix for Prism.js +if (typeof document !== 'undefined') { + const styleId = 'console-code-dark-mode-fix' + if (!document.getElementById(styleId)) { + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .dark .token.operator { + color: #9cdcfe !important; + background: transparent !important; + } + .dark .token.punctuation { + color: #d4d4d4 !important; + } + ` + document.head.appendChild(style) + } +} + +interface CodeDisplayProps { + code: string + language?: string +} + +export const CodeDisplay = ({ code, language = 'javascript' }: CodeDisplayProps) => { + const [visualLineHeights, setVisualLineHeights] = useState([]) + const containerRef = useRef(null) + + // Calculate sidebar width based on number of lines + const lineCount = code.split('\n').length + const sidebarWidth = Math.max(30, lineCount.toString().length * 8 + 16) // 8px per digit + 16px padding + + // Calculate visual line heights similar to code.tsx + useEffect(() => { + if (!containerRef.current) return + + const calculateVisualLines = () => { + const preElement = containerRef.current?.querySelector('pre') + if (!preElement) return + + const lines = code.split('\n') + const newVisualLineHeights: number[] = [] + + const tempContainer = document.createElement('div') + tempContainer.style.cssText = ` + position: absolute; + visibility: hidden; + height: auto; + width: ${preElement.clientWidth}px; + font-family: ${window.getComputedStyle(preElement).fontFamily}; + font-size: ${window.getComputedStyle(preElement).fontSize}; + line-height: 21px; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + box-sizing: border-box; + ` + document.body.appendChild(tempContainer) + + lines.forEach((line) => { + const lineDiv = document.createElement('div') + lineDiv.textContent = line || ' ' + + tempContainer.appendChild(lineDiv) + const actualHeight = lineDiv.getBoundingClientRect().height + const lineUnits = Math.max(1, Math.ceil(actualHeight / 21)) + newVisualLineHeights.push(lineUnits) + tempContainer.removeChild(lineDiv) + }) + + document.body.removeChild(tempContainer) + setVisualLineHeights(newVisualLineHeights) + } + + const timeoutId = setTimeout(calculateVisualLines, 50) + + const resizeObserver = new ResizeObserver(calculateVisualLines) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [code, sidebarWidth]) + + // Render line numbers with proper visual line handling + const renderLineNumbers = () => { + const numbers: React.ReactElement[] = [] + let lineNumber = 1 + + visualLineHeights.forEach((height, index) => { + numbers.push( +
+ {lineNumber} +
+ ) + for (let i = 1; i < height; i++) { + numbers.push( +
+ {lineNumber} +
+ ) + } + lineNumber++ + }) + + if (numbers.length === 0) { + const lines = code.split('\n') + return lines.map((_, index) => ( +
+ {index + 1} +
+ )) + } + + return numbers + } + + return ( +
+ {/* Line numbers */} + + + {/* Code content */} +
+
+          
+        
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx index 1b0e9fab3..4ef6eaa2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -1,186 +1,603 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { format } from 'date-fns' import { AlertCircle, - AlertTriangle, - Calendar, + Check, ChevronDown, ChevronUp, - Clock, - Terminal, + Clipboard, + Download, + Pause, + Play, } from 'lucide-react' +import Image from 'next/image' import { Button } from '@/components/ui/button' +import { createLogger } from '@/lib/logs/console-logger' import { getBlock } from '@/blocks' import type { ConsoleEntry as ConsoleEntryType } from '@/stores/panel/console/types' +import { CodeDisplay } from '../code-display/code-display' import { JSONView } from '../json-view/json-view' +const logger = createLogger('ConsoleEntry') + interface ConsoleEntryProps { entry: ConsoleEntryType consoleWidth: number } -// Maximum character length for a word before it's broken up -const MAX_WORD_LENGTH = 25 +// Helper function to check if an object contains an audio URL +const hasAudioData = (obj: any): boolean => { + return obj && typeof obj === 'object' && 'audioUrl' in obj && typeof obj.audioUrl === 'string' +} -const WordWrap = ({ text }: { text: string }) => { - if (!text) return null +// Helper function to check if a string is likely a base64 image +const isBase64Image = (str: string): boolean => { + if (typeof str !== 'string') return false + return str.length > 100 && /^[A-Za-z0-9+/=]+$/.test(str) +} - // Split text into words, keeping spaces and punctuation - const parts = text.split(/(\s+)/g) +// Helper function to check if an object contains an image URL +const hasImageData = (obj: any): boolean => { + if (!obj || typeof obj !== 'object') return false + + // Case 1: Has explicit image data (base64) + if ( + 'image' in obj && + typeof obj.image === 'string' && + obj.image.length > 0 && + isBase64Image(obj.image) + ) { + return true + } + + // Case 2: Has explicit image type in metadata + if ( + obj.metadata?.type && + typeof obj.metadata.type === 'string' && + obj.metadata.type.toLowerCase() === 'image' + ) { + return true + } + + // Case 3: Content URL points to an image file + if (typeof obj.content === 'string' && obj.content.startsWith('http')) { + return !!obj.content.toLowerCase().match(/\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/) + } + + // Case 4: Has URL property with image extension + if ('url' in obj && typeof obj.url === 'string') { + if (obj.metadata?.fileType?.startsWith('image/')) { + return true + } + const url = obj.url.toLowerCase() + return url.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?|$)/) !== null + } + + return false +} + +// Get image URL from object +const getImageUrl = (obj: any): string | null => { + if (!obj || typeof obj !== 'object') return null + + // Try content field first + if (typeof obj.content === 'string' && obj.content.startsWith('http')) { + return obj.content + } + + // Try url field + if ('url' in obj && typeof obj.url === 'string') { + return obj.url + } + + return null +} + +// Get base64 image data from object +const getImageData = (obj: any): string | null => { + if (!obj || typeof obj !== 'object') return null + + if ( + 'image' in obj && + typeof obj.image === 'string' && + obj.image.length > 0 && + isBase64Image(obj.image) + ) { + return obj.image + } + + return null +} + +// Image preview component +const ImagePreview = ({ + imageUrl, + imageData, + isBase64 = false, + onLoadError, +}: { + imageUrl?: string + imageData?: string + isBase64?: boolean + onLoadError?: (hasError: boolean) => void +}) => { + const [loadError, setLoadError] = useState(false) + + // Only display image if we have valid data + const hasValidData = + (isBase64 && imageData && imageData.length > 0) || (imageUrl && imageUrl.length > 0) + + if (!hasValidData) { + return null + } + + if (loadError) { + return null + } + + // Determine the source for the image + const imageSrc = + isBase64 && imageData && imageData.length > 0 + ? `data:image/png;base64,${imageData}` + : imageUrl || '' return ( - <> - {parts.map((part, index) => { - // If the part is whitespace or shorter than the max length, render it as is - if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) { - return {part} - } - - // For long words, break them up into chunks - const chunks = [] - for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) { - chunks.push(part.substring(i, i + MAX_WORD_LENGTH)) - } - - return ( - - {chunks.map((chunk, chunkIndex) => ( - {chunk} - ))} - - ) - })} - +
+ { + console.error('Image failed to load:', imageSrc) + setLoadError(true) + onLoadError?.(true) + }} + onLoad={() => { + onLoadError?.(false) + }} + /> +
) } export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { - const [isExpanded, setIsExpanded] = useState(false) - const [expandAllJson, setExpandAllJson] = useState(false) + const [isExpanded, setIsExpanded] = useState(true) // Default expanded + const [showCopySuccess, setShowCopySuccess] = useState(false) + const [showInput, setShowInput] = useState(false) // State for input/output toggle + const [isPlaying, setIsPlaying] = useState(false) + const [progress, setProgress] = useState(0) + const [imageLoadError, setImageLoadError] = useState(false) + const audioRef = useRef(null) + + // Check if entry has audio data + const hasAudio = useMemo(() => { + return entry.output != null && hasAudioData(entry.output) + }, [entry.output]) + + // Check if entry has image data + const hasImage = useMemo(() => { + return entry.output != null && hasImageData(entry.output) + }, [entry.output]) + + // Only show image download button if image exists and didn't fail to load + const showImageDownload = hasImage && !imageLoadError + + const audioUrl = useMemo(() => { + return hasAudio && entry.output ? entry.output.audioUrl : null + }, [hasAudio, entry.output]) + + const imageUrl = useMemo(() => { + return hasImage && entry.output ? getImageUrl(entry.output) : null + }, [hasImage, entry.output]) + + const imageData = useMemo(() => { + return hasImage && entry.output ? getImageData(entry.output) : null + }, [hasImage, entry.output]) + + const isBase64Image = useMemo(() => { + return imageData != null && imageData.length > 0 + }, [imageData]) + + // Get the data to display based on the toggle state + const displayData = useMemo(() => { + return showInput ? entry.input : entry.output + }, [showInput, entry.input, entry.output]) + + // Check if input data exists + const hasInputData = useMemo(() => { + return ( + entry.input != null && + (typeof entry.input === 'object' + ? Object.keys(entry.input).length > 0 + : entry.input !== undefined && entry.input !== null) + ) + }, [entry.input]) + + // Check if this is a function block with code input + const shouldShowCodeDisplay = useMemo(() => { + return ( + entry.blockType === 'function' && + showInput && + entry.input && + typeof entry.input === 'object' && + 'code' in entry.input && + typeof entry.input.code === 'string' + ) + }, [entry.blockType, showInput, entry.input]) + + // Audio player logic + useEffect(() => { + if (!hasAudio || !audioUrl) return + + if (!audioRef.current) { + audioRef.current = new Audio(audioUrl) + audioRef.current.addEventListener('ended', () => setIsPlaying(false)) + audioRef.current.addEventListener('pause', () => setIsPlaying(false)) + audioRef.current.addEventListener('play', () => setIsPlaying(true)) + audioRef.current.addEventListener('timeupdate', updateProgress) + } else { + audioRef.current.src = audioUrl + setProgress(0) + } + + return () => { + if (audioRef.current) { + audioRef.current.pause() + audioRef.current.removeEventListener('ended', () => setIsPlaying(false)) + audioRef.current.removeEventListener('pause', () => setIsPlaying(false)) + audioRef.current.removeEventListener('play', () => setIsPlaying(true)) + audioRef.current.removeEventListener('timeupdate', updateProgress) + } + } + }, [hasAudio, audioUrl]) + + const updateProgress = () => { + if (audioRef.current) { + const value = (audioRef.current.currentTime / audioRef.current.duration) * 100 + setProgress(Number.isNaN(value) ? 0 : value) + } + } + + const togglePlay = () => { + if (!audioRef.current) return + + if (isPlaying) { + audioRef.current.pause() + } else { + audioRef.current.play() + } + } + + const downloadAudio = async () => { + if (!audioUrl) return + + try { + const response = await fetch(audioUrl) + const blob = await response.blob() + + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `tts-audio-${Date.now()}.mp3` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + URL.revokeObjectURL(url) + } catch (error) { + logger.error('Error downloading audio:', error) + } + } + + const downloadImage = async () => { + try { + let blob: Blob + if (isBase64Image && imageData && imageData.length > 0) { + // Convert base64 to blob + const byteString = atob(imageData) + const arrayBuffer = new ArrayBuffer(byteString.length) + const uint8Array = new Uint8Array(arrayBuffer) + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i) + } + blob = new Blob([arrayBuffer], { type: 'image/png' }) + } else if (imageUrl && imageUrl.length > 0) { + // Use proxy endpoint to fetch image + const proxyUrl = `/api/proxy/image?url=${encodeURIComponent(imageUrl)}` + const response = await fetch(proxyUrl) + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`) + } + blob = await response.blob() + } else { + throw new Error('No image data or URL provided') + } + + // Create object URL and trigger download + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `generated-image-${Date.now()}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + // Clean up the URL + setTimeout(() => URL.revokeObjectURL(url), 100) + } catch (error) { + console.error('Error downloading image:', error) + alert('Failed to download image. Please try again later.') + } + } const blockConfig = useMemo(() => { if (!entry.blockType) return null return getBlock(entry.blockType) }, [entry.blockType]) - const BlockIcon = blockConfig?.icon + const handleCopy = () => { + let textToCopy: string - // Helper function to check if data has nested objects or arrays - const hasNestedStructure = (data: any): boolean => { - if (data === null || typeof data !== 'object') return false - - // Check if it's an empty object or array - if (Object.keys(data).length === 0) return false - - // For arrays, check if any element is an object - if (Array.isArray(data)) { - return data.some((item) => typeof item === 'object' && item !== null) + if (shouldShowCodeDisplay) { + // For code display, copy just the code string + textToCopy = entry.input.code + } else { + // For regular JSON display, copy the full JSON + const dataToCopy = showInput ? entry.input : entry.output + textToCopy = JSON.stringify(dataToCopy, null, 2) } - // For objects, check if any value is an object - return Object.values(data).some((value) => typeof value === 'object' && value !== null) + navigator.clipboard.writeText(textToCopy) + setShowCopySuccess(true) + } + + useEffect(() => { + if (showCopySuccess) { + const timer = setTimeout(() => { + setShowCopySuccess(false) + }, 2000) + return () => clearTimeout(timer) + } + }, [showCopySuccess]) + + const BlockIcon = blockConfig?.icon + const blockColor = blockConfig?.bgColor || '#6B7280' + + // Handle image load error callback + const handleImageLoadError = (hasError: boolean) => { + setImageLoadError(hasError) } return ( -
!entry.error && !entry.warning && entry.success && setIsExpanded(!isExpanded)} - > -
+
+ {/* Header: Icon | Block name */} +
+ {BlockIcon && ( +
+ +
+ )} + + {entry.blockName || 'Unknown Block'} + +
+ + {/* Duration tag | Time tag | Input/Output tags */} +
= 400 ? 'flex items-center justify-between' : 'grid grid-cols-1 gap-4' + className={`flex h-5 items-center rounded-lg px-2 ${ + entry.error ? 'bg-[#F6D2D2] dark:bg-[#442929]' : 'bg-secondary' }`} > - {entry.blockName && ( -
- {BlockIcon ? ( - - ) : ( - - )} - {entry.blockName} + {entry.error ? ( +
+ + + Error +
+ ) : ( + + {entry.durationMs ?? 0}ms + )} -
= 400 ? 'flex gap-4' : 'grid grid-cols-2 gap-4' - } text-muted-foreground text-sm`} - > -
- - {entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'} -
-
- - Duration: {entry.durationMs ?? 0}ms +
+
+ + {entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'} + +
+ {/* Input/Output tags - only show if input data exists */} + {hasInputData && ( + <> + + + {/* Copy button for code input - only show when input is selected and it's a function block with code */} + {shouldShowCodeDisplay && ( + + )} + + )} +
+ + {/* Response area */} +
+ {/* Error display */} + {entry.error && !showInput && ( +
+
+ {entry.error}
-
+ )} -
- {!entry.error && !entry.warning && ( -
- -
- {entry.output != null && ( -
+ {/* Warning display */} + {entry.warning && !showInput && ( +
+
+ Warning +
+
+ {entry.warning} +
+
+ )} + + {/* Content display */} + {(showInput ? hasInputData : entry.output != null && !entry.error) && ( +
+ {shouldShowCodeDisplay ? ( + /* Code display - replace entire content */ + + ) : ( +
+ {/* Copy and Expand/Collapse buttons */} +
+ {/* Audio controls - only show if audio data exists and we're showing output */} + {hasAudio && !showInput && ( + <> + + + + )} + {/* Image controls - only show if image data exists and didn't fail to load and we're showing output */} + {showImageDownload && !showInput && ( + )} + + +
+ + {/* Image preview - show before JSON content - only for output mode */} + {hasImage && !showInput && ( + + )} + + {/* Content */} + {isExpanded ? ( +
+ +
+ ) : ( +
setIsExpanded(true)} + > + {'{...}'}
)} -
-
- )} + )} +
+ )} - {entry.error && ( -
- -
-
Error
-
- -
-
+ {/* No output message */} + {!showInput && entry.output == null && !entry.error && ( +
+
+ No output
- )} +
+ )} - {entry.warning && ( -
- -
-
Warning
-
- -
-
+ {/* No input message */} + {showInput && !hasInputData && ( +
+
+ No input
- )} -
+
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx index a4101a8b6..17daf1d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx @@ -1,30 +1,30 @@ import { useEffect, useState } from 'react' -import { Download } from 'lucide-react' import { Button } from '@/components/ui/button' -import { AudioPlayer } from '../audio-player/audio-player' interface JSONViewProps { data: any - level?: number - initiallyExpanded?: boolean } const MAX_STRING_LENGTH = 150 +const MAX_OBJECT_KEYS = 10 +const MAX_ARRAY_ITEMS = 20 const TruncatedValue = ({ value }: { value: string }) => { const [isExpanded, setIsExpanded] = useState(false) if (value.length <= MAX_STRING_LENGTH) { - return {value} + return ( + {value} + ) } return ( - + {isExpanded ? value : `${value.slice(0, MAX_STRING_LENGTH)}...`} -
- )} -
- ) -} - -export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONViewProps) => { - const [isCollapsed, setIsCollapsed] = useState(!initiallyExpanded) +export const JSONView = ({ data }: JSONViewProps) => { const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number y: number } | null>(null) - useEffect(() => { - setIsCollapsed(!initiallyExpanded) - }, [initiallyExpanded]) - const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault() setContextMenuPosition({ x: e.clientX, y: e.clientY }) @@ -250,72 +167,31 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie } }, [contextMenuPosition]) - // Check if this is a base64 image string - const isBase64ImageString = typeof data === 'string' && isBase64Image(data) - - // Check if current object contains image URL - const hasImageUrl = isImageData(data) - - // Check if current object contains audio URL - const hasAudioUrl = isAudioData(data) - - // Check if this is a response object with the new image format - const isResponseWithImage = hasImageContent(data) - - // Check if this is response.output with the new image structure - const isToolResponseWithImage = - data && typeof data === 'object' && data.output && hasImageContent(data.output) - - if (data === null) return null - - // Handle base64 image strings directly - if (isBase64ImageString) { - return ( -
- - {contextMenuPosition && ( -
- - -
- )} -
- ) - } + if (data === null) + return null + // For non-object data, show simple JSON if (typeof data !== 'object') { const stringValue = JSON.stringify(data) return ( - {typeof data === 'string' ? : stringValue} + {typeof data === 'string' ? ( + + ) : ( + + {stringValue} + + )} {contextMenuPosition && (
- -
- )} - - {'}'} -
- ) - } - - // Handle tool response objects with the new image structure in output - if (isToolResponseWithImage) { - const outputData = data.output || {} - const imageUrl = - outputData.content && typeof outputData.content === 'string' ? outputData.content : undefined - const hasValidImage = - outputData.image && typeof outputData.image === 'string' && outputData.image.length > 0 - - return ( -
- { - e.stopPropagation() - setIsCollapsed(!isCollapsed) - }} - > - {isCollapsed ? '▶' : '▼'} - {'{'} - {isCollapsed ? '...' : ''} - - - {!isCollapsed && ( -
- {Object.entries(data).map(([key, value]: [string, any], index) => { - const isOutputKey = key === 'output' - - return ( -
- {key}:{' '} - {isOutputKey ? ( -
- { - e.stopPropagation() - const nestedElem = e.currentTarget.nextElementSibling - if (nestedElem) { - nestedElem.classList.toggle('hidden') - } - }} - > - - {'{'} - -
- {Object.entries(value).map( - ([outputKey, outputValue]: [string, any], idx) => { - const isImageSubKey = outputKey === 'image' - - return ( -
- {outputKey}:{' '} - {isImageSubKey ? ( -
- {/* Show image preview within nested image field */} - -
- ) : ( - - )} - {idx < Object.entries(value).length - 1 && ','} -
- ) - } - )} -
- {'}'} -
- ) : ( - - )} - {index < Object.entries(data).length - 1 && ','} -
- ) - })} -
- )} - - {contextMenuPosition && ( -
- - -
- )} - - {'}'} -
- ) - } - - const isArray = Array.isArray(data) - const items = isArray ? data : Object.entries(data) - const isEmpty = items.length === 0 - - if (isEmpty) { - return {isArray ? '[]' : '{}'} - } - + // Default case: show JSON as formatted text with collapsible functionality return ( -
- { - e.stopPropagation() - setIsCollapsed(!isCollapsed) - }} - > - {isCollapsed ? '▶' : '▼'} - {isArray ? '[' : '{'} - {isCollapsed ? '...' : ''} - - - {/* Direct image render for objects with image URLs */} - {!isCollapsed && hasImageUrl && } - - {/* Direct audio render for objects with audio URLs */} - {!isCollapsed && hasAudioUrl && } - +
+
+        
+      
{contextMenuPosition && (
- {hasImageUrl && ( - - )}
)} - - {!isCollapsed && ( -
- {isArray - ? items.map((item, index) => ( -
- - {index < items.length - 1 && ','} -
- )) - : (items as [string, any][]).map(([key, value], index) => { - // Handle the case where we have content (URL) and image (base64) fields - const isImageField = - key === 'image' && typeof value === 'string' && value.length > 100 - - return ( -
- {key}:{' '} - {isImageField ? ( - - ) : ( - - )} - {index < items.length - 1 && ','} -
- ) - })} -
- )} - {isArray ? ']' : '}'}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx index 36ded4867..bb15adce3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx @@ -19,18 +19,20 @@ export function Console({ panelWidth }: ConsoleProps) { }, [entries, activeWorkflowId]) return ( - -
- {filteredEntries.length === 0 ? ( -
- No console entries +
+ {filteredEntries.length === 0 ? ( +
+ No console entries +
+ ) : ( + +
+ {filteredEntries.map((entry) => ( + + ))}
- ) : ( - filteredEntries.map((entry) => ( - - )) - )} -
- + + )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx index 598a5aab2..0dff32699 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx @@ -12,7 +12,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' @@ -23,11 +22,7 @@ import { useVariablesStore } from '@/stores/panel/variables/store' import type { Variable, VariableType } from '@/stores/panel/variables/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -interface VariablesProps { - panelWidth: number -} - -export function Variables({ panelWidth }: VariablesProps) { +export function Variables() { const { activeWorkflowId, workflows } = useWorkflowRegistry() const { variables: storeVariables, @@ -239,195 +234,196 @@ export function Variables({ panelWidth }: VariablesProps) { }, [workflowVariables]) return ( - -
- {/* Variables List */} - {workflowVariables.length === 0 ? ( -
-
No variables yet
- -
- ) : ( - <> -
- {workflowVariables.map((variable) => ( -
-
-
- handleVariableNameChange(variable.id, e.target.value)} - /> +
+ {workflowVariables.length === 0 ? ( +
+ +
+ ) : ( + +
+ {workflowVariables.map((variable) => ( +
+ {/* Header: Variable name | Variable type | Options dropdown */} +
+ handleVariableNameChange(variable.id, e.target.value)} + /> - - - - - - - - Set variable type - - - updateVariable(variable.id, { type: 'plain' })} - className='flex cursor-pointer items-center' - > -
Abc
- Plain -
- updateVariable(variable.id, { type: 'number' })} - className='flex cursor-pointer items-center' - > -
123
- Number -
- updateVariable(variable.id, { type: 'boolean' })} - className='flex cursor-pointer items-center' - > -
0/1
- Boolean -
- updateVariable(variable.id, { type: 'object' })} - className='flex cursor-pointer items-center' - > -
{'{}'}
- Object -
- updateVariable(variable.id, { type: 'array' })} - className='flex cursor-pointer items-center' - > -
[]
- Array -
-
-
- -
- - - - - - duplicateVariable(variable.id)} - className='cursor-pointer text-muted-foreground' - > - - Duplicate - - - deleteVariable(variable.id)} - className='cursor-pointer text-destructive focus:text-destructive' - > - - Delete - - - + {/* Type selector */} + + +
+ {getTypeIcon(variable.type)} +
-
-
+ + + updateVariable(variable.id, { type: 'plain' })} + className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > +
Abc
+ Plain +
+ updateVariable(variable.id, { type: 'number' })} + className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > +
123
+ Number +
+ updateVariable(variable.id, { type: 'boolean' })} + className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > +
0/1
+ Boolean +
+ updateVariable(variable.id, { type: 'object' })} + className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > +
{'{}'}
+ Object +
+ updateVariable(variable.id, { type: 'array' })} + className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > +
[]
+ Array +
+
+ -
{ - editorRefs.current[variable.id] = el - }} - style={{ - maxWidth: panelWidth ? `${panelWidth - 50}px` : '100%', - overflowWrap: 'break-word', - }} - > - {variable.value === '' && ( -
- {getPlaceholder(variable.type)} -
- )} - handleEditorBlur(variable.id)} - onFocus={() => handleEditorFocus(variable.id)} - highlight={(code) => - highlight( - code, - languages[getEditorLanguage(variable.type)], - getEditorLanguage(variable.type) - ) - } - padding={0} - style={{ - fontFamily: 'inherit', - lineHeight: '21px', - width: '100%', - wordWrap: 'break-word', - whiteSpace: 'pre-wrap', - }} - className='w-full focus:outline-none' - textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full whitespace-pre-wrap break-words overflow-visible' - /> + {/* Options dropdown */} + + + + + + duplicateVariable(variable.id)} + className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > + + Duplicate + + deleteVariable(variable.id)} + className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive' + > + + Delete + + + +
- {/* Show validation indicator for any non-empty variable */} - {variable.value !== '' && ( + {/* Value area */} +
+ {/* Validation indicator */} + {variable.value !== '' && getValidationStatus(variable) && ( +
-
- {getValidationStatus(variable) && ( -
- -
- )} +
+
- {getValidationStatus(variable) &&

{getValidationStatus(variable)}

} +

{getValidationStatus(variable)}

- )} +
+ )} + + {/* Editor */} +
+
{ + editorRefs.current[variable.id] = el + }} + style={{ maxWidth: '100%' }} + > + {variable.value === '' && ( +
+
{getPlaceholder(variable.type)}
+
+ )} + handleEditorBlur(variable.id)} + onFocus={() => handleEditorFocus(variable.id)} + highlight={(code) => + // Only apply syntax highlighting for non-basic text types + variable.type === 'plain' || variable.type === 'string' + ? code + : highlight( + code, + languages[getEditorLanguage(variable.type)], + getEditorLanguage(variable.type) + ) + } + padding={0} + style={{ + fontFamily: 'inherit', + lineHeight: '20px', + width: '100%', + maxWidth: '100%', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + overflowWrap: 'break-word', + minHeight: '20px', + overflow: 'hidden', + }} + className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none' + textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground' + /> +
- ))} -
+
+ ))} {/* Add Variable Button */} - - )} -
-
+
+ + )} +
) } 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 f40245f99..cbc776d91 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 @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { Expand, PanelRight } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { ArrowDownToLine, CircleSlash, X } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useChatStore } from '@/stores/panel/chat/store' import { useConsoleStore } from '@/stores/panel/console/store' @@ -13,209 +13,198 @@ import { Console } from './components/console/console' import { Variables } from './components/variables/variables' export function Panel() { - const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width) - const [isDragging, setIsDragging] = useState(false) const [chatMessage, setChatMessage] = useState('') const [copilotMessage, setCopilotMessage] = useState('') const [isChatModalOpen, setIsChatModalOpen] = useState(false) - const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false) - const copilotRef = useRef<{ clearMessages: () => void }>(null) + const [isResizing, setIsResizing] = useState(false) + const [resizeStartX, setResizeStartX] = useState(0) + const [resizeStartWidth, setResizeStartWidth] = useState(0) const isOpen = usePanelStore((state) => state.isOpen) const togglePanel = usePanelStore((state) => state.togglePanel) const activeTab = usePanelStore((state) => state.activeTab) const setActiveTab = usePanelStore((state) => state.setActiveTab) + const panelWidth = usePanelStore((state) => state.panelWidth) + const setPanelWidth = usePanelStore((state) => state.setPanelWidth) const clearConsole = useConsoleStore((state) => state.clearConsole) + const exportConsoleCSV = useConsoleStore((state) => state.exportConsoleCSV) const clearChat = useChatStore((state) => state.clearChat) + const exportChatCSV = useChatStore((state) => state.exportChatCSV) const { activeWorkflowId } = useWorkflowRegistry() - const handleMouseDown = (e: React.MouseEvent) => { - setIsDragging(true) - e.preventDefault() + const handleTabClick = (tab: 'chat' | 'console' | 'variables') => { + setActiveTab(tab) + if (!isOpen) { + togglePanel() + } } + const handleClosePanel = () => { + togglePanel() + } + + // Resize functionality + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + if (!isOpen) return + e.preventDefault() + setIsResizing(true) + setResizeStartX(e.clientX) + setResizeStartWidth(panelWidth) + }, + [isOpen, panelWidth] + ) + + const handleResize = useCallback( + (e: MouseEvent) => { + if (!isResizing) return + const deltaX = resizeStartX - e.clientX // Subtract because we're expanding left + const newWidth = resizeStartWidth + deltaX + setPanelWidth(newWidth) + }, + [isResizing, resizeStartX, resizeStartWidth, setPanelWidth] + ) + + const handleResizeEnd = useCallback(() => { + setIsResizing(false) + }, []) + + // Add global mouse event listeners for resize useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (isDragging) { - const newWidth = window.innerWidth - e.clientX - setWidth(Math.max(336, Math.min(newWidth, window.innerWidth * 0.8))) + if (isResizing) { + document.addEventListener('mousemove', handleResize) + document.addEventListener('mouseup', handleResizeEnd) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + return () => { + document.removeEventListener('mousemove', handleResize) + document.removeEventListener('mouseup', handleResizeEnd) + document.body.style.cursor = '' + document.body.style.userSelect = '' } } - - const handleMouseUp = () => { - setIsDragging(false) - } - - if (isDragging) { - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - }, [isDragging]) - - if (!isOpen) { - return ( - - - - - Open Panel - - ) - } + }, [isResizing, handleResize, handleResizeEnd]) return ( <> -
-
+ {/* Tab Selector - Always visible */} +
+ + + +
- {/* Panel Header */} -
-
- - - - {/* */} + {/* Panel Content - Only visible when isOpen is true */} + {isOpen && ( +
+ {/* Invisible resize handle */} +
+ + {/* Header - Fixed width content */} +
+

{activeTab}

+
+ {activeTab === 'console' && ( + + + + + Export console data + + )} + {activeTab === 'chat' && ( + + + + + Export chat data + + )} + {(activeTab === 'console' || activeTab === 'chat') && ( + + + + + Clear {activeTab} + + )} + +
- {(activeTab === 'console' || activeTab === 'chat') /* || activeTab === 'copilot' */ && ( - - )} + {/* Panel Content Area - Resizable */} +
+ {activeTab === 'chat' ? ( + + ) : activeTab === 'console' ? ( + + ) : ( + + )} +
- - {/* Panel Content */} -
- {activeTab === 'chat' ? ( - - ) : activeTab === 'console' ? ( - - ) : ( - /* activeTab === 'copilot' ? ( - - ) : */ - )} -
- - {/* Panel Footer */} -
- - - - - Close Panel - - - {activeTab === 'chat' && ( - - - - - Expand Chat - - )} - - {/* activeTab === 'copilot' && ( - - - - - Expand Copilot - - ) */} -
-
+ )} {/* Fullscreen Chat Modal */} { return ( -
- {/* Left Section - Workflow Name Skeleton */} -
- {/* Workflow name skeleton */} - - {/* "Saved X time ago" skeleton */} - -
+
+ {/* Delete Button */} + - {/* Middle Section */} -
+ {/* Duplicate Button */} + - {/* Right Section - Action Buttons with Real Icons */} -
- {/* Delete Button */} - + {/* Auto Layout Button */} + - {/* History Button */} - + {/* Debug Mode Button */} + - {/* Notifications Button */} - + {/* Deploy Button */} + - {/* Duplicate Button */} - - - {/* Auto Layout Button */} - - - {/* Debug Mode Button */} - - - {/* Deploy Button */} - - - {/* Run Button with Dropdown */} -
- {/* Main Run Button */} - - - {/* Dropdown Trigger */} - -
-
+ {/* Run Button */} +
) } @@ -163,26 +146,22 @@ export function SkeletonLoading({ isSidebarCollapsed, children, }: SkeletonLoadingProps) { - const { mode, isExpanded } = useSidebarStore() - return ( -
-
- {/* Skeleton Control Bar */} -
- -
+
+ {/* Skeleton Control Bar */} +
+ +
- {/* Real Control Bar */} -
- {children} -
+ {/* Real Control Bar */} +
+ {children}
{/* Real content will be rendered by children - sidebar will show its own loading state */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx deleted file mode 100644 index 285695a0c..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/components/toolbar-tabs/toolbar-tabs.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' - -interface ToolbarTabsProps { - activeTab: 'blocks' | 'tools' - onTabChange: (tab: 'blocks' | 'tools') => void -} - -export function ToolbarTabs({ activeTab, onTabChange }: ToolbarTabsProps) { - const blocksRef = useRef(null) - const toolsRef = useRef(null) - const [underlineStyle, setUnderlineStyle] = useState({ - width: 0, - transform: '', - }) - - useEffect(() => { - const activeRef = activeTab === 'blocks' ? blocksRef : toolsRef - if (activeRef.current) { - const rect = activeRef.current.getBoundingClientRect() - const parentRect = activeRef.current.parentElement?.getBoundingClientRect() - const offsetLeft = parentRect ? rect.left - parentRect.left : 0 - - setUnderlineStyle({ - width: rect.width, - transform: `translateX(${offsetLeft}px)`, - }) - } - }, [activeTab]) - - return ( -
-
- - -
- -
-
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx deleted file mode 100644 index d4558a038..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar.tsx +++ /dev/null @@ -1,176 +0,0 @@ -'use client' - -import React, { useCallback, useMemo, useState } from 'react' -import { PanelLeftClose, PanelRight, Search } from 'lucide-react' -import { useParams } from 'next/navigation' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' -import { getAllBlocks, getBlocksByCategory } from '@/blocks' -import type { BlockCategory } from '@/blocks/types' -import { useSidebarStore } from '@/stores/sidebar/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { ToolbarBlock } from './components/toolbar-block/toolbar-block' -import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' -import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block' -import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' - -interface ToolbarButtonProps { - onClick: () => void - className: string - children: React.ReactNode - tooltipContent: string - tooltipSide?: 'left' | 'right' | 'top' | 'bottom' -} - -const ToolbarButton = React.memo( - ({ onClick, className, children, tooltipContent, tooltipSide = 'right' }) => ( - - - - - {tooltipContent} - - ) -) - -ToolbarButton.displayName = 'ToolbarButton' - -export const Toolbar = React.memo(() => { - const params = useParams() - const workflowId = params?.id as string - - // Get the workspace ID from URL params - const { workflows } = useWorkflowRegistry() - const workspaceId = params.workspaceId as string - - const currentWorkflow = useMemo( - () => (workflowId ? workflows[workflowId] : null), - [workflowId, workflows] - ) - - const userPermissions = useUserPermissionsContext() - - const [activeTab, setActiveTab] = useState('blocks') - const [searchQuery, setSearchQuery] = useState('') - const { mode, isExpanded } = useSidebarStore() - - // In hover mode, act as if sidebar is always collapsed for layout purposes - const isSidebarCollapsed = useMemo( - () => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'), - [mode, isExpanded] - ) - - // State to track if toolbar is open - independent of sidebar state - const [isToolbarOpen, setIsToolbarOpen] = useState(true) - - const blocks = useMemo(() => { - const filteredBlocks = !searchQuery.trim() ? getBlocksByCategory(activeTab) : getAllBlocks() - - return filteredBlocks.filter((block) => { - if (block.type === 'starter' || block.hideFromToolbar) return false - - return ( - !searchQuery.trim() || - block.name.toLowerCase().includes(searchQuery.toLowerCase()) || - block.description.toLowerCase().includes(searchQuery.toLowerCase()) - ) - }) - }, [searchQuery, activeTab]) - - const handleOpenToolbar = useCallback(() => { - setIsToolbarOpen(true) - }, []) - - const handleCloseToolbar = useCallback(() => { - setIsToolbarOpen(false) - }, []) - - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - setSearchQuery(e.target.value) - }, []) - - const handleTabChange = useCallback((tab: BlockCategory) => { - setActiveTab(tab) - }, []) - - // Show toolbar button when it's closed, regardless of sidebar state - if (!isToolbarOpen) { - return ( - - - Open Toolbar - - ) - } - - return ( -
-
-
-
- - -
-
- - {!searchQuery && ( -
- -
- )} - - -
-
- {blocks.map((block) => ( - - ))} - {((activeTab === 'blocks' && !searchQuery) || - (searchQuery && 'loop'.includes(searchQuery.toLowerCase()))) && ( - - )} - {((activeTab === 'blocks' && !searchQuery) || - (searchQuery && 'parallel'.includes(searchQuery.toLowerCase()))) && ( - - )} -
-
-
- -
- - - Close Toolbar - -
-
-
- ) -}) - -Toolbar.displayName = 'Toolbar' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx index ed56ebae7..7bda3c424 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/date-input.tsx @@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import { useNotificationStore } from '@/stores/notifications/store' import { useSubBlockValue } from '../hooks/use-sub-block-value' interface DateInputProps { @@ -31,7 +30,6 @@ export function DateInput({ // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue - const addNotification = useNotificationStore((state) => state.addNotification) const date = value ? new Date(value) : undefined const isPastDate = React.useMemo(() => { @@ -47,10 +45,6 @@ export function DateInput({ if (selectedDate) { const today = new Date() today.setHours(0, 0, 0, 0) - - if (selectedDate < today) { - addNotification('error', 'Cannot start at a date in the past', blockId) - } } setStoreValue(selectedDate?.toISOString() || '') } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx index 4cc457e08..469800d77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-upload.tsx @@ -4,11 +4,13 @@ import { useRef, useState } from 'react' import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' -import { useNotificationStore } from '@/stores/notifications/store' +import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useSubBlockValue } from '../hooks/use-sub-block-value' +const logger = createLogger('FileUpload') + interface FileUploadProps { blockId: string subBlockId: string @@ -55,7 +57,6 @@ export function FileUpload({ const fileInputRef = useRef(null) // Stores - const { addNotification } = useNotificationStore() const { activeWorkflowId } = useWorkflowRegistry() // Use preview value when in preview mode, otherwise use store value @@ -110,8 +111,7 @@ export function FileUpload({ const file = files[i] // Check if adding this file would exceed the total limit if (existingTotalSize + totalNewSize + file.size > maxSizeInBytes) { - addNotification( - 'error', + logger.error( `Adding ${file.name} would exceed the maximum size limit of ${maxSize}MB`, activeWorkflowId ) @@ -248,14 +248,12 @@ export function FileUpload({ if (uploadedFiles.length > 0) { const uploadMethod = useDirectUpload ? 'direct' : 'server' if (uploadedFiles.length === 1) { - addNotification( - 'console', + logger.info( `${uploadedFiles[0].name} was uploaded successfully (${uploadMethod} upload)`, activeWorkflowId ) } else { - addNotification( - 'console', + logger.info( `Uploaded ${uploadedFiles.length} files successfully: ${uploadedFiles.map((f) => f.name).join(', ')} (${uploadMethod} upload)`, activeWorkflowId ) @@ -265,10 +263,9 @@ export function FileUpload({ // Send consolidated error notification if any if (uploadErrors.length > 0) { if (uploadErrors.length === 1) { - addNotification('error', uploadErrors[0], activeWorkflowId) + logger.error(uploadErrors[0], activeWorkflowId) } else { - addNotification( - 'error', + logger.error( `Failed to upload ${uploadErrors.length} files: ${uploadErrors.join('; ')}`, activeWorkflowId ) @@ -303,8 +300,7 @@ export function FileUpload({ useWorkflowStore.getState().triggerUpdate() } } catch (error) { - addNotification( - 'error', + logger.error( error instanceof Error ? error.message : 'Failed to upload file(s)', activeWorkflowId ) @@ -362,8 +358,7 @@ export function FileUpload({ useWorkflowStore.getState().triggerUpdate() } catch (error) { - addNotification( - 'error', + logger.error( error instanceof Error ? error.message : 'Failed to delete file from server', activeWorkflowId ) @@ -439,14 +434,9 @@ export function FileUpload({ // Show error notification if any deletions failed if (deletionResults.failures.length > 0) { if (deletionResults.failures.length === 1) { - addNotification( - 'error', - `Failed to delete file: ${deletionResults.failures[0]}`, - activeWorkflowId - ) + logger.error(`Failed to delete file: ${deletionResults.failures[0]}`, activeWorkflowId) } else { - addNotification( - 'error', + logger.error( `Failed to delete ${deletionResults.failures.length} files: ${deletionResults.failures.join('; ')}`, activeWorkflowId ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts index 78b7c163f..a5a3ce6b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation.ts @@ -1,6 +1,5 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@/lib/logs/console-logger' -import { useNotificationStore } from '@/stores/notifications/store' interface ChatMessage { role: 'user' | 'assistant' | 'system' @@ -44,7 +43,6 @@ export function useCodeGeneration({ const [promptInputValue, setPromptInputValue] = useState('') const [error, setError] = useState(null) const [isStreaming, setIsStreaming] = useState(false) - const addNotification = useNotificationStore((state) => state.addNotification) // State for conversation history const [conversationHistory, setConversationHistory] = useState([]) @@ -58,7 +56,6 @@ export function useCodeGeneration({ if (!prompt) { const errorMessage = 'Prompt cannot be empty.' setError(errorMessage) - addNotification('error', errorMessage, null) return } @@ -92,7 +89,6 @@ export function useCodeGeneration({ logger.info('Code generation successful', { generationType }) onGeneratedContent(result.generatedContent) - addNotification('info', 'Content generated successfully!', null) setIsPromptOpen(false) setIsPromptVisible(false) @@ -110,7 +106,6 @@ export function useCodeGeneration({ const errorMessage = err.message || 'An unknown error occurred during generation.' logger.error('Code generation failed', { error: errorMessage }) setError(errorMessage) - addNotification('error', `Generation failed: ${errorMessage}`, null) } finally { setIsLoading(false) } @@ -121,7 +116,6 @@ export function useCodeGeneration({ if (!prompt) { const errorMessage = 'Prompt cannot be empty.' setError(errorMessage) - addNotification('error', errorMessage, null) return } @@ -223,7 +217,6 @@ export function useCodeGeneration({ if (onGenerationComplete) { onGenerationComplete(currentPrompt, fullContent) } - addNotification('info', 'Content generated successfully!', null) break } } catch (jsonError: any) { @@ -251,7 +244,6 @@ export function useCodeGeneration({ const errorMessage = err.message || 'An unknown error occurred during streaming.' logger.error('Streaming code generation failed', { error: errorMessage }) setError(errorMessage) - addNotification('error', `Generation failed: ${errorMessage}`, null) } finally { setIsLoading(false) setIsStreaming(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index d136191ac..b63272ef1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -9,9 +9,7 @@ import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/t import { Serializer } from '@/serializer' import type { SerializedWorkflow } from '@/serializer/types' import { useExecutionStore } from '@/stores/execution/store' -import { useNotificationStore } from '@/stores/notifications/store' import { useConsoleStore } from '@/stores/panel/console/store' -import { usePanelStore } from '@/stores/panel/store' import { useVariablesStore } from '@/stores/panel/variables/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useGeneralStore } from '@/stores/settings/general/store' @@ -37,18 +35,16 @@ interface ExecutorOptions { } } -// Interface for stream error handling -interface StreamError extends Error { - name: string - message: string +// Debug state validation result +interface DebugValidationResult { + isValid: boolean + error?: string } export function useWorkflowExecution() { const { blocks, edges, loops, parallels } = useWorkflowStore() const { activeWorkflowId } = useWorkflowRegistry() - const { addNotification } = useNotificationStore() const { toggleConsole } = useConsoleStore() - const { togglePanel, setActiveTab, activeTab } = usePanelStore() const { getAllVariables } = useEnvironmentStore() const { isDebugModeEnabled } = useGeneralStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() @@ -67,6 +63,123 @@ export function useWorkflowExecution() { } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) + /** + * Validates debug state before performing debug operations + */ + const validateDebugState = useCallback((): DebugValidationResult => { + if (!executor || !debugContext || pendingBlocks.length === 0) { + const missing = [] + if (!executor) missing.push('executor') + if (!debugContext) missing.push('debugContext') + if (pendingBlocks.length === 0) missing.push('pendingBlocks') + + return { + isValid: false, + error: `Cannot perform debug operation - missing: ${missing.join(', ')}. Try restarting debug mode.`, + } + } + return { isValid: true } + }, [executor, debugContext, pendingBlocks]) + + /** + * Resets all debug-related state + */ + const resetDebugState = useCallback(() => { + setIsExecuting(false) + setIsDebugging(false) + setDebugContext(null) + setExecutor(null) + setPendingBlocks([]) + setActiveBlocks(new Set()) + + // Reset debug mode setting if it was enabled + if (isDebugModeEnabled) { + useGeneralStore.getState().toggleDebugMode() + } + }, [ + setIsExecuting, + setIsDebugging, + setDebugContext, + setExecutor, + setPendingBlocks, + setActiveBlocks, + isDebugModeEnabled, + ]) + + /** + * Checks if debug session is complete based on execution result + */ + const isDebugSessionComplete = useCallback((result: ExecutionResult): boolean => { + return ( + !result.metadata?.isDebugSession || + !result.metadata.pendingBlocks || + result.metadata.pendingBlocks.length === 0 + ) + }, []) + + /** + * Handles debug session completion + */ + const handleDebugSessionComplete = useCallback( + async (result: ExecutionResult) => { + logger.info('Debug session complete') + setExecutionResult(result) + + // Persist logs + await persistLogs(uuidv4(), result) + + // Reset debug state + resetDebugState() + }, + [activeWorkflowId, resetDebugState] + ) + + /** + * Handles debug session continuation + */ + const handleDebugSessionContinuation = useCallback( + (result: ExecutionResult) => { + logger.info('Debug step completed, next blocks pending', { + nextPendingBlocks: result.metadata?.pendingBlocks?.length || 0, + }) + + // Update debug context and pending blocks + if (result.metadata?.context) { + setDebugContext(result.metadata.context) + } + if (result.metadata?.pendingBlocks) { + setPendingBlocks(result.metadata.pendingBlocks) + } + }, + [setDebugContext, setPendingBlocks] + ) + + /** + * Handles debug execution errors + */ + const handleDebugExecutionError = useCallback( + async (error: any, operation: string) => { + logger.error(`Debug ${operation} Error:`, error) + + const errorMessage = error instanceof Error ? error.message : String(error) + const errorResult = { + success: false, + output: {}, + error: errorMessage, + logs: debugContext?.blockLogs || [], + } + + setExecutionResult(errorResult) + + // Persist logs + await persistLogs(uuidv4(), errorResult) + + // Reset debug state + resetDebugState() + }, + [debugContext, activeWorkflowId, resetDebugState] + ) + const persistLogs = async ( executionId: string, result: ExecutionResult, @@ -129,35 +242,21 @@ export function useWorkflowExecution() { } const handleRunWorkflow = useCallback( - async (workflowInput?: any) => { + async (workflowInput?: any, enableDebug = false) => { if (!activeWorkflowId) return // Reset execution result and set execution state setExecutionResult(null) setIsExecuting(true) - // Set debug mode if it's enabled in settings - if (isDebugModeEnabled) { + // Set debug mode only if explicitly requested + if (enableDebug) { setIsDebugging(true) } - // Check if panel is open and open it if not - const isPanelOpen = usePanelStore.getState().isOpen - if (!isPanelOpen) { - togglePanel() - } - - // Set active tab to console - if (activeTab !== 'console' && activeTab !== 'chat') { - setActiveTab('console') - } - // Determine if this is a chat execution const isChatExecution = - activeTab === 'chat' && - workflowInput && - typeof workflowInput === 'object' && - 'input' in workflowInput + workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput // For chat executions, we'll use a streaming approach if (isChatExecution) { @@ -246,13 +345,6 @@ export function useWorkflowExecution() { } } catch (error: any) { controller.error(error) - if (activeWorkflowId) { - addNotification( - 'error', - `Workflow execution failed: ${error.message}`, - activeWorkflowId - ) - } } finally { controller.close() setIsExecuting(false) @@ -288,15 +380,6 @@ export function useWorkflowExecution() { ;(result.metadata as any).source = 'chat' } - if (activeWorkflowId) { - addNotification( - result.success ? 'console' : 'error', - result.success - ? 'Workflow completed successfully' - : `Workflow execution failed: ${result.error || 'Unknown error'}`, - activeWorkflowId - ) - } persistLogs(executionId, result).catch((err) => { logger.error('Error persisting logs:', { error: err }) }) @@ -316,11 +399,7 @@ export function useWorkflowExecution() { edges, loops, parallels, - addNotification, toggleConsole, - togglePanel, - setActiveTab, - activeTab, getAllVariables, getVariablesByWorkflowId, isDebugModeEnabled, @@ -379,10 +458,7 @@ export function useWorkflowExecution() { // Determine if this is a chat execution const isChatExecution = - activeTab === 'chat' && - workflowInput && - typeof workflowInput === 'object' && - 'input' in workflowInput + workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput // If this is a chat execution, get the selected outputs let selectedOutputIds: string[] | undefined @@ -476,13 +552,6 @@ export function useWorkflowExecution() { notificationMessage += `: ${errorMessage}` } - try { - addNotification('error', notificationMessage, activeWorkflowId || '') - } catch (notificationError) { - logger.error('Error showing error notification:', notificationError) - console.error('Workflow execution failed:', errorMessage) - } - return errorResult } @@ -490,186 +559,73 @@ export function useWorkflowExecution() { * Handles stepping through workflow execution in debug mode */ const handleStepDebug = useCallback(async () => { - // Log debug information logger.info('Step Debug requested', { hasExecutor: !!executor, hasContext: !!debugContext, pendingBlockCount: pendingBlocks.length, }) - if (!executor || !debugContext || pendingBlocks.length === 0) { - logger.error('Cannot step debug - missing required state', { - executor: !!executor, - debugContext: !!debugContext, - pendingBlocks: pendingBlocks.length, - }) - - // Show error notification - addNotification( - 'error', - 'Cannot step through debugging - missing execution state. Try restarting debug mode.', - activeWorkflowId || '' - ) - - // Reset debug state - setIsDebugging(false) - setIsExecuting(false) - setActiveBlocks(new Set()) + // Validate debug state + const validation = validateDebugState() + if (!validation.isValid) { + resetDebugState() return } try { console.log('Executing debug step with blocks:', pendingBlocks) - - // Execute the next step with the pending blocks - const result = await executor.continueExecution(pendingBlocks, debugContext) - + const result = await executor!.continueExecution(pendingBlocks, debugContext!) console.log('Debug step execution result:', result) - // Save the new context in the store - if (result.metadata?.context) { - setDebugContext(result.metadata.context) - } - - // Check if the debug session is complete - if ( - !result.metadata?.isDebugSession || - !result.metadata.pendingBlocks || - result.metadata.pendingBlocks.length === 0 - ) { - logger.info('Debug session complete') - // Debug session complete - setExecutionResult(result) - - // Show completion notification - addNotification( - result.success ? 'console' : 'error', - result.success - ? 'Workflow completed successfully' - : `Workflow execution failed: ${result.error}`, - activeWorkflowId || '' - ) - - // Persist logs - await persistLogs(uuidv4(), result) - - // Reset debug state - setIsExecuting(false) - setIsDebugging(false) - setDebugContext(null) - setExecutor(null) - setPendingBlocks([]) - setActiveBlocks(new Set()) + if (isDebugSessionComplete(result)) { + await handleDebugSessionComplete(result) } else { - // Debug session continues - update UI with new pending blocks - logger.info('Debug step completed, next blocks pending', { - nextPendingBlocks: result.metadata.pendingBlocks.length, - }) - - // This is critical - ensure we update the pendingBlocks in the store - setPendingBlocks(result.metadata.pendingBlocks) + handleDebugSessionContinuation(result) } } catch (error: any) { - logger.error('Debug Step Error:', error) - - const errorMessage = error instanceof Error ? error.message : String(error) - - // Create error result - const errorResult = { - success: false, - output: {}, - error: errorMessage, - logs: debugContext.blockLogs, - } - - setExecutionResult(errorResult) - - // Safely show error notification - try { - addNotification('error', `Debug step failed: ${errorMessage}`, activeWorkflowId || '') - } catch (notificationError) { - logger.error('Error showing step error notification:', notificationError) - console.error('Debug step failed:', errorMessage) - } - - // Persist logs - await persistLogs(uuidv4(), errorResult) - - // Reset debug state - setIsExecuting(false) - setIsDebugging(false) - setDebugContext(null) - setExecutor(null) - setPendingBlocks([]) - setActiveBlocks(new Set()) + await handleDebugExecutionError(error, 'step') } }, [ executor, debugContext, pendingBlocks, activeWorkflowId, - addNotification, - setIsExecuting, - setIsDebugging, - setPendingBlocks, - setDebugContext, - setExecutor, - setActiveBlocks, + validateDebugState, + resetDebugState, + isDebugSessionComplete, + handleDebugSessionComplete, + handleDebugSessionContinuation, + handleDebugExecutionError, ]) /** * Handles resuming execution in debug mode until completion */ const handleResumeDebug = useCallback(async () => { - // Log debug information logger.info('Resume Debug requested', { hasExecutor: !!executor, hasContext: !!debugContext, pendingBlockCount: pendingBlocks.length, }) - if (!executor || !debugContext || pendingBlocks.length === 0) { - logger.error('Cannot resume debug - missing required state', { - executor: !!executor, - debugContext: !!debugContext, - pendingBlocks: pendingBlocks.length, - }) - - // Show error notification - addNotification( - 'error', - 'Cannot resume debugging - missing execution state. Try restarting debug mode.', - activeWorkflowId || '' - ) - - // Reset debug state - setIsDebugging(false) - setIsExecuting(false) - setActiveBlocks(new Set()) + // Validate debug state + const validation = validateDebugState() + if (!validation.isValid) { + resetDebugState() return } try { - // Show a notification that we're resuming execution - try { - addNotification( - 'info', - 'Resuming workflow execution until completion', - activeWorkflowId || '' - ) - } catch (notificationError) { - logger.error('Error showing resume notification:', notificationError) - console.info('Resuming workflow execution until completion') - } + logger.info('Resuming workflow execution until completion') let currentResult: ExecutionResult = { success: true, output: {}, - logs: debugContext.blockLogs, + logs: debugContext!.blockLogs, } // Create copies to avoid mutation issues - let currentContext = { ...debugContext } + let currentContext = { ...debugContext! } let currentPendingBlocks = [...pendingBlocks] console.log('Starting resume execution with blocks:', currentPendingBlocks) @@ -683,7 +639,7 @@ export function useWorkflowExecution() { `Resume iteration ${iterationCount + 1}, executing ${currentPendingBlocks.length} blocks` ) - currentResult = await executor.continueExecution(currentPendingBlocks, currentContext) + currentResult = await executor!.continueExecution(currentPendingBlocks, currentContext) logger.info('Resume iteration result:', { success: currentResult.success, @@ -696,7 +652,7 @@ export function useWorkflowExecution() { currentContext = currentResult.metadata.context } else { logger.info('No context in result, ending resume') - break // No context means we're done + break } // Update pending blocks for next iteration @@ -704,7 +660,7 @@ export function useWorkflowExecution() { currentPendingBlocks = currentResult.metadata.pendingBlocks } else { logger.info('No pending blocks in result, ending resume') - break // No pending blocks means we're done + break } // If we don't have a debug session anymore, we're done @@ -725,99 +681,29 @@ export function useWorkflowExecution() { success: currentResult.success, }) - // Final result is the last step's result - setExecutionResult(currentResult) - - // Show completion notification - try { - addNotification( - currentResult.success ? 'console' : 'error', - currentResult.success - ? 'Workflow completed successfully' - : `Workflow execution failed: ${currentResult.error}`, - activeWorkflowId || '' - ) - } catch (notificationError) { - logger.error('Error showing completion notification:', notificationError) - console.info('Workflow execution completed') - } - - // Persist logs - await persistLogs(uuidv4(), currentResult) - - // Reset debug state - setIsExecuting(false) - setIsDebugging(false) - setDebugContext(null) - setExecutor(null) - setPendingBlocks([]) - setActiveBlocks(new Set()) + // Handle completion + await handleDebugSessionComplete(currentResult) } catch (error: any) { - logger.error('Debug Resume Error:', error) - - const errorMessage = error instanceof Error ? error.message : String(error) - - // Create error result - const errorResult = { - success: false, - output: {}, - error: errorMessage, - logs: debugContext.blockLogs, - } - - setExecutionResult(errorResult) - - // Safely show error notification - try { - addNotification('error', `Resume execution failed: ${errorMessage}`, activeWorkflowId || '') - } catch (notificationError) { - logger.error('Error showing resume error notification:', notificationError) - console.error('Resume execution failed:', errorMessage) - } - - // Persist logs - await persistLogs(uuidv4(), errorResult) - - // Reset debug state - setIsExecuting(false) - setIsDebugging(false) - setDebugContext(null) - setExecutor(null) - setPendingBlocks([]) - setActiveBlocks(new Set()) + await handleDebugExecutionError(error, 'resume') } }, [ executor, debugContext, pendingBlocks, activeWorkflowId, - addNotification, - setIsExecuting, - setIsDebugging, - setPendingBlocks, - setDebugContext, - setExecutor, - setActiveBlocks, + validateDebugState, + resetDebugState, + handleDebugSessionComplete, + handleDebugExecutionError, ]) /** * Handles cancelling the current debugging session */ const handleCancelDebug = useCallback(() => { - setIsExecuting(false) - setIsDebugging(false) - setDebugContext(null) - setExecutor(null) - setPendingBlocks([]) - setActiveBlocks(new Set()) - }, [ - setIsExecuting, - setIsDebugging, - setDebugContext, - setExecutor, - setPendingBlocks, - setActiveBlocks, - ]) + logger.info('Debug session cancelled') + resetDebugState() + }, [resetDebugState]) return { isExecuting, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f5d64a9b0..0b1e45a4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -15,20 +15,15 @@ import { createLogger } from '@/lib/logs/console-logger' import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' -import { NotificationList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node' -import { SkeletonLoading } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/skeleton-loading/skeleton-loading' -import { Toolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/toolbar/toolbar' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions' import { useExecutionStore } from '@/stores/execution/store' -import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' -import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { WorkflowBlock } from './components/workflow-block/workflow-block' @@ -70,13 +65,6 @@ interface BlockData { const WorkflowContent = React.memo(() => { // State const [isWorkflowReady, setIsWorkflowReady] = useState(false) - const { mode, isExpanded } = useSidebarStore() - - // In hover mode, act as if sidebar is always collapsed for layout purposes - const isSidebarCollapsed = useMemo( - () => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'), - [mode, isExpanded] - ) // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) @@ -122,7 +110,6 @@ const WorkflowContent = React.memo(() => { collaborativeSetSubblockValue, } = useCollaborativeWorkflow() - const { markAllAsRead } = useNotificationStore() const { resetLoaded: resetVariablesLoaded } = useVariablesStore() // Execution and debug mode state @@ -265,7 +252,6 @@ const WorkflowContent = React.memo(() => { ...orientationConfig, alignByLayer: true, animationDuration: 500, // Smooth 500ms animation - isSidebarCollapsed, handleOrientation: detectedOrientation, // Explicitly set the detected orientation onComplete: (finalPositions) => { // Emit collaborative updates for final positions after animation completes @@ -291,7 +277,6 @@ const WorkflowContent = React.memo(() => { storeUpdateBlockPosition, collaborativeUpdateBlockPosition, fitView, - isSidebarCollapsed, resizeLoopNodesWrapper, getOrientationConfig, ]) @@ -904,8 +889,6 @@ const WorkflowContent = React.memo(() => { // Don't reset variables cache if we're not actually switching workflows setActiveWorkflow(currentId) } - - markAllAsRead(currentId) } validateAndNavigate() @@ -916,7 +899,6 @@ const WorkflowContent = React.memo(() => { setActiveWorkflow, createWorkflow, router, - markAllAsRead, resetVariablesLoaded, ]) @@ -1504,19 +1486,18 @@ const WorkflowContent = React.memo(() => { if (showSkeletonUI) { return (
- - 0} /> - - -
+
-
+ 0} />
- +
@@ -1525,18 +1506,14 @@ const WorkflowContent = React.memo(() => { return (
-
- 0} /> -
- -
+
-
+ {/* Floating Control Bar */} + 0} /> + { autoPanOnConnect={userPermissions.canEdit} autoPanOnNodeDrag={userPermissions.canEdit} > - +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx index 07891f70f..7e887cf5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx @@ -1,22 +1,21 @@ 'use client' -import { useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { logger } from '@sentry/nextjs' -import { ChevronRight, File, Folder, Plus, Upload } from 'lucide-react' -import { useParams } from 'next/navigation' +import { File, Folder, Plus, Upload } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { useFolderStore } from '@/stores/folders/store' import { ImportControls, type ImportControlsRef } from './import-controls' interface CreateMenuProps { - onCreateWorkflow: (folderId?: string) => void + onCreateWorkflow: (folderId?: string) => Promise isCollapsed?: boolean isCreatingWorkflow?: boolean } @@ -29,10 +28,11 @@ export function CreateMenu({ const [showFolderDialog, setShowFolderDialog] = useState(false) const [folderName, setFolderName] = useState('') const [isCreating, setIsCreating] = useState(false) - const [isHoverOpen, setIsHoverOpen] = useState(false) - const [isImportSubmenuOpen, setIsImportSubmenuOpen] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [pressTimer, setPressTimer] = useState(null) const params = useParams() + const router = useRouter() const workspaceId = params.workspaceId as string const { createFolder } = useFolderStore() const userPermissions = useUserPermissionsContext() @@ -40,22 +40,111 @@ export function CreateMenu({ // Ref for the file input that will be used by ImportControls const importControlsRef = useRef(null) - const handleCreateWorkflow = () => { - setIsHoverOpen(false) - onCreateWorkflow() - } + const handleCreateWorkflow = useCallback(async () => { + if (isCreatingWorkflow) { + logger.info('Workflow creation already in progress, ignoring request') + return + } - const handleCreateFolder = () => { - setIsHoverOpen(false) + setIsOpen(false) + + try { + // Call the parent's workflow creation function and wait for the ID + const workflowId = await onCreateWorkflow() + + // Navigate to the new workflow + if (workflowId) { + router.push(`/workspace/${workspaceId}/w/${workflowId}`) + } + } catch (error) { + logger.error('Error creating workflow:', { error }) + } + }, [onCreateWorkflow, isCreatingWorkflow, router, workspaceId]) + + const handleCreateFolder = useCallback(() => { + setIsOpen(false) setShowFolderDialog(true) - } + }, []) - const handleUploadYaml = () => { - setIsHoverOpen(false) - setIsImportSubmenuOpen(false) + const handleImportWorkflow = useCallback(() => { + setIsOpen(false) // Trigger the file upload from ImportControls component importControlsRef.current?.triggerFileUpload() - } + }, []) + + // Handle direct click for workflow creation + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + // Clear any existing press timer + if (pressTimer) { + window.clearTimeout(pressTimer) + setPressTimer(null) + } + + // Direct workflow creation on click + handleCreateWorkflow() + }, + [handleCreateWorkflow, pressTimer] + ) + + // Handle hover to show popover + const handleMouseEnter = useCallback(() => { + setIsOpen(true) + }, []) + + const handleMouseLeave = useCallback(() => { + if (pressTimer) { + window.clearTimeout(pressTimer) + setPressTimer(null) + } + setIsOpen(false) + }, [pressTimer]) + + // Handle dropdown content hover + const handlePopoverMouseEnter = useCallback(() => { + setIsOpen(true) + }, []) + + const handlePopoverMouseLeave = useCallback(() => { + setIsOpen(false) + }, []) + + // Handle right-click to show popover + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsOpen(true) + }, []) + + // Handle long press to show popover + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button === 0) { + // Left mouse button + const timer = setTimeout(() => { + setIsOpen(true) + setPressTimer(null) + }, 500) // 500ms for long press + setPressTimer(timer) + } + }, []) + + const handleMouseUp = useCallback(() => { + if (pressTimer) { + window.clearTimeout(pressTimer) + setPressTimer(null) + } + }, [pressTimer]) + + useEffect(() => { + return () => { + if (pressTimer) { + window.clearTimeout(pressTimer) + } + } + }, [pressTimer]) const handleFolderSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -83,25 +172,23 @@ export function CreateMenu({ return ( <> - + setIsHoverOpen(true)} - onMouseLeave={() => setIsHoverOpen(false)} onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} + onMouseEnter={handlePopoverMouseEnter} + onMouseLeave={handlePopoverMouseLeave} > {userPermissions.canEdit && ( - <> - - - - - - - setIsImportSubmenuOpen(true)} - onMouseLeave={() => setIsImportSubmenuOpen(false)} - onOpenAutoFocus={(e) => e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - - - - + )} @@ -191,10 +244,7 @@ export function CreateMenu({ { - setIsHoverOpen(false) - setIsImportSubmenuOpen(false) - }} + onClose={() => setIsOpen(false)} /> {/* Folder creation dialog */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx index 1d555aed5..6c2fca752 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx @@ -1,19 +1,7 @@ 'use client' import { forwardRef, useImperativeHandle, useRef, useState } from 'react' -import { AlertCircle, CheckCircle } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Textarea } from '@/components/ui/textarea' import { createLogger } from '@/lib/logs/console-logger' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -33,7 +21,6 @@ export interface ImportControlsRef { export const ImportControls = forwardRef( ({ disabled = false, onClose }, ref) => { const [isImporting, setIsImporting] = useState(false) - const [showYamlDialog, setShowYamlDialog] = useState(false) const [yamlContent, setYamlContent] = useState('') const [importResult, setImportResult] = useState<{ success: boolean @@ -66,7 +53,9 @@ export const ImportControls = forwardRef try { const content = await file.text() setYamlContent(content) - setShowYamlDialog(true) + + // Import directly without showing the modal + await handleDirectImport(content) onClose?.() } catch (error) { logger.error('Failed to read file:', error) @@ -85,8 +74,8 @@ export const ImportControls = forwardRef } } - const handleYamlImport = async () => { - if (!yamlContent.trim()) { + const handleDirectImport = async (content: string) => { + if (!content.trim()) { setImportResult({ success: false, errors: ['YAML content is required'], @@ -100,7 +89,7 @@ export const ImportControls = forwardRef try { // First validate the YAML without importing - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(content) if (!yamlWorkflow || parseErrors.length > 0) { setImportResult({ @@ -121,13 +110,12 @@ export const ImportControls = forwardRef // Import the YAML into the new workflow BEFORE navigation (creates complete state and saves directly to DB) // This avoids timing issues with workflow reload during navigation const result = await importWorkflowFromYaml( - yamlContent, + content, { addBlock: collaborativeAddBlock, addEdge: collaborativeAddEdge, applyAutoLayout: () => { - // Trigger auto layout - window.dispatchEvent(new CustomEvent('trigger-auto-layout')) + // Do nothing - auto layout should not run during import }, setSubBlockValue: (blockId: string, subBlockId: string, value: unknown) => { // Use the collaborative function - the same one called when users type into fields @@ -151,7 +139,6 @@ export const ImportControls = forwardRef if (result.success) { setYamlContent('') - setShowYamlDialog(false) logger.info('YAML import completed successfully') } } catch (error) { @@ -166,8 +153,6 @@ export const ImportControls = forwardRef } } - const isDisabled = disabled || isImporting - return ( <> {/* Hidden file input */} @@ -178,103 +163,6 @@ export const ImportControls = forwardRef onChange={handleFileUpload} className='hidden' /> - - {/* YAML Import Dialog */} - - - - Import Workflow from YAML - - Review the YAML content below and click "Import Workflow" to create a new workflow - with the blocks and connections defined in the YAML. - - - -
-