feat(platform): new UI and templates (#639)

* improvement: control bar

* improvement: debug flow

* improvement: control bar hovers and skeleton loading

* improvement: completed control bar

* improvement: panel tab selector complete

* refactor: deleted notifications and history dropdown

* improvement: chat UI complete

* fix: tab change on control bar run

* improvement: finshed console (audio display not working)

* fix: text wrapping in console content

* improvement: audio UI

* improvement: image display

* feat: add input to console

* improvement: code input and showing input on errors

* feat: download chat and console

* improvement: expandable panel and console visibility

* improvement: empty state UI

* improvement: finished variables

* fix: image in console entry

* improvement: sidebar and templates ui

* feat: uploading and fetching templates

* improvement: sidebar and control bar

* improvement: templates

* feat: templates done
This commit is contained in:
Emir Karabeg
2025-07-15 09:43:36 -07:00
committed by GitHub
parent 0c5e70fc23
commit 487f0328c9
114 changed files with 27892 additions and 8295 deletions

View File

@@ -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)
}

View File

@@ -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 })
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
<div className='w-full max-w-md'>{children}</div>
</div>
{/* Notifications */}
<div className='fixed right-4 bottom-4 z-50'>
<NotificationList />
</div>
</main>
)
}

View File

@@ -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<string | null>(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

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 })
}
}

View File

@@ -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<string, any[]>
} = {
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<string>()
// 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)
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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<string, string>()
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 })
}
}

View File

@@ -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<boolean>`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<number>`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 })
}
}

View File

@@ -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<string, Variable>) || {}
// 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))

View File

@@ -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

View File

@@ -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 */
}

View File

@@ -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 (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
{/* Header with Breadcrumbs */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
@@ -78,7 +72,7 @@ export function DocumentLoading({
</div>
{/* Table container */}
<ChunkTableSkeleton isSidebarCollapsed={isSidebarCollapsed} rowCount={8} />
<ChunkTableSkeleton isSidebarCollapsed={false} rowCount={8} />
</div>
</div>
</div>

View File

@@ -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 (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
<KnowledgeHeader breadcrumbs={errorBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<div className='text-center'>
@@ -382,9 +374,7 @@ export function Document({
}
return (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
{/* Fixed Header with Breadcrumbs */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
@@ -463,7 +453,7 @@ export function Document({
<colgroup>
<col className='w-[5%]' />
<col className='w-[8%]' />
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
<col className='w-[55%]' />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
@@ -509,7 +499,7 @@ export function Document({
<colgroup>
<col className='w-[5%]' />
<col className='w-[8%]' />
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
<col className='w-[55%]' />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
@@ -602,7 +592,7 @@ export function Document({
</tr>
))
) : (
paginatedChunks.map((chunk) => (
paginatedChunks.map((chunk: ChunkData) => (
<tr
key={chunk.id}
className='cursor-pointer border-b transition-colors hover:bg-accent/30'

View File

@@ -36,7 +36,6 @@ import { PrimaryButton } from '@/app/workspace/[workspaceId]/knowledge/component
import { SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components/search-input/search-input'
import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../components/knowledge-header/knowledge-header'
import { KnowledgeBaseLoading } from './components/knowledge-base-loading/knowledge-base-loading'
import { UploadModal } from './components/upload-modal/upload-modal'
@@ -120,7 +119,6 @@ export function KnowledgeBase({
id,
knowledgeBaseName: passedKnowledgeBaseName,
}: KnowledgeBaseProps) {
const { mode, isExpanded } = useSidebarStore()
const { removeKnowledgeBase } = useKnowledgeStore()
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -158,9 +156,6 @@ export function KnowledgeBase({
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
})
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
@@ -631,9 +626,7 @@ export function KnowledgeBase({
]
return (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
<KnowledgeHeader breadcrumbs={errorBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<div className='text-center'>
@@ -651,9 +644,7 @@ export function KnowledgeBase({
}
return (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
{/* Fixed Header with Breadcrumbs */}
<KnowledgeHeader
breadcrumbs={breadcrumbs}
@@ -711,11 +702,11 @@ export function KnowledgeBase({
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[24%]' />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[16%]' />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
@@ -764,11 +755,11 @@ export function KnowledgeBase({
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[24%]' />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[16%]' />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>

View File

@@ -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 (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
{/* Fixed Header with Breadcrumbs */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
@@ -70,7 +64,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
</div>
{/* Table container */}
<DocumentTableSkeleton isSidebarCollapsed={isSidebarCollapsed} rowCount={8} />
<DocumentTableSkeleton isSidebarCollapsed={false} rowCount={8} />
</div>
</div>
</div>

View File

@@ -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 (
<>
<div
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-screen flex-col pl-64'>
{/* Header */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />

View File

@@ -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 (
<div
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-screen flex-col pl-64'>
{/* Header */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />

View File

@@ -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<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
<div className='flex h-[100vh] flex-col pl-64'>
{/* Add the animation styles */}
<style jsx global>
{selectedRowAnimation}

View File

@@ -1,47 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks/use-debounce'
interface ControlBarProps {
setSearchQuery: (query: string) => void
}
/**
* Control bar for marketplace page - includes search functionality
*/
export function ControlBar({ setSearchQuery }: ControlBarProps) {
const [localSearchQuery, setLocalSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(localSearchQuery, 300)
// Update parent component when debounced search query changes
useEffect(() => {
setSearchQuery(debouncedSearchQuery)
}, [debouncedSearchQuery, setSearchQuery])
return (
<div className='flex h-16 w-full items-center justify-between border-b bg-background px-6 transition-all duration-300'>
{/* Left Section - Search */}
<div className='relative w-[400px]'>
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<Search className='h-4 w-4 text-muted-foreground' />
</div>
<Input
type='search'
placeholder='Search workflows...'
className='h-9 pl-10'
value={localSearchQuery}
onChange={(e) => setLocalSearchQuery(e.target.value)}
/>
</div>
{/* Middle Section - Reserved for future use */}
<div className='flex-1' />
{/* Right Section - Reserved for future use */}
<div className='flex items-center gap-3' />
</div>
)
}

View File

@@ -1,36 +0,0 @@
'use client'
import { motion } from 'framer-motion'
import { AlertCircle } from 'lucide-react'
/**
* ErrorMessageProps interface - defines the properties for the ErrorMessage component
* @property {string | null} message - The error message to display, or null if no error
*/
interface ErrorMessageProps {
message: string | null
}
/**
* ErrorMessage component - Displays an error message with animation
* Only renders when a message is provided, otherwise returns null
* Uses Framer Motion for smooth entrance animation
*/
export function ErrorMessage({ message }: ErrorMessageProps) {
// Don't render anything if there's no message
if (!message) return null
return (
<motion.div
className='mb-8 rounded-md border border-red-200 bg-red-50 p-4 text-red-700'
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<p className='flex items-center'>
<AlertCircle className='mr-2 h-4 w-4' />
{message}
</p>
</motion.div>
)
}

View File

@@ -1,31 +0,0 @@
'use client'
import { forwardRef, type ReactNode } from 'react'
/**
* SectionProps interface - defines the properties for the Section component
* @property {string} title - The heading text for the section
* @property {string} id - The ID for the section (used for scroll targeting)
* @property {ReactNode} children - The content to be rendered inside the section
*/
interface SectionProps {
title: string
id: string
children: ReactNode
}
/**
* Section component - Renders a section with a title and content
* Used to organize different categories of workflows in the marketplace
* Implements forwardRef to allow parent components to access the DOM node for scrolling
*/
export const Section = forwardRef<HTMLDivElement, SectionProps>(({ title, id, children }, ref) => {
return (
<div ref={ref} id={id} className='mb-12 scroll-mt-14'>
<h2 className='mb-6 font-medium text-lg capitalize'>{title}</h2>
{children}
</div>
)
})
Section.displayName = 'Section'

View File

@@ -1,62 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Clock, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CATEGORIES, getCategoryIcon, getCategoryLabel } from '../../constants/categories'
export type MarketplaceCategory = 'popular' | 'programming' | 'marketing' | 'all'
interface ToolbarProps {
scrollToSection: (sectionId: string) => void
activeSection: string | null
}
// Map of special section icons
const specialIcons: Record<string, React.ReactNode> = {
popular: <Star className='mr-2 h-4 w-4' />,
recent: <Clock className='mr-2 h-4 w-4' />,
}
export function Toolbar({ scrollToSection, activeSection }: ToolbarProps) {
const [categories, setCategories] = useState<string[]>([])
// Set categories including special sections
useEffect(() => {
// Start with special sections like 'popular' and 'recent'
const specialSections = ['popular', 'recent']
// Add categories from centralized definitions
const categoryValues = CATEGORIES.map((cat) => cat.value)
// Put special sections first, then regular categories
const allCategories = [...specialSections, ...categoryValues]
setCategories(allCategories)
}, [])
return (
<div className='h-full w-60 overflow-auto border-r p-4'>
<h2 className='mb-4 pl-2 font-medium text-sm'>Categories</h2>
<nav className='space-y-1'>
{categories.map((category) => (
<Button
key={category}
variant='ghost'
className={`w-full justify-start px-2 py-2 font-medium text-muted-foreground text-sm capitalize transition-colors hover:text-foreground ${
activeSection === category ? 'bg-accent text-foreground' : 'hover:bg-accent/50'
}`}
onClick={() => scrollToSection(category)}
>
{specialIcons[category] || getCategoryIcon(category)}
{category === 'popular'
? 'Popular'
: category === 'recent'
? 'Recent'
: getCategoryLabel(category)}
</Button>
))}
</nav>
</div>
)
}

View File

@@ -1,36 +0,0 @@
'use client'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
/**
* WorkflowCardSkeleton - Loading placeholder component for workflow cards
* Displays a skeleton UI while workflow data is being fetched
* Maintains the same structure as WorkflowCard for consistent layout during loading
*/
export function WorkflowCardSkeleton() {
return (
<Card className='flex h-full flex-col overflow-hidden'>
{/* Thumbnail area skeleton */}
<div className='relative h-40 overflow-hidden bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-700'>
<Skeleton className='h-full w-full' />
</div>
<div className='flex flex-grow flex-col'>
{/* Title skeleton */}
<CardHeader className='p-4 pb-2'>
<Skeleton className='h-4 w-3/4' />
</CardHeader>
{/* Description skeleton */}
<CardContent className='flex flex-grow flex-col p-4 pt-0 pb-2'>
<Skeleton className='mb-1 h-3 w-full' />
<Skeleton className='h-3 w-4/5' />
</CardContent>
{/* Footer with author and views skeletons */}
<CardFooter className='mt-auto flex items-center justify-between p-4 pt-2'>
<Skeleton className='h-3 w-1/4' />
<Skeleton className='h-3 w-10' />
</CardFooter>
</div>
</Card>
)
}

View File

@@ -1,150 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Eye } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { Workflow } from '../marketplace'
/**
* WorkflowCardProps interface - defines the properties for the WorkflowCard component
* @property {Workflow} workflow - The workflow data to display
* @property {number} index - The index of the workflow in the list
* @property {Function} onHover - Optional callback function triggered when card is hovered
*/
interface WorkflowCardProps {
workflow: Workflow
index: number
onHover?: (id: string) => void
}
/**
* WorkflowCard component - Displays a workflow in a card format
* Shows either a workflow preview, thumbnail image, or fallback text
* State is now pre-loaded in most cases, fallback to load on hover if needed
*/
export function WorkflowCard({ workflow, onHover }: WorkflowCardProps) {
const [isPreviewReady, setIsPreviewReady] = useState(!!workflow.workflowState)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
const { createWorkflow } = useWorkflowRegistry()
// When workflow state becomes available, update preview ready state
useEffect(() => {
if (workflow.workflowState && !isPreviewReady) {
setIsPreviewReady(true)
}
}, [workflow.workflowState, isPreviewReady])
/**
* Handle mouse enter event
* Sets hover state and triggers onHover callback to load workflow state if needed
*/
const handleMouseEnter = () => {
if (onHover && !workflow.workflowState) {
onHover(workflow.id)
}
}
/**
* Handle workflow card click - track views and import workflow
*/
const handleClick = async () => {
try {
// Track view
await fetch('/api/marketplace/workflows', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: workflow.id }),
})
// Create a local copy of the marketplace workflow
if (workflow.workflowState) {
const newWorkflowId = await createWorkflow({
name: `${workflow.name} (Copy)`,
description: workflow.description,
marketplaceId: workflow.id,
marketplaceState: workflow.workflowState,
})
// Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
} else {
console.error('Cannot import workflow: state is not available')
}
} catch (error) {
console.error('Failed to handle workflow click:', error)
}
}
return (
<div
className='block cursor-pointer'
aria-label={`View ${workflow.name} workflow`}
onClick={handleClick}
>
<Card
className='flex h-full flex-col overflow-hidden transition-all hover:shadow-md'
onMouseEnter={handleMouseEnter}
>
{/* Workflow preview/thumbnail area */}
<div className='relative h-40 overflow-hidden bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-800 dark:to-slate-900'>
{isPreviewReady && workflow.workflowState ? (
// Interactive Preview
<div className='absolute inset-0 flex items-center justify-center'>
<div className='h-full w-full scale-[0.9] transform-gpu'>
<WorkflowPreview
workflowState={{
...workflow.workflowState,
parallels: workflow.workflowState.parallels || {},
loops: workflow.workflowState.loops || {},
}}
/>
</div>
</div>
) : workflow.thumbnail ? (
// Show static thumbnail image if available
<div
className='h-full w-full bg-center bg-cover'
style={{
backgroundImage: `url(${workflow.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center top',
}}
/>
) : (
// Fallback to text if no preview or thumbnail is available
<div className='flex h-full w-full items-center justify-center'>
<span className='font-medium text-lg text-muted-foreground'>{workflow.name}</span>
</div>
)}
</div>
<div className='flex flex-grow flex-col'>
{/* Workflow title */}
<CardHeader className='p-4 pb-2'>
<h3 className='font-medium text-sm'>{workflow.name}</h3>
</CardHeader>
{/* Workflow description */}
<CardContent className='flex flex-grow flex-col p-4 pt-0 pb-2'>
<p className='line-clamp-2 text-muted-foreground text-xs'>{workflow.description}</p>
</CardContent>
{/* Footer with author and stats */}
<CardFooter className='mt-auto flex items-center justify-between p-4 pt-2'>
<div className='text-muted-foreground text-xs'>by {workflow.author}</div>
<div className='flex items-center'>
<div className='flex items-center space-x-1'>
<Eye className='h-3.5 w-3.5 text-muted-foreground' />
<span className='font-medium text-muted-foreground text-xs'>{workflow.views}</span>
</div>
</div>
</CardFooter>
</div>
</Card>
</div>
)
}

View File

@@ -1,91 +0,0 @@
import type { ReactNode } from 'react'
import {
Atom,
BotMessageSquare,
Brain,
Code,
Database,
LineChart,
MailIcon,
Store,
} from 'lucide-react'
export interface Category {
value: string
label: string
icon: ReactNode
color: string
}
export const CATEGORIES: Category[] = [
{
value: 'data',
label: 'Data Analysis',
icon: <Database className='mr-2 h-4 w-4' />,
color: '#0ea5e9', // sky-500
},
{
value: 'marketing',
label: 'Marketing',
icon: <MailIcon className='mr-2 h-4 w-4' />,
color: '#f43f5e', // rose-500
},
{
value: 'sales',
label: 'Sales',
icon: <Store className='mr-2 h-4 w-4' />,
color: '#10b981', // emerald-500
},
{
value: 'customer_service',
label: 'Customer Service',
icon: <BotMessageSquare className='mr-2 h-4 w-4' />,
color: '#8b5cf6', // violet-500
},
{
value: 'research',
label: 'Research',
icon: <Atom className='mr-2 h-4 w-4' />,
color: '#f59e0b', // amber-500
},
{
value: 'finance',
label: 'Finance',
icon: <LineChart className='mr-2 h-4 w-4' />,
color: '#14b8a6', // teal-500
},
{
value: 'programming',
label: 'Programming',
icon: <Code className='mr-2 h-4 w-4' />,
color: '#6366f1', // indigo-500
},
{
value: 'other',
label: 'Other',
icon: <Brain className='mr-2 h-4 w-4' />,
color: '#802FFF', // Brand purple
},
]
// Helper functions to get category information
export const getCategoryByValue = (value: string): Category => {
return CATEGORIES.find((cat) => cat.value === value) || CATEGORIES[CATEGORIES.length - 1]
}
export const getCategoryLabel = (value: string): string => {
// Special handling for "popular" and "recent" sections
if (value === 'popular') return 'Popular'
if (value === 'recent') return 'Recent'
// Default handling for regular categories
return getCategoryByValue(value).label
}
export const getCategoryIcon = (value: string): ReactNode => {
return getCategoryByValue(value).icon
}
export const getCategoryColor = (value: string): string => {
return getCategoryByValue(value).color
}

View File

@@ -1,574 +0,0 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { ControlBar } from './components/control-bar/control-bar'
import { ErrorMessage } from './components/error-message'
import { Section } from './components/section'
import { Toolbar } from './components/toolbar/toolbar'
import { WorkflowCard } from './components/workflow-card'
import { WorkflowCardSkeleton } from './components/workflow-card-skeleton'
import { CATEGORIES, getCategoryLabel } from './constants/categories'
// Types
export interface Workflow {
id: string
name: string
description: string
author: string
views: number
tags: string[]
thumbnail?: string
workflowUrl: string
workflowState?: {
blocks: Record<string, any>
edges: Array<{
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
loops: Record<string, any>
parallels?: Record<string, any>
}
}
// Updated interface to match API response format
export interface MarketplaceWorkflow {
id: string
workflowId: string
name: string
description: string
authorName: string
views: number
category: string
createdAt: string
updatedAt: string
workflowState?: {
blocks: Record<string, any>
edges: Array<{
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}>
loops: Record<string, any>
parallels?: Record<string, any>
}
}
export interface MarketplaceData {
popular: MarketplaceWorkflow[]
recent: MarketplaceWorkflow[]
byCategory: Record<string, MarketplaceWorkflow[]>
}
// The order to display sections in, matching toolbar order
const SECTION_ORDER = ['popular', 'recent', ...CATEGORIES.map((cat) => cat.value)]
export default function Marketplace() {
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [marketplaceData, setMarketplaceData] = useState<MarketplaceData>({
popular: [],
recent: [],
byCategory: {},
})
const [activeSection, setActiveSection] = useState<string | null>(null)
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set(['popular', 'recent']))
const [_visibleSections, setVisibleSections] = useState<Set<string>>(new Set(['popular']))
// Create refs for each section
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
const contentRef = useRef<HTMLDivElement>(null)
const initialFetchCompleted = useRef(false)
// Convert marketplace data to the format expected by components
const workflowData = useMemo(() => {
const result: Record<string, Workflow[]> = {
popular: marketplaceData.popular.map((item) => ({
id: item.id,
name: item.name,
description: item.description || '',
author: item.authorName,
views: item.views,
tags: [item.category],
workflowState: item.workflowState,
workflowUrl: `/w/${item.workflowId}`,
})),
recent: marketplaceData.recent.map((item) => ({
id: item.id,
name: item.name,
description: item.description || '',
author: item.authorName,
views: item.views,
tags: [item.category],
workflowState: item.workflowState,
workflowUrl: `/w/${item.workflowId}`,
})),
}
// Add entries for each category
Object.entries(marketplaceData.byCategory).forEach(([category, items]) => {
if (items && items.length > 0) {
result[category] = items.map((item) => ({
id: item.id,
name: item.name,
description: item.description || '',
author: item.authorName,
views: item.views,
tags: [item.category],
workflowState: item.workflowState,
workflowUrl: `/w/${item.workflowId}`,
}))
}
})
return result
}, [marketplaceData])
// Fetch workflows on component mount - improved to include state initially
useEffect(() => {
const fetchInitialData = async () => {
try {
setLoading(true)
// Fetch ALL data including categories in the initial load
const response = await fetch(
'/api/marketplace/workflows?includeState=true&section=popular,recent,byCategory'
)
if (!response.ok) {
throw new Error('Failed to fetch marketplace data')
}
const data = await response.json()
// Add all categories to loaded sections to avoid redundant load
setLoadedSections((prev) => {
const allSections = new Set([...prev])
Object.keys(data.byCategory || {}).forEach((category) => {
allSections.add(category)
})
return allSections
})
console.log(
'Initial marketplace data loaded with categories:',
data.popular?.length || 0,
'popular,',
data.recent?.length || 0,
'recent,',
'categories:',
Object.keys(data.byCategory || {})
)
setMarketplaceData(data)
initialFetchCompleted.current = true
// Set initial active section to popular
setActiveSection('popular')
setLoading(false)
} catch (error) {
console.error('Error fetching workflows:', error)
setError('Failed to load workflows. Please try again later.')
setLoading(false)
}
}
fetchInitialData()
}, [])
// Lazy load category data when sections become visible
const loadCategoryData = async (categoryName: string) => {
if (loadedSections.has(categoryName)) {
return // Already loaded, no need to fetch again
}
try {
setLoadedSections((prev) => new Set([...prev, categoryName]))
console.log(`Loading category: ${categoryName}`) // Debug
const response = await fetch(
`/api/marketplace/workflows?includeState=true&category=${categoryName}`
)
if (!response.ok) {
throw new Error(`Failed to fetch ${categoryName} category data`)
}
const data = await response.json()
// Debug logging
console.log(
'Category data received:',
data.byCategory ? Object.keys(data.byCategory) : 'No byCategory',
data.byCategory?.[categoryName]?.length || 0
)
// Check if we received any data in the category
if (
!data.byCategory ||
!data.byCategory[categoryName] ||
data.byCategory[categoryName].length === 0
) {
console.warn(`No items found for category: ${categoryName}`)
}
setMarketplaceData((prev) => ({
...prev,
byCategory: {
...prev.byCategory,
[categoryName]: data.byCategory?.[categoryName] || [],
},
}))
} catch (error) {
console.error(`Error fetching ${categoryName} category:`, error)
// We don't set a global error, just log it
}
}
// Function to mark a workflow as needing state and fetch it if not available
const ensureWorkflowState = async (workflowId: string) => {
try {
// Find which section contains this workflow
let foundWorkflow: MarketplaceWorkflow | undefined
// Check in popular section
foundWorkflow = marketplaceData.popular.find((w) => w.id === workflowId)
// Check in recent section if not found
if (!foundWorkflow) {
foundWorkflow = marketplaceData.recent.find((w) => w.id === workflowId)
}
// Check in category sections if not found
if (!foundWorkflow) {
for (const category of Object.keys(marketplaceData.byCategory)) {
foundWorkflow = marketplaceData.byCategory[category].find((w) => w.id === workflowId)
if (foundWorkflow) break
}
}
// If we have the workflow but it doesn't have state, fetch it
if (foundWorkflow && !foundWorkflow.workflowState) {
const response = await fetch(
`/api/marketplace/workflows?marketplaceId=${workflowId}&includeState=true`,
{
method: 'GET',
}
)
if (response.ok) {
const data = await response.json()
// Update the workflow data with the state
setMarketplaceData((prevData) => {
const updatedData = { ...prevData }
// Helper function to update workflow in a section
const updateWorkflowInSection = (workflows: MarketplaceWorkflow[]) => {
return workflows.map((w) =>
w.id === workflowId
? {
...w,
workflowState: data.data.workflowState,
}
: w
)
}
// Update in popular
updatedData.popular = updateWorkflowInSection(updatedData.popular)
// Update in recent
updatedData.recent = updateWorkflowInSection(updatedData.recent)
// Update in categories
Object.keys(updatedData.byCategory).forEach((category) => {
updatedData.byCategory[category] = updateWorkflowInSection(
updatedData.byCategory[category]
)
})
return updatedData
})
}
}
} catch (error) {
console.error(`Error ensuring workflow state for ${workflowId}:`, error)
}
}
// Filter workflows based on search query
const filteredWorkflows = useMemo(() => {
if (!searchQuery.trim()) {
return workflowData
}
const query = searchQuery.toLowerCase()
const filtered: Record<string, Workflow[]> = {}
Object.entries(workflowData).forEach(([category, workflows]) => {
const matchingWorkflows = workflows.filter(
(workflow) =>
workflow.name.toLowerCase().includes(query) ||
workflow.description.toLowerCase().includes(query) ||
workflow.author.toLowerCase().includes(query) ||
workflow.tags.some((tag) => tag.toLowerCase().includes(query))
)
if (matchingWorkflows.length > 0) {
filtered[category] = matchingWorkflows
}
})
return filtered
}, [searchQuery, workflowData])
// Sort sections according to the toolbar order
const sortedFilteredWorkflows = useMemo(() => {
// Get entries from filteredWorkflows
const entries = Object.entries(filteredWorkflows)
// Sort based on the SECTION_ORDER
entries.sort((a, b) => {
const indexA = SECTION_ORDER.indexOf(a[0])
const indexB = SECTION_ORDER.indexOf(b[0])
// If both categories are in our predefined order, use that order
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB
}
// If only one category is in our order, prioritize it
if (indexA !== -1) return -1
if (indexB !== -1) return 1
// Otherwise, alphabetical order
return a[0].localeCompare(b[0])
})
return entries
}, [filteredWorkflows])
// Function to scroll to a specific section
const scrollToSection = (sectionId: string) => {
if (sectionRefs.current[sectionId]) {
// Load the section data if not already loaded
if (!loadedSections.has(sectionId) && sectionId !== 'popular' && sectionId !== 'recent') {
loadCategoryData(sectionId)
}
sectionRefs.current[sectionId]?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}
// Setup intersection observer to track active section and load sections as they become visible
useEffect(() => {
if (!initialFetchCompleted.current) return
// Function to get current section IDs in their display order
const getCurrentSectionIds = () => {
return Object.keys(filteredWorkflows).filter(
(key) => filteredWorkflows[key] && filteredWorkflows[key].length > 0
)
}
// Create intersection observer to detect when sections enter viewport
const observeSections = () => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const sectionId = entry.target.id
// Update visibility tracking
if (entry.isIntersecting) {
setVisibleSections((prev) => {
const updated = new Set(prev)
updated.add(sectionId)
return updated
})
// Load category data if section is visible and not loaded yet
if (
!loadedSections.has(sectionId) &&
sectionId !== 'popular' &&
sectionId !== 'recent'
) {
loadCategoryData(sectionId)
}
} else {
setVisibleSections((prev) => {
const updated = new Set(prev)
updated.delete(sectionId)
return updated
})
}
})
},
{
root: contentRef.current,
rootMargin: '200px 0px', // Load sections slightly before they become visible
threshold: 0.1,
}
)
// Observe all sections
Object.entries(sectionRefs.current).forEach(([id, ref]) => {
if (ref) {
observer.observe(ref)
}
})
return observer
}
const observer = observeSections()
// Use a single source of truth for determining the active section
const determineActiveSection = () => {
if (!contentRef.current) return
const { scrollTop, scrollHeight, clientHeight } = contentRef.current
const viewportTop = scrollTop
const viewportMiddle = viewportTop + clientHeight / 2
const viewportBottom = scrollTop + clientHeight
const isAtBottom = viewportBottom >= scrollHeight - 50
const isAtTop = viewportTop <= 20
const currentSectionIds = getCurrentSectionIds()
// Handle edge cases first
if (isAtTop && currentSectionIds.length > 0) {
setActiveSection(currentSectionIds[0])
return
}
if (isAtBottom && currentSectionIds.length > 0) {
setActiveSection(currentSectionIds[currentSectionIds.length - 1])
return
}
// Find section whose position is closest to middle of viewport
// This creates smoother transitions as we scroll
let closestSection = null
let closestDistance = Number.POSITIVE_INFINITY
Object.entries(sectionRefs.current).forEach(([id, ref]) => {
if (!ref || !currentSectionIds.includes(id)) return
const rect = ref.getBoundingClientRect()
const sectionTop =
rect.top + scrollTop - (contentRef.current?.getBoundingClientRect().top || 0)
const sectionMiddle = sectionTop + rect.height / 2
const distance = Math.abs(viewportMiddle - sectionMiddle)
if (distance < closestDistance) {
closestDistance = distance
closestSection = id
}
})
if (closestSection) {
setActiveSection(closestSection)
}
}
// Use a passive scroll listener for smooth transitions
const handleScroll = () => {
// Using requestAnimationFrame ensures we only calculate
// section positions during a paint frame, reducing jank
window.requestAnimationFrame(determineActiveSection)
}
const contentElement = contentRef.current
contentElement?.addEventListener('scroll', handleScroll, { passive: true })
return () => {
observer.disconnect()
contentElement?.removeEventListener('scroll', handleScroll)
}
}, [initialFetchCompleted.current, loading, filteredWorkflows, loadedSections])
return (
<div className='flex h-[100vh] flex-col'>
{/* Control Bar */}
<ControlBar setSearchQuery={setSearchQuery} />
<div className='flex flex-1 overflow-hidden'>
{/* Toolbar */}
<Toolbar scrollToSection={scrollToSection} activeSection={activeSection} />
{/* Main content */}
<div ref={contentRef} className='flex-1 overflow-y-auto px-6 py-6 pb-16'>
{/* Error message */}
<ErrorMessage message={error} />
{/* Loading state */}
{loading && (
<Section
id='loading'
title='Popular'
ref={(el) => {
sectionRefs.current.loading = el
}}
>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3'>
{Array.from({ length: 6 }).map((_, index) => (
<WorkflowCardSkeleton key={`skeleton-${index}`} />
))}
</div>
</Section>
)}
{/* Render workflow sections */}
{!loading && (
<>
{sortedFilteredWorkflows.map(
([category, workflows]) =>
workflows.length > 0 && (
<Section
key={category}
id={category}
title={getCategoryLabel(category)}
ref={(el) => {
if (el) {
sectionRefs.current[category] = el
}
}}
>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3'>
{workflows.map((workflow, index) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
index={index}
onHover={ensureWorkflowState}
/>
))}
</div>
</Section>
)
)}
{sortedFilteredWorkflows.length === 0 && !loading && (
<div className='flex h-64 flex-col items-center justify-center'>
<AlertCircle className='mb-4 h-8 w-8 text-muted-foreground' />
<p className='text-muted-foreground'>No workflows found matching your search.</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,3 +0,0 @@
import Marketplace from './marketplace'
export default Marketplace

View File

@@ -0,0 +1,126 @@
import { and, eq } from 'drizzle-orm'
import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { templateStars, templates } from '@/db/schema'
import type { Template } from '../templates'
import TemplateDetails from './template'
interface TemplatePageProps {
params: Promise<{
workspaceId: string
id: string
}>
}
export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
try {
// Validate the template ID format (basic UUID validation)
if (!id || typeof id !== 'string' || id.length !== 36) {
notFound()
}
const session = await getSession()
if (!session?.user?.id) {
return <div>Please log in to view this template</div>
}
// Fetch template data first without star status to avoid query issues
const templateData = 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,
})
.from(templates)
.where(eq(templates.id, id))
.limit(1)
if (templateData.length === 0) {
notFound()
}
const template = templateData[0]
// Validate that required fields are present
if (!template.id || !template.name || !template.author) {
console.error('Template missing required fields:', {
id: template.id,
name: template.name,
author: template.author,
})
notFound()
}
// Check if user has starred this template
let isStarred = false
try {
const starData = await db
.select({ id: templateStars.id })
.from(templateStars)
.where(
and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id))
)
.limit(1)
isStarred = starData.length > 0
} catch {
// Continue with isStarred = false
}
// Ensure proper serialization of the template data with null checks
const serializedTemplate: Template = {
id: template.id,
workflowId: template.workflowId,
userId: template.userId,
name: template.name,
description: template.description,
author: template.author,
views: template.views,
stars: template.stars,
color: template.color || '#3972F6', // Default color if missing
icon: template.icon || 'FileText', // Default icon if missing
category: template.category as any,
state: template.state as any,
createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(),
updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(),
isStarred,
}
console.log('Template from DB:', template)
console.log('Serialized template:', serializedTemplate)
console.log('Template state from DB:', template.state)
return (
<TemplateDetails
template={serializedTemplate}
workspaceId={workspaceId}
currentUserId={session.user.id}
/>
)
} catch (error) {
console.error('Error loading template:', error)
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Error Loading Template</h1>
<p className='text-muted-foreground'>There was an error loading this template.</p>
<p className='mt-2 text-muted-foreground text-sm'>Template ID: {id}</p>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,348 @@
'use client'
import { useState } from 'react'
import {
ArrowLeft,
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
Eye,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { Template } from '../templates'
import { categories } from '../templates'
const logger = createLogger('TemplateDetails')
interface TemplateDetailsProps {
template: Template
workspaceId: string
currentUserId: string
}
// Icon mapping - reuse from template-card
const iconMap = {
FileText,
NotebookPen,
BookOpen,
Edit,
BarChart3,
LineChart,
TrendingUp,
Target,
Database,
Server,
Cloud,
Folder,
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
HeadphonesIcon,
Users,
Settings,
Wrench,
Bot,
Brain,
Cpu,
Code,
Zap,
Workflow,
Search,
Play,
Layers,
Lightbulb,
Globe,
Award,
}
// Get icon component from template-card logic
const getIconComponent = (icon: string): React.ReactNode => {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' />
}
// Get category display name
const getCategoryDisplayName = (categoryValue: string): string => {
const category = categories.find((c) => c.value === categoryValue)
return category?.label || categoryValue
}
export default function TemplateDetails({
template,
workspaceId,
currentUserId,
}: TemplateDetailsProps) {
const router = useRouter()
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
const [starCount, setStarCount] = useState(template?.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
// Defensive check for template after hooks are initialized
if (!template) {
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
</div>
</div>
)
}
// Render workflow preview exactly like deploy-modal.tsx
const renderWorkflowPreview = () => {
// Follow the same pattern as deployed-workflow-card.tsx
if (!template?.state) {
console.log('Template has no state:', template)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'> No Workflow Data</div>
<div className='text-sm'>This template doesn't contain workflow state data.</div>
</div>
</div>
)
}
console.log('Template state:', template.state)
console.log('Template state type:', typeof template.state)
console.log('Template state blocks:', template.state.blocks)
console.log('Template state edges:', template.state.edges)
try {
return (
<WorkflowPreview
workflowState={template.state as WorkflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
)
} catch (error) {
console.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'>⚠️ Preview Error</div>
<div className='text-sm'>Unable to render workflow preview</div>
</div>
</div>
)
}
}
const handleBack = () => {
router.back()
}
const handleStarToggle = async () => {
if (isStarring) return
setIsStarring(true)
try {
const method = isStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${template.id}/star`, { method })
if (response.ok) {
setIsStarred(!isStarred)
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
}
} catch (error) {
logger.error('Error toggling star:', error)
} finally {
setIsStarring(false)
}
}
const handleUseTemplate = async () => {
if (isUsing) return
setIsUsing(true)
try {
// TODO: Implement proper template usage logic
// This should create a new workflow from the template state
// For now, we'll create a basic workflow and navigate to it
logger.info('Using template:', template.id)
// Create a new workflow
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `${template.name} (Copy)`,
description: `Created from template: ${template.name}`,
color: template.color,
workspaceId,
folderId: null,
}),
})
if (!response.ok) {
throw new Error('Failed to create workflow from template')
}
const newWorkflow = await response.json()
// Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${newWorkflow.id}`)
} catch (error) {
logger.error('Error using template:', error)
// Show error to user (could implement toast notification)
} finally {
setIsUsing(false)
}
}
return (
<div className='flex min-h-screen flex-col'>
{/* Header */}
<div className='border-b bg-background p-6'>
<div className='mx-auto max-w-7xl'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
>
<ArrowLeft className='h-4 w-4' />
<span className='text-sm'>Go back</span>
</button>
{/* Template header */}
<div className='flex items-start justify-between'>
<div className='flex items-start gap-4'>
{/* Icon */}
<div
className='flex h-12 w-12 items-center justify-center rounded-lg'
style={{ backgroundColor: template.color }}
>
{getIconComponent(template.icon)}
</div>
{/* Title and description */}
<div>
<h1 className='font-bold text-3xl text-foreground'>{template.name}</h1>
<p className='mt-2 max-w-3xl text-lg text-muted-foreground'>
{template.description}
</p>
</div>
</div>
{/* Action buttons */}
<div className='flex items-center gap-3'>
{/* Star button */}
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
{starCount}
</Button>
{/* Use template button */}
<Button
onClick={handleUseTemplate}
disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700'
>
Use this template
</Button>
</div>
</div>
{/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
{/* Category */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<span>{getCategoryDisplayName(template.category)}</span>
</div>
{/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' />
<span>{template.views}</span>
</div>
{/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' />
<span>{starCount}</span>
</div>
{/* Author */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<User className='h-3 w-3' />
<span>by {template.author}</span>
</div>
</div>
</div>
</div>
{/* Workflow preview */}
<div className='flex-1 p-6'>
<div className='mx-auto max-w-7xl'>
<h2 className='mb-4 font-semibold text-xl'>Workflow Preview</h2>
<div className='h-[600px] w-full'>{renderWorkflowPreview()}</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils'
interface NavigationTab {
id: string
label: string
count?: number
}
interface NavigationTabsProps {
tabs: NavigationTab[]
activeTab?: string
onTabClick?: (tabId: string) => void
className?: string
}
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,396 @@
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks/registry'
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
description: string
author: string
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
onClick?: () => void
className?: string
// Add state prop to extract block types
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
// Add handlers for star and use actions
onStar?: (templateId: string, isCurrentlyStarred: boolean) => Promise<void>
onUse?: (templateId: string) => Promise<void>
isStarred?: boolean
}
// Skeleton component for loading states
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[14px] border bg-card shadow-xs', 'flex h-38', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-3'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-24 animate-pulse rounded bg-gray-200' />
</div>
{/* Description skeleton */}
<div className='space-y-2'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/4 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-1/2 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5'>
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Right side - Blocks skeleton */}
<div className='flex w-16 flex-col gap-1 rounded-r-[14px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className='flex items-center gap-1.5'>
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-12 animate-pulse rounded bg-gray-200' />
</div>
))}
</div>
</div>
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get icon component from string or return the component directly
const getIconComponent = (icon: React.ReactNode | string | undefined): React.ReactNode => {
if (typeof icon === 'string') {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? <IconComponent /> : <FileText />
}
if (icon) {
return icon
}
// Default fallback icon
return <FileText />
}
// Utility function to get block display name
const getBlockDisplayName = (blockType: string): string => {
const block = getBlock(blockType)
return block?.name || blockType
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
export function TemplateCard({
id,
title,
description,
author,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
onClick,
className,
state,
onStar,
onUse,
isStarred = false,
}: TemplateCardProps) {
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Get the icon component
const iconComponent = getIconComponent(icon)
// Handle star toggle
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (onStar) {
await onStar(id, isStarred)
}
}
// Handle use template
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (onUse) {
await onUse(id)
}
}
return (
<div
className={cn(
'group rounded-[14px] border bg-card shadow-xs transition-all duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-3'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon container */}
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
// Use CSS class if iconColor doesn't start with #
iconColor?.startsWith('#') ? '' : iconColor || 'bg-blue-500'
)}
style={{
// Use inline style for hex colors
backgroundColor: iconColor?.startsWith('#') ? iconColor : undefined,
}}
>
<div className='h-3 w-3 text-white [&>svg]:h-3 [&>svg]:w-3'>{iconComponent}</div>
</div>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Star and Use button */}
<div className='flex flex-shrink-0 items-center gap-3'>
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors',
isStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400'
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-md px-3 py-1 font-medium font-sans text-white text-xs transition-all duration-200',
'bg-[#701FFC] hover:bg-[#6518E6]',
'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</div>
</div>
{/* Description */}
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{stars}</span>
</div>
</div>
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[14px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { GeistSans } from 'geist/font/sans'
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return <div className={GeistSans.className}>{children}</div>
}

View File

@@ -0,0 +1,47 @@
import { and, desc, eq, sql } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { templateStars, templates } from '@/db/schema'
import type { Template } from './templates'
import Templates from './templates'
export default async function TemplatesPage() {
const session = await getSession()
if (!session?.user?.id) {
return <div>Please log in to view templates</div>
}
// Fetch templates server-side with all necessary data
const templatesData = 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<boolean>`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))
)
.orderBy(desc(templates.views), desc(templates.createdAt))
return (
<Templates
initialTemplates={templatesData as unknown as Template[]}
currentUserId={session.user.id}
/>
)
}

View File

@@ -0,0 +1,432 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronRight, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console-logger'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { NavigationTabs } from './components/navigation-tabs'
import { TemplateCard, TemplateCardSkeleton } from './components/template-card'
const logger = createLogger('TemplatesPage')
// Shared categories definition
export const categories = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'finance', label: 'Finance' },
{ value: 'support', label: 'Support' },
{ value: 'artificial-intelligence', label: 'Artificial Intelligence' },
{ value: 'other', label: 'Other' },
] as const
export type CategoryValue = (typeof categories)[number]['value']
// Template data structure
export interface Template {
id: string
workflowId: string
userId: string
name: string
description: string | null
author: string
views: number
stars: number
color: string
icon: string
category: CategoryValue
state: WorkflowState
createdAt: Date | string
updatedAt: Date | string
isStarred: boolean
}
interface TemplatesProps {
initialTemplates: Template[]
currentUserId: string
}
export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) {
const router = useRouter()
const params = useParams()
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('your')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
// Refs for scrolling to sections
const sectionRefs = {
your: useRef<HTMLDivElement>(null),
recent: useRef<HTMLDivElement>(null),
marketing: useRef<HTMLDivElement>(null),
sales: useRef<HTMLDivElement>(null),
finance: useRef<HTMLDivElement>(null),
support: useRef<HTMLDivElement>(null),
'artificial-intelligence': useRef<HTMLDivElement>(null),
other: useRef<HTMLDivElement>(null),
}
// Get your templates count (created by user OR starred by user)
const yourTemplatesCount = templates.filter(
(template) => template.userId === currentUserId || template.isStarred === true
).length
// Handle case where active tab is "your" but user has no templates
useEffect(() => {
if (!loading && activeTab === 'your' && yourTemplatesCount === 0) {
setActiveTab('recent') // Switch to recent tab
}
}, [loading, activeTab, yourTemplatesCount])
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs]
if (sectionRef.current) {
sectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}
const handleTemplateClick = (templateId: string) => {
// Navigate to template detail page
router.push(`/workspace/${params.workspaceId}/templates/${templateId}`)
}
// Handle using a template
const handleUseTemplate = async (templateId: string) => {
try {
const response = await fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: params.workspaceId,
}),
})
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
// Use window.location.href for more reliable navigation
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
}
}
const handleCreateNew = () => {
// TODO: Open create template modal or navigate to create page
console.log('Create new template')
}
// Handle starring/unstarring templates (client-side for interactivity)
const handleStarToggle = async (templateId: string, isCurrentlyStarred: boolean) => {
try {
const method = isCurrentlyStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${templateId}/star`, { method })
if (response.ok) {
// Update local state optimistically
setTemplates((prev) =>
prev.map((template) =>
template.id === templateId
? {
...template,
isStarred: !isCurrentlyStarred,
stars: isCurrentlyStarred ? template.stars - 1 : template.stars + 1,
}
: template
)
)
}
} catch (error) {
logger.error('Error toggling star:', error)
}
}
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
let filteredByCategory = templates
if (category === 'your') {
// For "your" templates, show templates created by you OR starred by you
filteredByCategory = templates.filter(
(template) => template.userId === currentUserId || template.isStarred === true
)
} else if (category === 'recent') {
// For "recent" templates, show the 8 most recent templates
filteredByCategory = templates
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
} else {
filteredByCategory = templates.filter((template) => template.category === category)
}
if (!searchQuery) return filteredByCategory
return filteredByCategory.filter(
(template) =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
)
}
// Helper function to render template cards with proper type handling
const renderTemplateCard = (template: Template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.description || ''}
author={template.author}
usageCount={template.views.toString()}
stars={template.stars}
icon={template.icon}
iconColor={template.color}
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
onClick={() => handleTemplateClick(template.id)}
onStar={handleStarToggle}
onUse={handleUseTemplate}
isStarred={template.isStarred}
/>
)
// Group templates by category for display
const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => {
return filteredTemplates(category)
}
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
}
// Calculate navigation tabs with real counts or skeleton counts
const navigationTabs = [
// Only include "Your templates" tab if user has created or starred templates
...(yourTemplatesCount > 0 || loading
? [
{
id: 'your',
label: 'Your templates',
count: loading ? 8 : getTemplatesByCategory('your').length,
},
]
: []),
{
id: 'recent',
label: 'Recent',
count: loading ? 8 : getTemplatesByCategory('recent').length,
},
{
id: 'marketing',
label: 'Marketing',
count: loading ? 8 : getTemplatesByCategory('marketing').length,
},
{ id: 'sales', label: 'Sales', count: loading ? 8 : getTemplatesByCategory('sales').length },
{
id: 'finance',
label: 'Finance',
count: loading ? 8 : getTemplatesByCategory('finance').length,
},
{
id: 'support',
label: 'Support',
count: loading ? 8 : getTemplatesByCategory('support').length,
},
{
id: 'artificial-intelligence',
label: 'Artificial Intelligence',
count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length,
},
{ id: 'other', label: 'Other', count: loading ? 8 : getTemplatesByCategory('other').length },
]
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
{/* Header */}
<div className='mb-6'>
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
Templates
</h1>
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
Grab a template and start building, or make
<br />
one from scratch.
</p>
</div>
{/* Search and Create New */}
<div className='mb-6 flex items-center justify-between'>
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search templates...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
{/* <Button
onClick={handleCreateNew}
className='flex h-9 items-center gap-2 rounded-lg bg-[#701FFC] px-4 py-2 font-normal font-sans text-sm text-white hover:bg-[#601EE0]'
>
<Plus className='h-4 w-4' />
Create New
</Button> */}
</div>
{/* Navigation */}
<div className='mb-6'>
<NavigationTabs
tabs={navigationTabs}
activeTab={activeTab}
onTabClick={handleTabClick}
/>
</div>
{/* Your Templates Section */}
{yourTemplatesCount > 0 || loading ? (
<div ref={sectionRefs.your} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Your templates</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('your').map((template) => renderTemplateCard(template))}
</div>
</div>
) : null}
{/* Recent Templates Section */}
<div ref={sectionRefs.recent} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Recent</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Marketing Section */}
<div ref={sectionRefs.marketing} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Marketing</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('marketing').map((template) =>
renderTemplateCard(template)
)}
</div>
</div>
{/* Sales Section */}
<div ref={sectionRefs.sales} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Sales</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Finance Section */}
<div ref={sectionRefs.finance} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Finance</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Support Section */}
<div ref={sectionRefs.support} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Support</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('support').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Artificial Intelligence Section */}
<div ref={sectionRefs['artificial-intelligence']} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>
Artificial Intelligence
</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('artificial-intelligence').map((template) =>
renderTemplateCard(template)
)}
</div>
</div>
{/* Other Section */}
<div ref={sectionRefs.other} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Other</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('other').map((template) => renderTemplateCard(template))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -35,7 +35,6 @@ import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select'
import { useNotificationStore } from '@/stores/notifications/store'
import type { OutputConfig } from '@/stores/panel/chat/types'
const logger = createLogger('ChatDeploy')
@@ -88,9 +87,6 @@ export function ChatDeploy({
setShowDeleteConfirmation: externalSetShowDeleteConfirmation,
onDeploymentComplete,
}: ChatDeployProps) {
// Store hooks
const { addNotification } = useNotificationStore()
// Form state
const [subdomain, setSubdomain] = useState('')
const [title, setTitle] = useState('')
@@ -748,7 +744,7 @@ export function ChatDeploy({
} catch (error: any) {
logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error)
setErrorMessage(error.message || 'An unexpected error occurred')
addNotification('error', `Failed to deploy chat: ${error.message}`, workflowId)
logger.error(`Failed to deploy chat: ${error.message}`, workflowId)
} finally {
setChatSubmitting(false)
setShowEditConfirmation(false)

View File

@@ -19,7 +19,6 @@ import { ApiEndpoint } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
import { ApiKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/api-key/api-key'
import { DeployStatus } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/deploy-status/deploy-status'
import { ExampleCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command'
import { useNotificationStore } from '@/stores/notifications/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { DeployedWorkflowModal } from '../../../deployment-controls/components/deployed-workflow-modal'
@@ -50,14 +49,11 @@ export function DeploymentInfo({
isUndeploying,
workflowId,
deployedState,
isLoadingDeployedState,
}: DeploymentInfoProps) {
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
const { addNotification } = useNotificationStore()
const handleViewDeployed = async () => {
if (!workflowId) {
addNotification('error', 'Cannot view deployment: Workflow ID is missing', null)
return
}
@@ -66,9 +62,6 @@ export function DeploymentInfo({
setIsViewingDeployed(true)
return
}
if (!isLoadingDeployedState) {
addNotification('error', 'Cannot view deployment: No deployed state available', workflowId)
}
}
if (isLoading || !deploymentInfo) {

View File

@@ -23,7 +23,6 @@ import { cn } from '@/lib/utils'
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
import { DeployForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form'
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/deployment-info'
import { useNotificationStore } from '@/stores/notifications/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -77,9 +76,6 @@ export function DeployModal({
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
// Store hooks
const { addNotification } = useNotificationStore()
// Use registry store for deployment-related functions
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
@@ -161,7 +157,6 @@ export function DeployModal({
}
} catch (error) {
logger.error('Error fetching API keys:', { error })
addNotification('error', 'Failed to fetch API keys', workflowId)
setKeysLoaded(true)
}
}
@@ -243,22 +238,16 @@ export function DeployModal({
})
} catch (error) {
logger.error('Error fetching deployment info:', { error })
addNotification('error', 'Failed to fetch deployment information', workflowId)
} finally {
setIsLoading(false)
}
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, addNotification, needsRedeployment])
}, [open, workflowId, isDeployed, needsRedeployment])
// Handle form submission for deployment
const onDeploy = async (data: DeployFormValues) => {
if (!workflowId) {
addNotification('error', 'No active workflow to deploy', null)
return
}
// Reset any previous errors
setApiDeployError(null)
@@ -319,7 +308,6 @@ export function DeployModal({
// No notification on successful deploy
} catch (error: any) {
logger.error('Error deploying workflow:', { error })
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
} finally {
setIsSubmitting(false)
}
@@ -327,11 +315,6 @@ export function DeployModal({
// Handle workflow undeployment
const handleUndeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to undeploy', null)
return
}
try {
setIsUndeploying(true)
@@ -351,14 +334,10 @@ export function DeployModal({
setDeployedChatUrl(null)
setChatExists(false)
// Add a success notification
addNotification('info', 'Workflow successfully undeployed', workflowId)
// Close the modal
onOpenChange(false)
} catch (error: any) {
logger.error('Error undeploying workflow:', { error })
addNotification('error', `Failed to undeploy workflow: ${error.message}`, workflowId)
} finally {
setIsUndeploying(false)
}
@@ -366,11 +345,6 @@ export function DeployModal({
// Handle redeployment of workflow
const handleRedeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to redeploy', null)
return
}
try {
setIsSubmitting(true)
@@ -407,11 +381,8 @@ export function DeployModal({
// Fetch the updated deployed state after redeployment
await refetchDeployedState()
addNotification('info', 'Workflow successfully redeployed', workflowId)
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
addNotification('error', `Failed to redeploy workflow: ${error.message}`, workflowId)
} finally {
setIsSubmitting(false)
}
@@ -427,11 +398,6 @@ export function DeployModal({
// Add a new handler for chat undeploy
const handleChatUndeploy = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to undeploy chat', null)
return
}
try {
setIsUndeploying(true)
@@ -462,15 +428,10 @@ export function DeployModal({
// Reset chat deployment info
setDeployedChatUrl(null)
setChatExists(false)
// Add a success notification
addNotification('info', 'Chat successfully undeployed', workflowId)
// Close the modal
onOpenChange(false)
} catch (error: any) {
logger.error('Error undeploying chat:', { error })
addNotification('error', `Failed to undeploy chat: ${error.message}`, workflowId)
} finally {
setIsUndeploying(false)
setShowDeleteConfirmation(false)
@@ -479,11 +440,6 @@ export function DeployModal({
// Find or create appropriate method to handle chat deployment
const handleChatSubmit = async () => {
if (!workflowId) {
addNotification('error', 'No active workflow to deploy', null)
return
}
// Check if workflow is deployed
if (!isDeployed) {
// Deploy workflow first
@@ -518,7 +474,6 @@ export function DeployModal({
)
} catch (error: any) {
logger.error('Error auto-deploying workflow for chat:', { error })
addNotification('error', `Failed to deploy workflow: ${error.message}`, workflowId)
setChatSubmitting(false)
return
}

View File

@@ -86,14 +86,16 @@ export function DeploymentControls({
<TooltipTrigger asChild>
<div className='relative'>
<Button
variant='ghost'
size='icon'
variant='outline'
onClick={handleDeployClick}
disabled={isDisabled}
className={cn(
'hover:text-[#802FFF]',
'h-12 w-12 rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] shadow-xs',
'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
'transition-all duration-200',
isDeployed && 'text-[#802FFF]',
isDisabled && 'cursor-not-allowed opacity-50'
isDisabled &&
'cursor-not-allowed opacity-50 hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:text-[hsl(var(--card-text))] hover:shadow-xs'
)}
>
{isDeploying ? (
@@ -105,10 +107,10 @@ export function DeploymentControls({
</Button>
{isDeployed && workflowNeedsRedeployment && (
<div className='absolute top-0.5 right-0.5 flex items-center justify-center'>
<div className='pointer-events-none absolute right-2 bottom-2 flex items-center justify-center'>
<div className='relative'>
<div className='absolute inset-0 h-2 w-2 animate-ping rounded-full bg-amber-500/50' />
<div className='zoom-in fade-in relative h-2 w-2 animate-in rounded-full bg-amber-500 ring-1 ring-background duration-300' />
<div className='absolute inset-0 h-[6px] w-[6px] animate-ping rounded-full bg-amber-500/50' />
<div className='zoom-in fade-in relative h-[6px] w-[6px] animate-in rounded-full bg-amber-500/80 duration-300' />
</div>
<span className='sr-only'>Needs Redeployment</span>
</div>

View File

@@ -1,14 +1,8 @@
'use client'
import { useState } from 'react'
import { Download, FileText } from 'lucide-react'
import { Download } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -63,44 +57,34 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
}
}
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
disabled={disabled || isExporting || !currentWorkflow}
className='hover:text-foreground'
>
<Download className='h-5 w-5' />
<span className='sr-only'>Export Workflow</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
{disabled
? 'Export not available'
: !currentWorkflow
? 'No workflow to export'
: 'Export Workflow'}
</TooltipContent>
</Tooltip>
const isDisabled = disabled || isExporting || !currentWorkflow
<DropdownMenuContent align='end' className='w-48'>
<DropdownMenuItem
onClick={handleExportYaml}
disabled={isExporting || !currentWorkflow}
className='flex cursor-pointer items-center gap-2'
>
<FileText className='h-4 w-4' />
<div className='flex flex-col'>
<span>Export as YAML</span>
<span className='text-muted-foreground text-xs'>workflow language</span>
const getTooltipText = () => {
if (disabled) return 'Export not available'
if (!currentWorkflow) return 'No workflow to export'
if (isExporting) return 'Exporting...'
return 'Export as YAML'
}
return (
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<Download className='h-5 w-5' />
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant='outline'
onClick={handleExportYaml}
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
>
<Download className='h-5 w-5' />
<span className='sr-only'>Export as YAML</span>
</Button>
)}
</TooltipTrigger>
<TooltipContent>{getTooltipText()}</TooltipContent>
</Tooltip>
)
}

View File

@@ -1,64 +0,0 @@
import { formatDistanceToNow } from 'date-fns'
import { Clock } from 'lucide-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
interface HistoryDropdownItemProps {
action: string
timestamp: number
onClick?: () => void
isCurrent?: boolean
isFuture?: boolean
id?: string
}
export function HistoryDropdownItem({
action,
timestamp,
onClick,
isCurrent = false,
isFuture = false,
id,
}: HistoryDropdownItemProps) {
const timeAgo = formatDistanceToNow(timestamp, { addSuffix: true })
return (
<DropdownMenuItem
className={cn(
'flex cursor-pointer items-start gap-2 p-3',
isFuture && 'text-muted-foreground/50'
)}
onClick={onClick}
>
<Clock
className={cn('h-4 w-4', isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground')}
/>
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
{isCurrent ? (
<span
className={cn(
'text-xs',
isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground'
)}
>
Current
</span>
) : (
<span
className={cn(
'text-xs',
isFuture ? 'text-muted-foreground/50' : 'text-muted-foreground'
)}
>
{timeAgo}
</span>
)}
</div>
<p className={cn('text-sm', isFuture ? 'text-muted-foreground/50' : 'text-foreground')}>
{action}
</p>
</div>
</DropdownMenuItem>
)
}

View File

@@ -1,589 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Eye, HelpCircle, Info, Trash, X } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { Notice } from '@/components/ui/notice'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import {
CATEGORIES,
getCategoryColor,
getCategoryIcon,
getCategoryLabel,
} from '@/app/workspace/[workspaceId]/marketplace/constants/categories'
import { useNotificationStore } from '@/stores/notifications/store'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('MarketplaceModal')
/**
* Sanitizes sensitive data from workflow state before publishing
* Removes API keys, tokens, and environment variable references
*/
const sanitizeWorkflowData = (workflowData: any) => {
if (!workflowData) return workflowData
const sanitizedData = JSON.parse(JSON.stringify(workflowData))
let sanitizedCount = 0
// Handle workflow state format
if (sanitizedData.state?.blocks) {
Object.values(sanitizedData.state.blocks).forEach((block: any) => {
if (block.subBlocks) {
// Check for sensitive fields in subBlocks
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
// Check for API key related fields in any block type
const isSensitiveField =
key.toLowerCase() === 'apikey' || key.toLowerCase().includes('api_key')
if (isSensitiveField && subBlock.value) {
subBlock.value = ''
sanitizedCount++
}
})
}
})
}
logger.info(`Sanitized ${sanitizedCount} API keys from workflow data`)
return sanitizedData
}
interface MarketplaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
// Form schema for validation
const marketplaceFormSchema = z.object({
name: z
.string()
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name cannot exceed 50 characters'),
description: z
.string()
.min(10, 'Description must be at least 10 characters')
.max(500, 'Description cannot exceed 500 characters'),
category: z.string().min(1, 'Please select a category'),
authorName: z
.string()
.min(2, 'Author name must be at least 2 characters')
.max(50, 'Author name cannot exceed 50 characters'),
})
type MarketplaceFormValues = z.infer<typeof marketplaceFormSchema>
// Tooltip texts
const TOOLTIPS = {
category: 'Categorizing your workflow helps users find it more easily.',
authorName: 'The name you want to publish under (defaults to your account name if left empty).',
}
interface MarketplaceInfo {
id: string
name: string
description: string
category: string
authorName: string
views: number
createdAt: string
updatedAt: string
}
export function MarketplaceModal({ open, onOpenChange }: MarketplaceModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUnpublishing, setIsUnpublishing] = useState(false)
const [marketplaceInfo, setMarketplaceInfo] = useState<MarketplaceInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const { addNotification } = useNotificationStore()
const { activeWorkflowId, workflows, updateWorkflow } = useWorkflowRegistry()
// Get marketplace data from the registry
const getMarketplaceData = () => {
if (!activeWorkflowId || !workflows[activeWorkflowId]) return null
return workflows[activeWorkflowId].marketplaceData
}
// Check if workflow is published to marketplace
const isPublished = () => {
return !!getMarketplaceData()
}
// Check if the current user is the owner of the published workflow
const isOwner = () => {
const marketplaceData = getMarketplaceData()
return marketplaceData?.status === 'owner'
}
// Initialize form with react-hook-form
const form = useForm<MarketplaceFormValues>({
resolver: zodResolver(marketplaceFormSchema),
defaultValues: {
name: '',
description: '',
category: 'marketing',
authorName: '',
},
})
// Fetch marketplace information when the modal opens and the workflow is published
useEffect(() => {
async function fetchMarketplaceInfo() {
if (!open || !activeWorkflowId || !isPublished()) {
setMarketplaceInfo(null)
return
}
try {
setIsLoading(true)
// Get marketplace ID from the workflow's marketplaceData
const marketplaceData = getMarketplaceData()
if (!marketplaceData?.id) {
throw new Error('No marketplace ID found in workflow data')
}
// Use the marketplace ID to fetch details instead of workflow ID
const response = await fetch(
`/api/marketplace/workflows?marketplaceId=${marketplaceData.id}`
)
if (!response.ok) {
throw new Error('Failed to fetch marketplace information')
}
// The API returns the data directly without wrapping
const marketplaceEntry = await response.json()
setMarketplaceInfo(marketplaceEntry)
} catch (error) {
console.error('Error fetching marketplace info:', error)
addNotification('error', 'Failed to fetch marketplace information', activeWorkflowId)
} finally {
setIsLoading(false)
}
}
fetchMarketplaceInfo()
}, [open, activeWorkflowId, addNotification])
// Update form values when the active workflow changes or modal opens
useEffect(() => {
if (open && activeWorkflowId && workflows[activeWorkflowId] && !isPublished()) {
const workflow = workflows[activeWorkflowId]
form.setValue('name', workflow.name)
form.setValue('description', workflow.description || '')
}
}, [open, activeWorkflowId, workflows, form])
// Listen for the custom event to open the marketplace modal
useEffect(() => {
const handleOpenMarketplace = () => {
onOpenChange(true)
}
// Add event listener
window.addEventListener('open-marketplace', handleOpenMarketplace as EventListener)
// Clean up
return () => {
window.removeEventListener('open-marketplace', handleOpenMarketplace as EventListener)
}
}, [onOpenChange])
const onSubmit = async (data: MarketplaceFormValues) => {
if (!activeWorkflowId) {
addNotification('error', 'No active workflow to publish', null)
return
}
try {
setIsSubmitting(true)
// Get the complete workflow state client-side
const workflowData = getWorkflowWithValues(activeWorkflowId)
if (!workflowData) {
addNotification('error', 'Failed to retrieve workflow state', activeWorkflowId)
return
}
// Sanitize the workflow data
const sanitizedWorkflowData = sanitizeWorkflowData(workflowData)
logger.info('Publishing sanitized workflow to marketplace', {
workflowId: activeWorkflowId,
workflowName: data.name,
category: data.category,
})
const response = await fetch('/api/marketplace/publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId: activeWorkflowId,
name: data.name,
description: data.description,
category: data.category,
authorName: data.authorName,
workflowState: sanitizedWorkflowData.state,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to publish workflow')
}
// Get the marketplace ID from the response
const responseData = await response.json()
const marketplaceId = responseData.data.id
// Update the marketplace data in the workflow registry
updateWorkflow(activeWorkflowId, {
marketplaceData: { id: marketplaceId, status: 'owner' },
})
// Add a marketplace notification with detailed information
addNotification(
'marketplace',
`"${data.name}" successfully published to marketplace`,
activeWorkflowId
)
// Close the modal after successful submission
onOpenChange(false)
} catch (error: any) {
console.error('Error publishing workflow:', error)
addNotification('error', `Failed to publish workflow: ${error.message}`, activeWorkflowId)
} finally {
setIsSubmitting(false)
}
}
const handleUnpublish = async () => {
if (!activeWorkflowId) {
addNotification('error', 'No active workflow to unpublish', null)
return
}
try {
setIsUnpublishing(true)
// Get marketplace ID from the workflow's marketplaceData
const marketplaceData = getMarketplaceData()
if (!marketplaceData?.id) {
throw new Error('No marketplace ID found in workflow data')
}
logger.info('Attempting to unpublish marketplace entry', {
marketplaceId: marketplaceData.id,
workflowId: activeWorkflowId,
status: marketplaceData.status,
})
const response = await fetch(`/api/marketplace/${marketplaceData.id}/unpublish`, {
method: 'POST',
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Error response from unpublish endpoint', {
status: response.status,
data: errorData,
})
throw new Error(errorData.error || 'Failed to unpublish workflow')
}
logger.info('Successfully unpublished workflow from marketplace', {
marketplaceId: marketplaceData.id,
workflowId: activeWorkflowId,
})
// First close the modal to prevent any flashing
onOpenChange(false)
// Then update the workflow state after modal is closed
setTimeout(() => {
// Remove the marketplace data from the workflow registry
updateWorkflow(activeWorkflowId, {
marketplaceData: null,
})
}, 100)
} catch (error: any) {
console.error('Error unpublishing workflow:', error)
addNotification('error', `Failed to unpublish workflow: ${error.message}`, activeWorkflowId)
} finally {
setIsUnpublishing(false)
}
}
const LabelWithTooltip = ({ name, tooltip }: { name: string; tooltip: string }) => (
<div className='flex items-center gap-1.5'>
<FormLabel>{name}</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className='h-4 w-4 cursor-help text-muted-foreground' />
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{tooltip}</p>
</TooltipContent>
</Tooltip>
</div>
)
// Render marketplace information for published workflows
const renderMarketplaceInfo = () => {
if (isLoading) {
return (
<div className='flex items-center justify-center py-12'>
<LoadingAgent size='md' />
</div>
)
}
if (!marketplaceInfo) {
return (
<div className='flex items-center justify-center py-12 text-muted-foreground'>
<div className='flex flex-col items-center gap-2'>
<Info className='h-5 w-5' />
<p className='text-sm'>No marketplace information available</p>
</div>
</div>
)
}
return (
<div className='space-y-5 px-1'>
{/* Header section with title and stats */}
<div className='space-y-2.5'>
<div className='flex items-start justify-between'>
<h3 className='font-medium text-xl leading-tight'>{marketplaceInfo.name}</h3>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-1.5 rounded-md px-2 py-1'>
<Eye className='h-3.5 w-3.5 text-muted-foreground' />
<span className='font-medium text-muted-foreground text-xs'>
{marketplaceInfo.views}
</span>
</div>
</div>
</div>
<p className='text-muted-foreground text-sm'>{marketplaceInfo.description}</p>
</div>
{/* Category and Author Info */}
<div className='flex items-center gap-6'>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Category</Label>
<div
className='flex items-center gap-1.5 rounded-md px-2.5 py-1'
style={{
backgroundColor: `${getCategoryColor(marketplaceInfo.category)}15`,
color: getCategoryColor(marketplaceInfo.category),
}}
>
{getCategoryIcon(marketplaceInfo.category)}
<span className='font-medium text-sm'>
{getCategoryLabel(marketplaceInfo.category)}
</span>
</div>
</div>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Author</Label>
<div className='flex items-center font-medium text-sm'>
{marketplaceInfo.authorName}
</div>
</div>
</div>
{/* Action buttons - Only show unpublish if owner */}
{isOwner() && (
<div className='flex justify-end gap-2 pt-2'>
<Button
type='button'
variant='destructive'
onClick={handleUnpublish}
disabled={isUnpublishing}
className='gap-2'
>
{isUnpublishing ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash className='mr-2 h-4 w-4' />
)}
{isUnpublishing ? 'Unpublishing...' : 'Unpublish'}
</Button>
</div>
)}
</div>
)
}
// Render publish form for unpublished workflows
const renderPublishForm = () => (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<Notice variant='warning' title='Security'>
API keys and environment variables will be automatically removed before publishing.
</Notice>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Workflow Name</FormLabel>
<FormControl>
<Input placeholder='Enter workflow name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what your workflow does and how it can help others'
className='min-h-24'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<LabelWithTooltip name='Category' tooltip={TOOLTIPS.category} />
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{CATEGORIES.map((category) => (
<SelectItem
key={category.value}
value={category.value}
className='flex items-center'
>
<div className='flex items-center'>
{category.icon}
{category.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorName'
render={({ field }) => (
<FormItem>
<LabelWithTooltip name='Author Name' tooltip={TOOLTIPS.authorName} />
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-between gap-2'>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type='submit'
disabled={isSubmitting}
className={cn(
// Base styles
'gap-2 font-medium',
// Brand color with hover states
'bg-[#802FFF] hover:bg-[#7028E6]',
// Hover effect with brand color
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
// Text color and transitions
'text-white transition-all duration-200',
// Running state animation
isSubmitting &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20',
// Disabled state
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
{isSubmitting ? 'Publishing...' : 'Publish Workflow'}
</Button>
</div>
</form>
</Form>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex flex-col gap-0 p-0 sm:max-w-[600px]' hideCloseButton>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>
{isPublished() ? 'Marketplace Information' : 'Publish to Marketplace'}
</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='overflow-y-auto px-6 pt-4 pb-6'>
{isPublished() ? renderMarketplaceInfo() : renderPublishForm()}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,110 +0,0 @@
import { useEffect, useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { AlertCircle, Rocket, Store, Terminal } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import type {
Notification,
NotificationOptions,
NotificationType,
} from '@/stores/notifications/types'
interface NotificationDropdownItemProps {
id: string
type: NotificationType
message: string
timestamp: number
options?: NotificationOptions
setDropdownOpen?: (open: boolean) => void
}
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
marketplace: Store,
info: AlertCircle,
api: Rocket,
}
const NotificationColors = {
error: 'text-destructive',
console: 'text-foreground',
marketplace: 'text-foreground',
info: 'text-foreground',
api: 'text-foreground',
}
export function NotificationDropdownItem({
id,
type,
message,
timestamp,
options,
setDropdownOpen,
}: NotificationDropdownItemProps) {
const { notifications, showNotification, hideNotification, removeNotification, addNotification } =
useNotificationStore()
const Icon = NotificationIcon[type]
const [, forceUpdate] = useState({})
// Update the time display every minute
useEffect(() => {
const interval = setInterval(() => forceUpdate({}), 60000)
return () => clearInterval(interval)
}, [])
// Find the full notification object from the store
const getFullNotification = (): Notification | undefined => {
return notifications.find((n) => n.id === id)
}
// Handle click to show the notification
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const notification = getFullNotification()
if (notification) {
// Simply show the notification regardless of its current state
showNotification(id)
} else {
// Fallback for any case where the notification doesn't exist anymore
addNotification(type, message, null, options)
}
// Close the dropdown after clicking
if (setDropdownOpen) {
setDropdownOpen(false)
}
}
// Format time and replace "less than a minute ago" with "<1 minute ago"
const rawTimeAgo = formatDistanceToNow(timestamp, { addSuffix: true })
const timeAgo = rawTimeAgo.replace('less than a minute ago', '<1 minute ago')
return (
<DropdownMenuItem className='flex cursor-pointer items-start gap-2 p-3' onClick={handleClick}>
<Icon className={cn('h-4 w-4', NotificationColors[type])} />
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-xs'>
{type === 'error'
? 'Error'
: type === 'marketplace'
? 'Marketplace'
: type === 'info'
? 'Info'
: 'Console'}
</span>
<span className='text-muted-foreground text-xs'>{timeAgo}</span>
</div>
<p className='overflow-wrap-anywhere hyphens-auto whitespace-normal break-normal text-foreground text-sm'>
{message.length > 100 ? `${message.slice(0, 60)}...` : message}
</p>
</div>
</DropdownMenuItem>
)
}

View File

@@ -0,0 +1,436 @@
'use client'
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Loader2,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
X,
Zap,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { ColorPicker } from '@/components/ui/color-picker'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
const logger = createLogger('TemplateModal')
const templateSchema = z.object({
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)'),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface TemplateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
}
// Enhanced icon selection with category-relevant icons
const icons = [
// Content & Documentation
{ value: 'FileText', label: 'File Text', component: FileText },
{ value: 'NotebookPen', label: 'Notebook', component: NotebookPen },
{ value: 'BookOpen', label: 'Book', component: BookOpen },
{ value: 'Edit', label: 'Edit', component: Edit },
// Analytics & Charts
{ value: 'BarChart3', label: 'Bar Chart', component: BarChart3 },
{ value: 'LineChart', label: 'Line Chart', component: LineChart },
{ value: 'TrendingUp', label: 'Trending Up', component: TrendingUp },
{ value: 'Target', label: 'Target', component: Target },
// Database & Storage
{ value: 'Database', label: 'Database', component: Database },
{ value: 'Server', label: 'Server', component: Server },
{ value: 'Cloud', label: 'Cloud', component: Cloud },
{ value: 'Folder', label: 'Folder', component: Folder },
// Marketing & Communication
{ value: 'Megaphone', label: 'Megaphone', component: Megaphone },
{ value: 'Mail', label: 'Mail', component: Mail },
{ value: 'MessageSquare', label: 'Message', component: MessageSquare },
{ value: 'Phone', label: 'Phone', component: Phone },
{ value: 'Bell', label: 'Bell', component: Bell },
// Sales & Finance
{ value: 'DollarSign', label: 'Dollar Sign', component: DollarSign },
{ value: 'CreditCard', label: 'Credit Card', component: CreditCard },
{ value: 'Calculator', label: 'Calculator', component: Calculator },
{ value: 'ShoppingCart', label: 'Shopping Cart', component: ShoppingCart },
{ value: 'Briefcase', label: 'Briefcase', component: Briefcase },
// Support & Service
{ value: 'HeadphonesIcon', label: 'Headphones', component: HeadphonesIcon },
{ value: 'User', label: 'User', component: User },
{ value: 'Users', label: 'Users', component: Users },
{ value: 'Settings', label: 'Settings', component: Settings },
{ value: 'Wrench', label: 'Wrench', component: Wrench },
// AI & Technology
{ value: 'Bot', label: 'Bot', component: Bot },
{ value: 'Brain', label: 'Brain', component: Brain },
{ value: 'Cpu', label: 'CPU', component: Cpu },
{ value: 'Code', label: 'Code', component: Code },
{ value: 'Zap', label: 'Zap', component: Zap },
// Workflow & Process
{ value: 'Workflow', label: 'Workflow', component: Workflow },
{ value: 'Search', label: 'Search', component: Search },
{ value: 'Play', label: 'Play', component: Play },
{ value: 'Layers', label: 'Layers', component: Layers },
// General
{ value: 'Lightbulb', label: 'Lightbulb', component: Lightbulb },
{ value: 'Star', label: 'Star', component: Star },
{ value: 'Globe', label: 'Globe', component: Globe },
{ value: 'Award', label: 'Award', component: Award },
]
export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
category: '',
icon: 'FileText',
color: '#3972F6',
},
})
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
return
}
setIsSubmitting(true)
try {
// Create the template state from current workflow using the same format as deployment
const templateState = buildWorkflowStateForTemplate(workflowId)
const templateData = {
workflowId,
name: data.name,
description: data.description || '',
author: data.author,
category: data.category,
icon: data.icon,
color: data.color,
state: templateState,
}
const response = await fetch('/api/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create template')
}
const result = await response.json()
logger.info('Template created successfully:', result)
// Reset form and close modal
form.reset()
onOpenChange(false)
// TODO: Show success toast/notification
} catch (error) {
logger.error('Failed to create template:', error)
// TODO: Show error toast/notification
} finally {
setIsSubmitting(false)
}
}
const SelectedIconComponent =
icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto px-6 py-4'>
<div className='space-y-6'>
<div className='flex gap-3'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Icon</FormLabel>
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
<SelectedIconComponent className='h-4 w-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='z-50 w-84 p-0' align='start'>
<div className='p-3'>
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
{icons.map((icon) => {
const IconComponent = icon.component
return (
<button
key={icon.value}
type='button'
onClick={() => {
field.onChange(icon.value)
setIconPopoverOpen(false)
}}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
field.value === icon.value &&
'bg-primary text-primary-foreground'
)}
>
<IconComponent className='h-4 w-4' />
</button>
)
})}
</div>
</div>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem className='w-20'>
<FormLabel>Color</FormLabel>
<FormControl>
<ColorPicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
className='h-10 w-20'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Enter template name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='author'
render={({ field }) => (
<FormItem>
<FormLabel>Author</FormLabel>
<FormControl>
<Input placeholder='Enter author name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a category' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Describe what this template does...'
className='resize-none'
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-end'>
<Button
type='submit'
disabled={isSubmitting}
className={cn(
'font-medium',
'bg-[#701FFC] hover:bg-[#6518E6]',
'shadow-[0_0_0_0_#701FFC] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none',
'h-10 rounded-md px-4 py-2'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Publishing...
</>
) : (
'Publish'
)}
</Button>
</div>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,648 +0,0 @@
// NOTE: API NOTIFICATIONS NO LONGER EXIST, BUT IF YOU DELETE THEM FROM THIS FILE THE APPLICATION WILL BREAK
import { useEffect, useState } from 'react'
import { Info, Rocket, Store, Terminal, X } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { MAX_VISIBLE_NOTIFICATIONS, useNotificationStore } from '@/stores/notifications/store'
import type { Notification } from '@/stores/notifications/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Notifications')
// Constants
const FADE_DURATION = 500 // Fade out over 500ms
// Define keyframes for the animations in a style tag
const AnimationStyles = () => (
<style jsx global>{`
@keyframes notification-slide {
0% {
opacity: 0;
transform: translateY(-100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes notification-fade-out {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-10%);
}
}
@keyframes notification-slide-up {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-100%);
}
}
.animate-notification-slide {
animation: notification-slide 300ms ease forwards;
}
.animate-notification-fade-out {
animation: notification-fade-out ${FADE_DURATION}ms ease forwards;
}
.animate-notification-slide-up {
animation: notification-slide-up 300ms ease forwards;
}
.notification-container {
transition:
height 300ms ease,
opacity 300ms ease,
transform 300ms ease;
}
`}</style>
)
// 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 (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent className='z-[100]'>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Deployment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this API deployment? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
/**
* 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<Set<string>>(new Set())
const [animatingIds, setAnimatingIds] = useState<Set<string>>(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<string, ReturnType<typeof setTimeout>> = {}
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 (
<>
<AnimationStyles />
<div
className='pointer-events-none absolute left-1/2 z-[60] w-full max-w-lg space-y-2'
style={{
top: '30px',
transform: 'translateX(-50%)',
}}
>
{visibleNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
'notification-container',
animatingIds.has(notification.id) && 'animate-notification-slide-up'
)}
>
<NotificationAlert
notification={notification}
isFading={notification.isFading ?? false}
onHide={(id) => {
// 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
}}
/>
</div>
))}
</div>
</>
)
}
/**
* 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 (
<>
<Alert
className={cn(
'pointer-events-auto translate-y-[-100%] opacity-0 transition-all duration-300 ease-in-out',
isFading
? 'pointer-events-none animate-notification-fade-out'
: 'animate-notification-slide',
NotificationColors[type]
)}
>
{type === 'api' ? (
// Special layout for API notifications with equal spacing
<div className='relative flex items-start py-1'>
{/* Left icon */}
<div className='mt-0.5 flex-shrink-0'>
<Icon className='!text-[#802FFF] h-4 w-4' />
</div>
{/* Content area with equal margins */}
<div className='mx-4 flex-1 space-y-2 pt-[3.5px] pr-4'>
<AlertTitle className='-mt-0.5'>
<span>API</span>
</AlertTitle>
<AlertDescription className='space-y-4'>
<p>{!isDeployed ? 'Workflow currently not deployed' : message}</p>
{/* 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 (
<div key={index} className='space-y-1.5'>
<div className='font-medium text-muted-foreground text-xs'>
{section.label}
</div>
{/* Copyable code block */}
<div className='group relative rounded-md border bg-muted/50 transition-colors hover:bg-muted/80'>
{section.label === 'x-api-key' ? (
<>
<pre
className='cursor-pointer overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'
onClick={() => setShowApiKey(!showApiKey)}
title={
showApiKey ? 'Click to hide API Key' : 'Click to reveal API Key'
}
>
{showApiKey ? section.content : maskApiKey(section.content)}
</pre>
<div className='overflow-x-auto whitespace-pre-wrap font-mono text-xs'>
<CopyButton text={section.content} showLabel={false} />
</div>
</>
) : section.label === 'Example curl command' ? (
<>
<pre className='overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
{formatCurlCommand(section.content, apiKey)}
</pre>
<CopyButton text={section.content} showLabel={false} />
</>
) : (
<>
<pre className='overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
{section.content}
</pre>
<CopyButton text={section.content} showLabel={false} />
</>
)}
</div>
</div>
)
})}
{/* Status and Delete button row - with pulsing green indicator */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-muted-foreground text-xs'>Status:</span>
<div className='flex items-center gap-1.5'>
<div className='relative flex items-center justify-center'>
{isDeployed ? (
options?.needsRedeployment ? (
<>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-amber-500/20' />
<div className='relative h-2 w-2 rounded-full bg-amber-500' />
</>
) : (
<>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-green-500/20' />
<div className='relative h-2 w-2 rounded-full bg-green-500' />
</>
)
) : (
<>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-red-500/20' />
<div className='relative h-2 w-2 rounded-full bg-red-500' />
</>
)}
</div>
<span
className={cn(
'font-medium text-xs',
isDeployed
? options?.needsRedeployment
? 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400'
: ApiStatusStyles.active
: ApiStatusStyles.inactive
)}
>
{isDeployed
? options?.needsRedeployment
? 'Changes Detected'
: 'Active'
: 'Inactive'}
</span>
</div>
</div>
<div className='flex gap-2'>
{options?.needsRedeployment && (
<Button
variant='ghost'
size='sm'
className='h-7 px-2.5 font-medium text-muted-foreground text-xs hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400'
onClick={async () => {
if (!workflowId) return
try {
// Call the deploy endpoint to redeploy the workflow
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to redeploy workflow')
// Get the response data
const data = await response.json()
// Update deployment status in the store (resets needsRedeployment flag)
updateDeploymentStatus(
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
// First close this notification
onHide(id)
// Show a temporary success notification without creating another API notification
useNotificationStore
.getState()
.addNotification(
'info',
'Workflow successfully redeployed',
workflowId,
{ isPersistent: false }
)
} catch (error) {
logger.error('Error redeploying workflow:', { error })
}
}}
>
Redeploy
</Button>
)}
{isDeployed && (
<Button
variant='ghost'
size='sm'
className='h-7 px-2.5 font-medium text-muted-foreground text-xs hover:bg-destructive/10 hover:text-destructive'
onClick={() => setIsDeleteDialogOpen(true)}
>
Delete
</Button>
)}
</div>
</div>
</AlertDescription>
</div>
{/* Absolute positioned close button in the top right */}
{options?.isPersistent && (
<div className='absolute top-0.5 right-1'>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'
onClick={() => onHide(id)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
)}
</div>
) : (
// Original layout for error, console and marketplace notifications
<div className='flex items-start gap-4 py-1'>
{/* Icon with proper vertical alignment */}
<div className='mt-0.5 flex-shrink-0'>
<Icon
className={cn('h-4 w-4', {
'!text-red-500 mt-[-3px]': type === 'error',
'mt-[-4px] text-foreground': type === 'console' || type === 'info',
'text-foreground': type === 'marketplace',
})}
/>
</div>
{/* Content area with right margin for balance */}
<div className='mr-4 flex-1 space-y-2'>
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
<span>
{type === 'error'
? 'Error'
: type === 'marketplace'
? 'Marketplace'
: type === 'info'
? 'Info'
: 'Console'}
</span>
{/* Close button for persistent notifications */}
{options?.isPersistent && (
<Button
variant='ghost'
size='sm'
className='h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'
onClick={() => onHide(id)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
)}
</AlertTitle>
<AlertDescription className='space-y-4'>
{/* Message with auto-expanding and max height */}
<p className='max-h-[300px] overflow-hidden whitespace-normal break-words break-all'>
{message}
</p>
{/* Optional sections with copyable content */}
{options?.sections?.map((section, index) => (
<div key={index} className='space-y-1.5'>
<div className='font-medium text-muted-foreground text-xs'>{section.label}</div>
{/* Copyable code block with max height */}
<div className='group relative rounded-md border bg-muted/50 transition-colors hover:bg-muted/80'>
<pre className='max-h-[300px] overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'>
{section.content}
</pre>
<CopyButton text={section.content} />
</div>
</div>
))}
</AlertDescription>
</div>
</div>
)}
</Alert>
{/* Delete API confirmation dialog */}
<DeleteApiConfirmation
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleDeleteApi}
workflowId={workflowId}
/>
</>
)
}

View File

@@ -315,7 +315,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
return (
<div className='flex h-full flex-col'>
{/* Output Source Dropdown */}
<div className='flex-none border-b px-4 py-2'>
<div className='flex-none py-2'>
<OutputSelect
workflowId={activeWorkflowId}
selectedOutputs={selectedOutputs}
@@ -329,38 +329,38 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Chat messages section - Scrollable area */}
<div className='flex-1 overflow-hidden'>
<ScrollArea className='h-full'>
<div>
{workflowMessages.length === 0 ? (
<div className='flex h-32 items-center justify-center text-muted-foreground text-sm'>
No messages yet
</div>
) : (
workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} containerWidth={panelWidth} />
))
)}
<div ref={messagesEndRef} />
{workflowMessages.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
No messages yet
</div>
</ScrollArea>
) : (
<ScrollArea className='h-full pb-2' hideScrollbar={true}>
<div>
{workflowMessages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
)}
</div>
{/* Input section - Fixed height */}
<div className='-mt-[1px] relative flex-none border-t bg-background px-4 pt-4 pb-4'>
<div className='-mt-[1px] relative flex-nonept-3 pb-4'>
<div className='flex gap-2'>
<Input
value={chatMessage}
onChange={(e) => 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}
/>
<Button
onClick={handleSendMessage}
size='icon'
disabled={!chatMessage.trim() || !activeWorkflowId || isExecuting}
className='h-10 w-10 bg-[#802FFF] text-white hover:bg-[#7028E6]'
className='h-9 w-9 rounded-lg bg-[#802FFF] text-white shadow-[0_0_0_0_#802FFF] transition-all duration-200 hover:bg-[#7028E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
<ArrowUp className='h-4 w-4' />
</Button>

View File

@@ -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 (
<div className='w-full space-y-4 border-border border-b p-4 transition-colors hover:bg-accent/50'>
{/* Header with time on left and message type on right */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm'>
<Clock className='h-4 w-4 text-muted-foreground' />
<span className='text-muted-foreground'>{relativeTime}</span>
</div>
<div className='flex items-center gap-2 text-sm'>
{message.type !== 'user' && <span className='text-muted-foreground'>Workflow</span>}
{message.isStreaming && (
<span className='ml-2 animate-pulse text-primary' title='Streaming'>
</span>
)}
// Render human messages as chat bubbles
if (message.type === 'user') {
return (
<div className='w-full py-2'>
<div className='flex justify-end'>
<div className='max-w-[80%]'>
<div className='rounded-[10px] bg-secondary px-3 py-2'>
<div className='whitespace-pre-wrap break-words font-normal text-foreground text-sm leading-normal'>
<WordWrap text={formattedContent} />
</div>
</div>
</div>
</div>
</div>
)
}
{/* Message content with proper word wrapping */}
<div className='overflow-wrap-anywhere relative flex-1 whitespace-normal break-normal font-mono text-sm'>
{isJsonObject ? (
<JSONView data={message.content} initiallyExpanded={false} />
) : (
<div className='whitespace-pre-wrap break-words text-foreground'>
<WordWrap text={formattedContent} />
{message.isStreaming && (
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
)}
</div>
)}
// Render agent/workflow messages as full-width text
return (
<div className='w-full py-2 pl-[2px]'>
<div className='overflow-wrap-anywhere relative whitespace-normal break-normal font-normal text-sm leading-normal'>
<div className='whitespace-pre-wrap break-words text-foreground'>
<WordWrap text={formattedContent} />
{message.isStreaming && (
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
)}
</div>
</div>
</div>
)

View File

@@ -31,7 +31,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
<div className='max-w-[80%] rounded-3xl bg-[#F4F4F4] px-4 py-3 shadow-sm dark:bg-primary/10'>
<div className='whitespace-pre-wrap break-words text-[#0D0D0D] text-base leading-relaxed dark:text-white'>
{isJsonObject ? (
<JSONView data={message.content} initiallyExpanded={false} />
<JSONView data={message.content} />
) : (
<span>{message.content}</span>
)}
@@ -50,11 +50,7 @@ function ModalChatMessage({ message }: ChatMessageProps) {
<div className='flex'>
<div className='max-w-[80%]'>
<div className='whitespace-pre-wrap break-words text-base leading-relaxed'>
{isJsonObject ? (
<JSONView data={message.content} initiallyExpanded={false} />
) : (
<span>{message.content}</span>
)}
{isJsonObject ? <JSONView data={message.content} /> : <span>{message.content}</span>}
</div>
</div>
</div>

View File

@@ -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 (
<div className='relative' ref={dropdownRef}>
<div className='relative w-full' ref={dropdownRef}>
<button
type='button'
onClick={() => setIsOutputDropdownOpen(!isOutputDropdownOpen)}
className={`flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm transition-colors ${
className={`flex h-9 w-full items-center justify-between rounded-[8px] border px-3 py-1.5 font-normal text-sm shadow-xs transition-colors ${
isOutputDropdownOpen
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
? 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground dark:border-[#414141] dark:bg-[#202020]'
: 'border-[#E5E5E5] bg-[#FFFFFF] text-muted-foreground hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020]'
}`}
disabled={workflowOutputs.length === 0 || disabled}
>
{selectedOutputInfo ? (
<div className='flex w-[calc(100%-24px)] items-center gap-2 overflow-hidden'>
<div className='flex w-[calc(100%-24px)] items-center gap-2 overflow-hidden text-left'>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
@@ -315,10 +314,12 @@ export function OutputSelect({
{selectedOutputInfo.blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='truncate'>{selectedOutputsDisplayText}</span>
<span className='truncate text-left'>{selectedOutputsDisplayText}</span>
</div>
) : (
<span className='w-[calc(100%-24px)] truncate'>{selectedOutputsDisplayText}</span>
<span className='w-[calc(100%-24px)] truncate text-left'>
{selectedOutputsDisplayText}
</span>
)}
<ChevronDown
className={`ml-1 h-4 w-4 flex-shrink-0 transition-transform ${
@@ -328,11 +329,11 @@ export function OutputSelect({
</button>
{isOutputDropdownOpen && workflowOutputs.length > 0 && (
<div className='absolute z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover pt-1 shadow-md'>
<div className='max-h-[240px] overflow-y-auto'>
<div className='absolute left-0 z-50 mt-1 w-full overflow-hidden rounded-[8px] border border-[#E5E5E5] bg-[#FFFFFF] pt-1 shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
<div className='max-h-[230px] overflow-y-auto'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => (
<div key={blockName}>
<div className='border-t px-2 pt-1.5 pb-0.5 font-medium text-muted-foreground text-xs first:border-t-0'>
<div className='border-[#E5E5E5] border-t px-3 pt-1.5 pb-0.5 font-normal text-muted-foreground text-xs first:border-t-0 dark:border-[#414141]'>
{blockName}
</div>
<div>
@@ -342,20 +343,11 @@ export function OutputSelect({
key={output.id}
onClick={() => handleOutputSelection(output.id)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'flex w-full items-center gap-2 px-3 py-1.5 text-left font-normal text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none'
)}
>
<div className='flex h-5 w-5 flex-shrink-0 items-center justify-center'>
{selectedOutputs.includes(output.id) ? (
<div className='flex h-4 w-4 items-center justify-center rounded bg-primary'>
<Check className='h-3 w-3 text-white' />
</div>
) : (
<div className='h-4 w-4 rounded border border-input' />
)}
</div>
<div
className='flex h-5 w-5 flex-shrink-0 items-center justify-center rounded'
style={{
@@ -366,25 +358,16 @@ export function OutputSelect({
{blockName.charAt(0).toUpperCase()}
</span>
</div>
<span className='max-w-[calc(100%-48px)] truncate'>{output.path}</span>
<span className='flex-1 truncate'>{output.path}</span>
{selectedOutputs.includes(output.id) && (
<Check className='h-4 w-4 flex-shrink-0 text-primary' />
)}
</button>
))}
</div>
</div>
))}
</div>
{/* Done button to close dropdown */}
<div className='border-t p-2'>
<Button
variant='secondary'
size='sm'
onClick={() => setIsOutputDropdownOpen(false)}
className='w-full bg-secondary/80 text-secondary-foreground hover:bg-secondary/90'
>
Done
</Button>
</div>
</div>
)}
</div>

View File

@@ -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<HTMLAudioElement | null>(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<HTMLDivElement>) => {
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 (
<div className='mt-2 flex w-full max-w-xs items-center gap-2 rounded-md bg-background/40 p-2'>
<button
className='inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors hover:bg-primary/20'
onClick={togglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className='h-3.5 w-3.5' /> : <Play className='ml-0.5 h-3.5 w-3.5' />}
</button>
<div
className='h-1.5 flex-grow cursor-pointer overflow-hidden rounded-full bg-muted'
onClick={seekAudio}
>
<div className='h-full rounded-full bg-primary/40' style={{ width: `${progress}%` }} />
</div>
<button
className='inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
onClick={downloadAudio}
aria-label='Download audio'
>
<Download className='h-3 w-3' />
</button>
</div>
)
}

View File

@@ -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<number[]>([])
const containerRef = useRef<HTMLDivElement>(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(
<div key={`${lineNumber}-0`} className='text-muted-foreground text-xs leading-[21px]'>
{lineNumber}
</div>
)
for (let i = 1; i < height; i++) {
numbers.push(
<div
key={`${lineNumber}-${i}`}
className='invisible text-muted-foreground text-xs leading-[21px]'
>
{lineNumber}
</div>
)
}
lineNumber++
})
if (numbers.length === 0) {
const lines = code.split('\n')
return lines.map((_, index) => (
<div key={index} className='text-muted-foreground text-xs leading-[21px]'>
{index + 1}
</div>
))
}
return numbers
}
return (
<div
className='relative overflow-hidden rounded-md border bg-background font-mono text-sm'
ref={containerRef}
>
{/* Line numbers */}
<div
className='absolute top-0 bottom-0 left-0 flex select-none flex-col items-end overflow-hidden bg-secondary pt-2 pr-3'
style={{ width: `${sidebarWidth}px` }}
aria-hidden='true'
>
{renderLineNumbers()}
</div>
{/* Code content */}
<div className='relative' style={{ paddingLeft: `${sidebarWidth}px` }}>
<pre
className='max-w-full overflow-hidden px-3 py-2 text-sm leading-[21px]'
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
<code
dangerouslySetInnerHTML={{
__html: highlight(code, languages[language], language),
}}
/>
</pre>
</div>
</div>
)
}

View File

@@ -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 <span key={index}>{part}</span>
}
// 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 (
<span key={index} className='break-all'>
{chunks.map((chunk, chunkIndex) => (
<span key={chunkIndex}>{chunk}</span>
))}
</span>
)
})}
</>
<div className='my-2 w-1/2'>
<Image
src={imageSrc}
alt='Generated image'
width={400}
height={300}
className='h-auto w-full rounded-lg border'
unoptimized
onError={(e) => {
console.error('Image failed to load:', imageSrc)
setLoadError(true)
onLoadError?.(true)
}}
onLoad={() => {
onLoadError?.(false)
}}
/>
</div>
)
}
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<HTMLAudioElement | null>(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 (
<div
className={`border-border border-b transition-colors ${
!entry.error && !entry.warning && entry.success ? 'cursor-pointer hover:bg-accent/50' : ''
}`}
onClick={() => !entry.error && !entry.warning && entry.success && setIsExpanded(!isExpanded)}
>
<div className='space-y-4 p-4'>
<div className='space-y-3'>
{/* Header: Icon | Block name */}
<div className='flex items-center gap-2'>
{BlockIcon && (
<div
className='flex h-5 w-5 items-center justify-center rounded-md'
style={{ backgroundColor: blockColor }}
>
<BlockIcon className='h-3 w-3 text-white' />
</div>
)}
<span className='font-normal text-base text-sm leading-normal'>
{entry.blockName || 'Unknown Block'}
</span>
</div>
{/* Duration tag | Time tag | Input/Output tags */}
<div className='flex items-center gap-2'>
<div
className={`${
consoleWidth >= 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 && (
<div className='flex items-center gap-2 text-sm'>
{BlockIcon ? (
<BlockIcon className='h-4 w-4 text-muted-foreground' />
) : (
<Terminal className='h-4 w-4 text-muted-foreground' />
)}
<span className='text-muted-foreground'>{entry.blockName}</span>
{entry.error ? (
<div className='flex items-center gap-1'>
<AlertCircle className='h-3 w-3 text-[#DC2626] dark:text-[#F87171]' />
<span className='font-normal text-[#DC2626] text-xs leading-normal dark:text-[#F87171]'>
Error
</span>
</div>
) : (
<span className='font-normal text-muted-foreground text-xs leading-normal'>
{entry.durationMs ?? 0}ms
</span>
)}
<div
className={`${
consoleWidth >= 400 ? 'flex gap-4' : 'grid grid-cols-2 gap-4'
} text-muted-foreground text-sm`}
>
<div className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
<span>{entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}</span>
</div>
<div className='flex items-center gap-2'>
<Clock className='h-4 w-4' />
<span>Duration: {entry.durationMs ?? 0}ms</span>
</div>
<div className='flex h-5 items-center rounded-lg bg-secondary px-2'>
<span className='font-normal text-muted-foreground text-xs leading-normal'>
{entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}
</span>
</div>
{/* Input/Output tags - only show if input data exists */}
{hasInputData && (
<>
<button
onClick={() => setShowInput(false)}
className={`flex h-5 items-center rounded-lg px-2 transition-colors ${
!showInput
? 'border-[#e5e5e5] bg-[#f5f5f5] text-[#1a1a1a] dark:border-[#424242] dark:bg-[#1f1f1f] dark:text-[#ffffff]'
: 'bg-secondary text-muted-foreground hover:bg-secondary hover:text-card-foreground'
}`}
>
<span className='font-normal text-xs leading-normal'>Output</span>
</button>
<button
onClick={() => setShowInput(true)}
className={`flex h-5 items-center rounded-lg px-2 transition-colors ${
showInput
? 'border-[#e5e5e5] bg-[#f5f5f5] text-[#1a1a1a] dark:border-[#424242] dark:bg-[#1f1f1f] dark:text-[#ffffff]'
: 'bg-secondary text-muted-foreground hover:bg-secondary hover:text-card-foreground'
}`}
>
<span className='font-normal text-xs leading-normal'>Input</span>
</button>
{/* Copy button for code input - only show when input is selected and it's a function block with code */}
{shouldShowCodeDisplay && (
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0 hover:bg-transparent'
onClick={handleCopy}
>
{showCopySuccess ? (
<Check className='h-3 w-3 text-gray-500' />
) : (
<Clipboard className='h-3 w-3 text-muted-foreground' />
)}
</Button>
)}
</>
)}
</div>
{/* Response area */}
<div className='space-y-2 pb-2'>
{/* Error display */}
{entry.error && !showInput && (
<div className='rounded-lg bg-[#F6D2D2] p-3 dark:bg-[#442929]'>
<div className='overflow-hidden whitespace-pre-wrap break-all font-normal text-[#DC2626] text-sm leading-normal dark:text-[#F87171]'>
{entry.error}
</div>
</div>
</div>
)}
<div className='space-y-4'>
{!entry.error && !entry.warning && (
<div className='flex items-start gap-2'>
<Terminal className='mt-1 h-4 w-4 text-muted-foreground' />
<div className='overflow-wrap-anywhere relative flex-1 whitespace-normal break-normal font-mono text-sm'>
{entry.output != null && (
<div className='absolute top-0 right-0 z-10'>
{/* Warning display */}
{entry.warning && !showInput && (
<div className='rounded-lg border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800/50'>
<div className='mb-1 font-normal text-sm text-yellow-800 leading-normal dark:text-yellow-200'>
Warning
</div>
<div className='overflow-hidden whitespace-pre-wrap break-all font-normal text-sm text-yellow-700 leading-normal dark:text-yellow-300'>
{entry.warning}
</div>
</div>
)}
{/* Content display */}
{(showInput ? hasInputData : entry.output != null && !entry.error) && (
<div className={`rounded-lg bg-secondary/50 ${shouldShowCodeDisplay ? 'p-0' : 'p-3'}`}>
{shouldShowCodeDisplay ? (
/* Code display - replace entire content */
<CodeDisplay code={entry.input.code} />
) : (
<div className='relative'>
{/* Copy and Expand/Collapse buttons */}
<div className='absolute top-[-2.8] right-0 z-10 flex items-center gap-1'>
{/* Audio controls - only show if audio data exists and we're showing output */}
{hasAudio && !showInput && (
<>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-transparent'
onClick={togglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause className='h-3 w-3 text-muted-foreground' />
) : (
<Play className='h-3 w-3 text-muted-foreground' />
)}
</Button>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-transparent'
onClick={downloadAudio}
aria-label='Download audio'
>
<Download className='h-3 w-3 text-muted-foreground' />
</Button>
</>
)}
{/* Image controls - only show if image data exists and didn't fail to load and we're showing output */}
{showImageDownload && !showInput && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-muted-foreground hover:text-foreground'
onClick={(e) => {
e.stopPropagation()
setExpandAllJson(!expandAllJson)
}}
className='h-6 w-6 p-0 hover:bg-transparent'
onClick={downloadImage}
aria-label='Download image'
>
<span className='flex items-center'>
{expandAllJson ? (
<>
<ChevronUp className='mr-1 h-3 w-3' />
<span className='text-xs'>Collapse</span>
</>
) : (
<>
<ChevronDown className='mr-1 h-3 w-3' />
<span className='text-xs'>Expand</span>
</>
)}
</span>
<Download className='h-3 w-3 text-muted-foreground' />
</Button>
)}
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-transparent'
onClick={handleCopy}
>
{showCopySuccess ? (
<Check className='h-3 w-3 text-gray-500' />
) : (
<Clipboard className='h-3 w-3 text-muted-foreground' />
)}
</Button>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-transparent'
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</Button>
</div>
{/* Image preview - show before JSON content - only for output mode */}
{hasImage && !showInput && (
<ImagePreview
imageUrl={imageUrl || undefined}
imageData={imageData || undefined}
isBase64={isBase64Image}
onLoadError={handleImageLoadError}
/>
)}
{/* Content */}
{isExpanded ? (
<div className='max-w-full overflow-hidden break-all font-mono font-normal text-muted-foreground text-sm leading-normal'>
<JSONView data={displayData} />
</div>
) : (
<div
className='max-w-full cursor-pointer overflow-hidden break-all font-mono font-normal text-muted-foreground text-sm leading-normal'
onClick={() => setIsExpanded(true)}
>
{'{...}'}
</div>
)}
<JSONView data={entry.output} initiallyExpanded={expandAllJson} />
</div>
</div>
)}
)}
</div>
)}
{entry.error && (
<div className='flex items-start gap-2 rounded-md border border-red-500 bg-red-50 p-3 text-destructive dark:border-border dark:bg-background dark:text-foreground'>
<AlertCircle className='mt-1 h-4 w-4 flex-shrink-0 text-red-500' />
<div className='min-w-0 flex-1'>
<div className='font-medium'>Error</div>
<div className='w-full overflow-hidden whitespace-pre-wrap text-sm'>
<WordWrap text={entry.error} />
</div>
</div>
{/* No output message */}
{!showInput && entry.output == null && !entry.error && (
<div className='rounded-lg bg-secondary/50 p-3'>
<div className='text-center font-normal text-muted-foreground text-sm leading-normal'>
No output
</div>
)}
</div>
)}
{entry.warning && (
<div className='flex items-start gap-2 rounded-md border border-yellow-500 bg-yellow-50 p-3 text-yellow-700 dark:border-border dark:bg-background dark:text-yellow-500'>
<AlertTriangle className='mt-1 h-4 w-4 flex-shrink-0 text-yellow-500' />
<div className='min-w-0 flex-1'>
<div className='font-medium'>Warning</div>
<div className='w-full overflow-hidden whitespace-pre-wrap text-sm'>
<WordWrap text={entry.warning} />
</div>
</div>
{/* No input message */}
{showInput && !hasInputData && (
<div className='rounded-lg bg-secondary/50 p-3'>
<div className='text-center font-normal text-muted-foreground text-sm leading-normal'>
No input
</div>
)}
</div>
</div>
)}
</div>
</div>
)

View File

@@ -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 <span>{value}</span>
return (
<span className='break-all font-[380] text-muted-foreground leading-normal'>{value}</span>
)
}
return (
<span>
<span className='break-all font-[380] text-muted-foreground leading-normal'>
{isExpanded ? value : `${value.slice(0, MAX_STRING_LENGTH)}...`}
<Button
variant='link'
size='sm'
className='h-auto px-1 text-muted-foreground text-xs hover:text-foreground'
className='h-auto px-1 font-[380] text-muted-foreground/70 text-xs hover:text-foreground'
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
@@ -36,207 +36,124 @@ const TruncatedValue = ({ value }: { value: string }) => {
)
}
const CollapsibleJSON = ({ data, depth = 0 }: { data: any; depth?: number }) => {
const [isExpanded, setIsExpanded] = useState(false)
if (data === null) {
return <span className='break-all font-[380] text-muted-foreground leading-normal'>null</span>
}
if (data === undefined) {
return (
<span className='break-all font-[380] text-muted-foreground leading-normal'>undefined</span>
)
}
if (typeof data === 'string') {
return <TruncatedValue value={JSON.stringify(data)} />
}
if (typeof data === 'number' || typeof data === 'boolean') {
return (
<span className='break-all font-[380] text-muted-foreground leading-normal'>
{JSON.stringify(data)}
</span>
)
}
if (Array.isArray(data)) {
const shouldCollapse = depth > 0 && data.length > MAX_ARRAY_ITEMS
if (shouldCollapse && !isExpanded) {
return (
<span
className='cursor-pointer break-all font-[380] text-muted-foreground/70 text-xs leading-normal'
onClick={() => setIsExpanded(true)}
>
{'[...]'}
</span>
)
}
return (
<span className='break-all font-[380] text-muted-foreground/70 leading-normal'>
{'['}
{data.length > 0 && (
<>
{'\n'}
{data.map((item, index) => (
<span key={index} className='break-all'>
{' '.repeat(depth + 1)}
<CollapsibleJSON data={item} depth={depth + 1} />
{index < data.length - 1 ? ',' : ''}
{'\n'}
</span>
))}
{' '.repeat(depth)}
</>
)}
{']'}
</span>
)
}
if (typeof data === 'object') {
const keys = Object.keys(data)
const shouldCollapse = depth > 0 && keys.length > MAX_OBJECT_KEYS
if (shouldCollapse && !isExpanded) {
return (
<span
className='cursor-pointer break-all font-[380] text-muted-foreground/70 text-xs leading-normal'
onClick={() => setIsExpanded(true)}
>
{'{...}'}
</span>
)
}
return (
<span className='break-all font-[380] text-muted-foreground/70 leading-normal'>
{'{'}
{keys.length > 0 && (
<>
{'\n'}
{keys.map((key, index) => (
<span key={key} className='break-all'>
{' '.repeat(depth + 1)}
<span className='break-all font-[380] text-foreground leading-normal'>{key}</span>
<span className='font-[380] text-muted-foreground/60 leading-normal'>: </span>
<CollapsibleJSON data={data[key]} depth={depth + 1} />
{index < keys.length - 1 ? ',' : ''}
{'\n'}
</span>
))}
{' '.repeat(depth)}
</>
)}
{'}'}
</span>
)
}
return (
<span className='break-all font-[380] text-muted-foreground leading-normal'>
{JSON.stringify(data)}
</span>
)
}
const copyToClipboard = (data: any) => {
const stringified = JSON.stringify(data, null, 2)
navigator.clipboard.writeText(stringified)
}
// Helper function to check if an object contains an image URL
const isImageData = (obj: any): boolean => {
if (!obj || typeof obj !== 'object' || !('url' in obj) || typeof obj.url !== 'string') {
return false
}
// Check if we have metadata with file type
if (obj.metadata?.fileType) {
return obj.metadata.fileType.startsWith('image/')
}
// Fallback to checking URL extension
const url = obj.url.toLowerCase()
return url.match(/\.(jpg|jpeg|png|gif|webp|svg)(\?|$)/) !== null
}
// Helper function to check if an object contains an audio URL
const isAudioData = (obj: any): boolean => {
return obj && typeof obj === 'object' && 'audioUrl' in obj && typeof obj.audioUrl === 'string'
}
// Helper function to check if a string is likely a base64 image
const isBase64Image = (str: string): boolean => {
if (typeof str !== 'string') return false
// Check if it's a reasonably long string that could be a base64 image
// and contains only valid base64 characters
return str.length > 100 && /^[A-Za-z0-9+/=]+$/.test(str)
}
// Check if this is a response with the new image structure
// Strict validation to only detect actual image responses
const hasImageContent = (obj: any): boolean => {
// Debug check - basic structure validation
if (
!(
obj &&
typeof obj === 'object' &&
'content' in obj &&
typeof obj.content === 'string' &&
'metadata' in obj &&
typeof obj.metadata === 'object'
)
) {
return false
}
// Case 1: Has explicit image data
const hasExplicitImageData =
'image' in obj &&
typeof obj.image === 'string' &&
obj.image.length > 0 &&
isBase64Image(obj.image)
if (hasExplicitImageData) {
return true
}
// Case 2: Has explicit image type in metadata
const hasExplicitImageType =
obj.metadata?.type &&
typeof obj.metadata.type === 'string' &&
obj.metadata.type.toLowerCase() === 'image'
if (hasExplicitImageType) {
return true
}
// Case 3: Content URL points to an image file
const isImageUrl =
typeof obj.content === 'string' &&
obj.content.startsWith('http') &&
!!obj.content.toLowerCase().match(/\.(png|jpg|jpeg|gif|webp|svg)(\?|$)/)
return isImageUrl
}
// Image preview component with support for both URL and base64
const ImagePreview = ({
imageUrl,
imageData,
isBase64 = false,
}: {
imageUrl?: string
imageData?: string
isBase64?: boolean
}) => {
const [loadError, setLoadError] = useState(false)
const downloadImage = async () => {
try {
let blob: Blob
if (isBase64 && 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.')
}
}
// Only display image if we have valid data
const hasValidData =
(isBase64 && imageData && imageData.length > 0) || (imageUrl && imageUrl.length > 0)
if (!hasValidData) {
return <div className='my-2 text-muted-foreground'>Image data unavailable</div>
}
if (loadError) {
return <div className='my-2 text-muted-foreground'>Failed to load image</div>
}
// Determine the source for the image
const imageSrc =
isBase64 && imageData && imageData.length > 0
? `data:image/png;base64,${imageData}`
: imageUrl || ''
return (
<div className='group relative my-2'>
<img
src={imageSrc}
alt='Generated image'
className='h-auto max-w-full rounded-md border'
onError={(e) => {
console.error('Image failed to load:', imageSrc)
setLoadError(true)
e.currentTarget.alt = 'Failed to load image'
e.currentTarget.style.height = '100px'
e.currentTarget.style.width = '100%'
e.currentTarget.style.display = 'flex'
e.currentTarget.style.alignItems = 'center'
e.currentTarget.style.justifyContent = 'center'
e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'
}}
/>
{!loadError && (
<div className='absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='secondary'
size='icon'
className='h-8 w-8 bg-background/80 backdrop-blur-sm'
onClick={(e) => {
e.stopPropagation()
downloadImage()
}}
>
<Download className='h-4 w-4' />
<span className='sr-only'>Download image</span>
</Button>
</div>
)}
</div>
)
}
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 <span className='text-muted-foreground'>null</span>
// Handle base64 image strings directly
if (isBase64ImageString) {
return (
<div onContextMenu={handleContextMenu}>
<ImagePreview imageData={data} isBase64={true} />
{contextMenuPosition && (
<div
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
>
<button
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
>
Copy base64 string
</button>
<button
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => {
document
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
?.click()
}}
>
<Download className='h-4 w-4' />
Download image
</button>
</div>
)}
</div>
)
}
if (data === null)
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
// For non-object data, show simple JSON
if (typeof data !== 'object') {
const stringValue = JSON.stringify(data)
return (
<span
onContextMenu={handleContextMenu}
className={`${typeof data === 'string' ? 'text-success' : 'text-info'} relative break-all`}
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
>
{typeof data === 'string' ? <TruncatedValue value={stringValue} /> : stringValue}
{typeof data === 'string' ? (
<TruncatedValue value={stringValue} />
) : (
<span className='break-all font-[380] text-muted-foreground leading-normal'>
{stringValue}
</span>
)}
{contextMenuPosition && (
<div
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
>
<button
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
>
Copy value
@@ -326,307 +202,25 @@ export const JSONView = ({ data, level = 0, initiallyExpanded = false }: JSONVie
)
}
// Handle objects that have the new image structure
if (isResponseWithImage) {
// Get the URL from content field since that's where it should be
const imageUrl = data.content && typeof data.content === 'string' ? data.content : undefined
// Check if we have valid image data
const hasValidImage = data.image && typeof data.image === 'string' && data.image.length > 0
return (
<div className='relative' onContextMenu={handleContextMenu}>
<span
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
onClick={(e) => {
e.stopPropagation()
setIsCollapsed(!isCollapsed)
}}
>
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
<span>{'{'}</span>
{isCollapsed ? '...' : ''}
</span>
{!isCollapsed && (
<div className='ml-4 break-words'>
{Object.entries(data).map(([key, value], index) => {
const isImageKey = key === 'image'
return (
<div key={key} className='break-all'>
<span className='text-muted-foreground'>{key}</span>:{' '}
{isImageKey ? (
<div>
{/* Show image preview within the image field */}
<ImagePreview
imageUrl={imageUrl}
imageData={
hasValidImage && isBase64Image(data.image) ? data.image : undefined
}
isBase64={hasValidImage && isBase64Image(data.image)}
/>
</div>
) : (
<JSONView
data={value}
level={level + 1}
initiallyExpanded={initiallyExpanded}
/>
)}
{index < Object.entries(data).length - 1 && ','}
</div>
)
})}
</div>
)}
{contextMenuPosition && (
<div
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
>
<button
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
>
Copy object
</button>
<button
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => {
document
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
?.click()
}}
>
<Download className='h-4 w-4' />
Download image
</button>
</div>
)}
<span className='text-muted-foreground'>{'}'}</span>
</div>
)
}
// 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 (
<div className='relative' onContextMenu={handleContextMenu}>
<span
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
onClick={(e) => {
e.stopPropagation()
setIsCollapsed(!isCollapsed)
}}
>
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
<span>{'{'}</span>
{isCollapsed ? '...' : ''}
</span>
{!isCollapsed && (
<div className='ml-4 break-words'>
{Object.entries(data).map(([key, value]: [string, any], index) => {
const isOutputKey = key === 'output'
return (
<div key={key} className='break-all'>
<span className='text-muted-foreground'>{key}</span>:{' '}
{isOutputKey ? (
<div className='relative'>
<span
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
onClick={(e) => {
e.stopPropagation()
const nestedElem = e.currentTarget.nextElementSibling
if (nestedElem) {
nestedElem.classList.toggle('hidden')
}
}}
>
<span className='mr-1 text-xs leading-none'></span>
<span>{'{'}</span>
</span>
<div className='ml-4 break-words'>
{Object.entries(value).map(
([outputKey, outputValue]: [string, any], idx) => {
const isImageSubKey = outputKey === 'image'
return (
<div key={outputKey} className='break-all'>
<span className='text-muted-foreground'>{outputKey}</span>:{' '}
{isImageSubKey ? (
<div>
{/* Show image preview within nested image field */}
<ImagePreview
imageUrl={imageUrl}
imageData={
hasValidImage && isBase64Image(outputValue)
? outputValue
: undefined
}
isBase64={hasValidImage && isBase64Image(outputValue)}
/>
</div>
) : (
<JSONView
data={outputValue}
level={level + 2}
initiallyExpanded={initiallyExpanded}
/>
)}
{idx < Object.entries(value).length - 1 && ','}
</div>
)
}
)}
</div>
<span className='text-muted-foreground'>{'}'}</span>
</div>
) : (
<JSONView
data={value}
level={level + 1}
initiallyExpanded={initiallyExpanded}
/>
)}
{index < Object.entries(data).length - 1 && ','}
</div>
)
})}
</div>
)}
{contextMenuPosition && (
<div
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
>
<button
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
>
Copy object
</button>
<button
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => {
document
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
?.click()
}}
>
<Download className='h-4 w-4' />
Download image
</button>
</div>
)}
<span className='text-muted-foreground'>{'}'}</span>
</div>
)
}
const isArray = Array.isArray(data)
const items = isArray ? data : Object.entries(data)
const isEmpty = items.length === 0
if (isEmpty) {
return <span className='text-muted-foreground'>{isArray ? '[]' : '{}'}</span>
}
// Default case: show JSON as formatted text with collapsible functionality
return (
<div className='relative' onContextMenu={handleContextMenu}>
<span
className='inline-flex cursor-pointer select-none items-center text-muted-foreground'
onClick={(e) => {
e.stopPropagation()
setIsCollapsed(!isCollapsed)
}}
>
<span className='mr-1 text-xs leading-none'>{isCollapsed ? '▶' : '▼'}</span>
<span>{isArray ? '[' : '{'}</span>
{isCollapsed ? '...' : ''}
</span>
{/* Direct image render for objects with image URLs */}
{!isCollapsed && hasImageUrl && <ImagePreview imageUrl={data.url} />}
{/* Direct audio render for objects with audio URLs */}
{!isCollapsed && hasAudioUrl && <AudioPlayer audioUrl={data.audioUrl} />}
<div onContextMenu={handleContextMenu}>
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
<CollapsibleJSON data={data} />
</pre>
{contextMenuPosition && (
<div
className='fixed z-50 min-w-[160px] rounded-md border bg-popover py-1 shadow-md'
style={{ left: contextMenuPosition.x, top: contextMenuPosition.y }}
>
<button
className='w-full px-3 py-1.5 text-left text-sm hover:bg-accent'
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
>
Copy object
</button>
{hasImageUrl && (
<button
className='flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent'
onClick={() => {
document
.querySelector<HTMLButtonElement>('.group .bg-background\\/80 button')
?.click()
}}
>
<Download className='h-4 w-4' />
Download image
</button>
)}
</div>
)}
{!isCollapsed && (
<div className='ml-4 break-words'>
{isArray
? items.map((item, index) => (
<div key={index} className='break-all'>
<JSONView data={item} level={level + 1} initiallyExpanded={initiallyExpanded} />
{index < items.length - 1 && ','}
</div>
))
: (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 (
<div key={key} className='break-all'>
<span className='text-muted-foreground'>{key}</span>:{' '}
{isImageField ? (
<JSONView
data={value}
level={level + 1}
initiallyExpanded={initiallyExpanded}
/>
) : (
<JSONView
data={value}
level={level + 1}
initiallyExpanded={initiallyExpanded}
/>
)}
{index < items.length - 1 && ','}
</div>
)
})}
</div>
)}
<span className='text-muted-foreground'>{isArray ? ']' : '}'}</span>
</div>
)
}

View File

@@ -19,18 +19,20 @@ export function Console({ panelWidth }: ConsoleProps) {
}, [entries, activeWorkflowId])
return (
<ScrollArea className='h-full'>
<div>
{filteredEntries.length === 0 ? (
<div className='flex h-32 items-center justify-center pt-4 text-muted-foreground text-sm'>
No console entries
<div className='h-full pt-2 pl-[1px]'>
{filteredEntries.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
No console entries
</div>
) : (
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='space-y-3'>
{filteredEntries.map((entry) => (
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={panelWidth} />
))}
</div>
) : (
filteredEntries.map((entry) => (
<ConsoleEntry key={entry.id} entry={entry} consoleWidth={panelWidth} />
))
)}
</div>
</ScrollArea>
</ScrollArea>
)}
</div>
)
}

View File

@@ -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 (
<ScrollArea className='h-full'>
<div className='space-y-3 p-4'>
{/* Variables List */}
{workflowVariables.length === 0 ? (
<div className='flex h-32 flex-col items-center justify-center pt-4 text-muted-foreground text-sm'>
<div className='mb-2'>No variables yet</div>
<Button variant='outline' size='sm' className='text-xs' onClick={handleAddVariable}>
<Plus className='mr-1 h-3.5 w-3.5' />
Add your first variable
</Button>
</div>
) : (
<>
<div className='space-y-3'>
{workflowVariables.map((variable) => (
<div
key={variable.id}
className='group flex flex-col space-y-2 rounded-lg border bg-background shadow-sm'
>
<div className='flex items-center justify-between border-b bg-muted/30 p-3'>
<div className='flex flex-1 items-center gap-2'>
<Input
className='!text-md h-9 max-w-40 border-input bg-background focus-visible:ring-1 focus-visible:ring-ring'
placeholder='Variable name'
value={variable.name}
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
/>
<div className='h-full pt-2'>
{workflowVariables.length === 0 ? (
<div className='flex h-full items-center justify-center'>
<Button
onClick={handleAddVariable}
className='h-9 rounded-lg border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 font-normal text-muted-foreground text-sm shadow-xs transition-colors hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020] dark:hover:text-muted-foreground'
variant='outline'
>
<Plus className='h-4 w-4' />
Add variable
</Button>
</div>
) : (
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='space-y-4'>
{workflowVariables.map((variable) => (
<div key={variable.id} className='space-y-2'>
{/* Header: Variable name | Variable type | Options dropdown */}
<div className='flex items-center gap-2'>
<Input
className='h-9 flex-1 rounded-lg border-none bg-secondary/50 px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
placeholder='Variable name'
value={variable.name}
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
/>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='h-9 gap-1'>
<span className='!font-mono pt-[0.3px] text-sm'>
{getTypeIcon(variable.type)}
</span>
<ChevronDown className='!h-3.5 !w-3.5 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side='top'>Set variable type</TooltipContent>
</Tooltip>
<DropdownMenuContent align='end' className='min-w-32'>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'plain' })}
className='flex cursor-pointer items-center'
>
<div className='mr-2 w-5 text-center font-mono text-sm'>Abc</div>
<span>Plain</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'number' })}
className='flex cursor-pointer items-center'
>
<div className='mr-2 w-5 text-center font-mono text-sm'>123</div>
<span>Number</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'boolean' })}
className='flex cursor-pointer items-center'
>
<div className='mr-2 w-5 text-center font-mono text-sm'>0/1</div>
<span>Boolean</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'object' })}
className='flex cursor-pointer items-center'
>
<div className='mr-2 w-5 text-center font-mono text-sm'>{'{}'}</div>
<span>Object</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'array' })}
className='flex cursor-pointer items-center'
>
<div className='mr-2 w-5 text-center font-mono text-sm'>[]</div>
<span>Array</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className='flex items-center'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-9 w-9 text-muted-foreground'
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={() => duplicateVariable(variable.id)}
className='cursor-pointer text-muted-foreground'
>
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteVariable(variable.id)}
className='cursor-pointer text-destructive focus:text-destructive'
>
<Trash className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Type selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className='flex h-9 w-16 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-secondary/50 px-3'>
<span className='font-normal text-sm'>{getTypeIcon(variable.type)}</span>
<ChevronDown className='ml-1 h-3 w-3 text-muted-foreground' />
</div>
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
onClick={() => 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'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>Abc</div>
<span className='font-[380]'>Plain</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>123</div>
<span className='font-[380]'>Number</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>0/1</div>
<span className='font-[380]'>Boolean</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>{'{}'}</div>
<span className='font-[380]'>Object</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>[]</div>
<span className='font-[380]'>Array</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div
className='relative min-h-[36px] rounded-md bg-background px-4 pt-2 pb-3 font-mono text-sm'
ref={(el) => {
editorRefs.current[variable.id] = el
}}
style={{
maxWidth: panelWidth ? `${panelWidth - 50}px` : '100%',
overflowWrap: 'break-word',
}}
>
{variable.value === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-4 select-none text-muted-foreground/50'>
{getPlaceholder(variable.type)}
</div>
)}
<Editor
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
onBlur={() => 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 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-9 w-9 shrink-0 rounded-lg bg-secondary/50 p-0 text-muted-foreground hover:bg-secondary/70 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
>
<MoreVertical className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
onClick={() => 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'
>
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'
>
<Trash className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Show validation indicator for any non-empty variable */}
{variable.value !== '' && (
{/* Value area */}
<div className='relative rounded-lg bg-secondary/50'>
{/* Validation indicator */}
{variable.value !== '' && getValidationStatus(variable) && (
<div className='absolute top-2 right-2 z-10'>
<Tooltip>
<TooltipTrigger asChild>
<div className='group absolute top-[4px] right-[0px] cursor-help'>
{getValidationStatus(variable) && (
<div className='rounded-md border border-transparent p-1 transition-all duration-200 group-hover:border-muted/50 group-hover:bg-muted/80 group-hover:shadow-sm'>
<AlertTriangle className='h-4 w-4 text-muted-foreground opacity-30 transition-opacity duration-200 group-hover:opacity-100' />
</div>
)}
<div className='cursor-help'>
<AlertTriangle className='h-3 w-3 text-muted-foreground' />
</div>
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-xs'>
{getValidationStatus(variable) && <p>{getValidationStatus(variable)}</p>}
<p>{getValidationStatus(variable)}</p>
</TooltipContent>
</Tooltip>
)}
</div>
)}
{/* Editor */}
<div className='relative overflow-hidden'>
<div
className='relative min-h-[36px] w-full max-w-full px-3 py-2 font-normal text-sm'
ref={(el) => {
editorRefs.current[variable.id] = el
}}
style={{ maxWidth: '100%' }}
>
{variable.value === '' && (
<div className='pointer-events-none absolute inset-0 flex select-none items-start justify-start px-3 py-2 font-[380] text-muted-foreground text-sm leading-normal'>
<div style={{ lineHeight: '20px' }}>{getPlaceholder(variable.type)}</div>
</div>
)}
<Editor
key={`editor-${variable.id}-${variable.type}`}
value={formatValue(variable)}
onValueChange={handleEditorChange.bind(null, variable)}
onBlur={() => 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'
/>
</div>
</div>
</div>
))}
</div>
</div>
))}
{/* Add Variable Button */}
<Button
variant='ghost'
size='sm'
className='mt-2 w-full justify-start text-muted-foreground text-xs hover:text-foreground'
onClick={handleAddVariable}
className='mt-2 h-9 w-full rounded-lg border border-[#E5E5E5] bg-[#FFFFFF] px-3 py-1.5 font-[380] text-muted-foreground text-sm shadow-xs transition-colors hover:text-muted-foreground dark:border-[#414141] dark:bg-[#202020] dark:hover:text-muted-foreground'
variant='outline'
>
<Plus className='mr-1.5 h-3.5 w-3.5' />
<Plus className='h-4 w-4' />
Add variable
</Button>
</>
)}
</div>
</ScrollArea>
</div>
</ScrollArea>
)}
</div>
)
}

View File

@@ -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<string>('')
const [copilotMessage, setCopilotMessage] = useState<string>('')
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={togglePanel}
className='fixed right-4 bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
>
<PanelRight className='h-5 w-5' />
<span className='sr-only'>Open Panel</span>
</button>
</TooltipTrigger>
<TooltipContent side='top'>Open Panel</TooltipContent>
</Tooltip>
)
}
}, [isResizing, handleResize, handleResizeEnd])
return (
<>
<div
className='fixed top-16 right-0 z-10 flex h-[calc(100vh-4rem)] flex-col border-l bg-background'
style={{ width: `${width}px` }}
>
<div
className='absolute top-0 bottom-0 left-[-4px] z-50 w-4 cursor-ew-resize hover:bg-accent/50'
onMouseDown={handleMouseDown}
/>
{/* Tab Selector - Always visible */}
<div className='fixed top-[76px] right-4 z-20 flex h-9 w-[308px] items-center gap-1 rounded-[14px] border bg-card px-[2.5px] py-1 shadow-xs'>
<button
onClick={() => handleTabClick('chat')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'chat' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Chat
</button>
<button
onClick={() => handleTabClick('console')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'console' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Console
</button>
<button
onClick={() => handleTabClick('variables')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'variables' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Variables
</button>
</div>
{/* Panel Header */}
<div className='flex h-14 flex-none items-center justify-between border-b px-4'>
<div className='flex gap-2'>
<button
onClick={() => setActiveTab('chat')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'chat'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Chat
</button>
<button
onClick={() => setActiveTab('console')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'console'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Console
</button>
<button
onClick={() => setActiveTab('variables')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'variables'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Variables
</button>
{/* <button
onClick={() => setActiveTab('copilot')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'copilot'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Copilot
</button> */}
{/* Panel Content - Only visible when isOpen is true */}
{isOpen && (
<div
className='fixed top-[124px] right-4 bottom-4 z-10 flex flex-col rounded-[14px] border bg-card shadow-xs'
style={{ width: `${panelWidth}px` }}
>
{/* Invisible resize handle */}
<div
className='-left-1 absolute top-0 bottom-0 w-2 cursor-col-resize'
onMouseDown={handleResizeStart}
/>
{/* Header - Fixed width content */}
<div className='flex items-center justify-between px-3 pt-3 pb-1'>
<h2 className='font-[450] text-base text-card-foreground capitalize'>{activeTab}</h2>
<div className='flex items-center gap-2'>
{activeTab === 'console' && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => activeWorkflowId && exportConsoleCSV(activeWorkflowId)}
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
style={{ color: 'var(--base-muted-foreground)' }}
>
<ArrowDownToLine className='h-4 w-4' strokeWidth={2} />
</button>
</TooltipTrigger>
<TooltipContent side='bottom'>Export console data</TooltipContent>
</Tooltip>
)}
{activeTab === 'chat' && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => activeWorkflowId && exportChatCSV(activeWorkflowId)}
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
style={{ color: 'var(--base-muted-foreground)' }}
>
<ArrowDownToLine className='h-4 w-4' strokeWidth={2} />
</button>
</TooltipTrigger>
<TooltipContent side='bottom'>Export chat data</TooltipContent>
</Tooltip>
)}
{(activeTab === 'console' || activeTab === 'chat') && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
activeTab === 'console'
? clearConsole(activeWorkflowId)
: clearChat(activeWorkflowId)
}
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
style={{ color: 'var(--base-muted-foreground)' }}
>
<CircleSlash className='h-4 w-4' strokeWidth={2} />
</button>
</TooltipTrigger>
<TooltipContent side='bottom'>Clear {activeTab}</TooltipContent>
</Tooltip>
)}
<button
onClick={handleClosePanel}
className='font-medium text-md leading-normal transition-all hover:brightness-75 dark:hover:brightness-125'
style={{ color: 'var(--base-muted-foreground)' }}
>
<X className='h-4 w-4' strokeWidth={2} />
</button>
</div>
</div>
{(activeTab === 'console' || activeTab === 'chat') /* || activeTab === 'copilot' */ && (
<button
onClick={() => {
if (activeTab === 'console') {
clearConsole(activeWorkflowId)
} else if (activeTab === 'chat') {
clearChat(activeWorkflowId)
}
// else if (activeTab === 'copilot') {
// copilotRef.current?.clearMessages()
// }
}}
className='rounded-md px-3 py-1 text-muted-foreground text-sm transition-colors hover:bg-accent/50 hover:text-foreground'
>
Clear
</button>
)}
{/* Panel Content Area - Resizable */}
<div className='flex-1 overflow-hidden px-3'>
{activeTab === 'chat' ? (
<Chat
panelWidth={panelWidth}
chatMessage={chatMessage}
setChatMessage={setChatMessage}
/>
) : activeTab === 'console' ? (
<Console panelWidth={panelWidth} />
) : (
<Variables />
)}
</div>
</div>
{/* Panel Content */}
<div className='flex-1 overflow-hidden'>
{activeTab === 'chat' ? (
<Chat panelWidth={width} chatMessage={chatMessage} setChatMessage={setChatMessage} />
) : activeTab === 'console' ? (
<Console panelWidth={width} />
) : (
/* activeTab === 'copilot' ? (
<Copilot
ref={copilotRef}
panelWidth={width}
isFullscreen={isCopilotModalOpen}
onFullscreenToggle={setIsCopilotModalOpen}
fullscreenInput={copilotMessage}
onFullscreenInputChange={setCopilotMessage}
/>
) : */ <Variables panelWidth={width} />
)}
</div>
{/* Panel Footer */}
<div className='flex h-16 flex-none items-center justify-between border-t bg-background px-4'>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={togglePanel}
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
>
<PanelRight className='h-5 w-5 rotate-180 transform' />
<span className='sr-only'>Close Panel</span>
</button>
</TooltipTrigger>
<TooltipContent side='right'>Close Panel</TooltipContent>
</Tooltip>
{activeTab === 'chat' && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsChatModalOpen(true)}
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
>
<Expand className='h-5 w-5' />
<span className='sr-only'>Expand Chat</span>
</button>
</TooltipTrigger>
<TooltipContent side='left'>Expand Chat</TooltipContent>
</Tooltip>
)}
{/* activeTab === 'copilot' && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsCopilotModalOpen(true)}
className='flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
>
<Expand className='h-5 w-5' />
<span className='sr-only'>Expand Copilot</span>
</button>
</TooltipTrigger>
<TooltipContent side='left'>Expand Copilot</TooltipContent>
</Tooltip>
) */}
</div>
</div>
)}
{/* Fullscreen Chat Modal */}
<ChatModal

View File

@@ -1,81 +1,64 @@
'use client'
import { Bell, Bug, ChevronDown, Copy, History, Layers, Play, Rocket, Trash2 } from 'lucide-react'
import { Bug, Copy, Layers, Play, Rocket, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useSidebarStore } from '@/stores/sidebar/store'
const SkeletonControlBar = () => {
return (
<div className='flex h-16 w-full items-center justify-between border-b bg-background'>
{/* Left Section - Workflow Name Skeleton */}
<div className='flex flex-col gap-[2px] pl-4'>
{/* Workflow name skeleton */}
<Skeleton className='h-[20px] w-32' />
{/* "Saved X time ago" skeleton */}
<Skeleton className='h-3 w-24' />
</div>
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
{/* Delete Button */}
<Button
variant='outline'
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))]'
disabled
>
<Trash2 className='h-5 w-5' />
</Button>
{/* Middle Section */}
<div className='flex-1' />
{/* Duplicate Button */}
<Button
variant='outline'
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
disabled
>
<Copy className='h-5 w-5' />
</Button>
{/* Right Section - Action Buttons with Real Icons */}
<div className='flex items-center gap-1 pr-4'>
{/* Delete Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Trash2 className='h-5 w-5' />
</Button>
{/* Auto Layout Button */}
<Button
variant='outline'
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
disabled
>
<Layers className='h-5 w-5' />
</Button>
{/* History Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<History className='h-5 w-5' />
</Button>
{/* Debug Mode Button */}
<Button
variant='outline'
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
disabled
>
<Bug className='h-5 w-5' />
</Button>
{/* Notifications Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Bell className='h-5 w-5' />
</Button>
{/* Deploy Button */}
<Button
variant='outline'
className='h-12 w-12 cursor-not-allowed rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] opacity-50 shadow-xs hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:bg-gray-100'
disabled
>
<Rocket className='h-5 w-5' />
</Button>
{/* Duplicate Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Copy className='h-5 w-5' />
</Button>
{/* Auto Layout Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Layers className='h-5 w-5' />
</Button>
{/* Debug Mode Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Bug className='h-5 w-5' />
</Button>
{/* Deploy Button */}
<Button variant='ghost' size='icon' disabled className='opacity-60'>
<Rocket className='h-5 w-5' />
</Button>
{/* Run Button with Dropdown */}
<div className='ml-1 flex'>
{/* Main Run Button */}
<Button
disabled
className='h-10 gap-2 rounded-r-none border-r border-r-[#6420cc] bg-[#701FFC] px-4 py-2 font-medium text-white opacity-60'
>
<Play className='h-3.5 w-3.5 fill-current stroke-current' />
Run
</Button>
{/* Dropdown Trigger */}
<Button
disabled
className='h-10 rounded-l-none bg-[#701FFC] px-2 font-medium text-white opacity-60'
>
<ChevronDown className='h-4 w-4' />
</Button>
</div>
</div>
{/* Run Button */}
<Button
className='h-12 cursor-not-allowed gap-2 rounded-[11px] bg-[#701FFC] px-4 py-2 font-medium text-white shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:bg-[#701FFC] disabled:hover:shadow-none'
disabled
>
<Play className='h-3.5 w-3.5 fill-current stroke-current' />
</Button>
</div>
)
}
@@ -163,26 +146,22 @@ export function SkeletonLoading({
isSidebarCollapsed,
children,
}: SkeletonLoadingProps) {
const { mode, isExpanded } = useSidebarStore()
return (
<div className='flex h-screen w-full flex-col overflow-hidden'>
<div className={`transition-all duration-200 ${isSidebarCollapsed ? 'ml-14' : 'ml-60'}`}>
{/* Skeleton Control Bar */}
<div
className={`transition-opacity duration-500 ${showSkeleton ? 'opacity-100' : 'pointer-events-none absolute opacity-0'}`}
style={{ zIndex: showSkeleton ? 10 : -1 }}
>
<SkeletonControlBar />
</div>
<div className='flex h-screen w-full flex-col overflow-hidden pl-64'>
{/* Skeleton Control Bar */}
<div
className={`transition-opacity duration-500 ${showSkeleton ? 'opacity-100' : 'pointer-events-none absolute opacity-0'}`}
style={{ zIndex: showSkeleton ? 10 : -1 }}
>
<SkeletonControlBar />
</div>
{/* Real Control Bar */}
<div
className={`transition-opacity duration-500 ${showSkeleton ? 'pointer-events-none absolute opacity-0' : 'opacity-100'}`}
style={{ zIndex: showSkeleton ? -1 : 10 }}
>
{children}
</div>
{/* Real Control Bar */}
<div
className={`transition-opacity duration-500 ${showSkeleton ? 'pointer-events-none absolute opacity-0' : 'opacity-100'}`}
style={{ zIndex: showSkeleton ? -1 : 10 }}
>
{children}
</div>
{/* Real content will be rendered by children - sidebar will show its own loading state */}

View File

@@ -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<HTMLButtonElement>(null)
const toolsRef = useRef<HTMLButtonElement>(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 (
<div className='relative pt-5'>
<div className='flex gap-8 px-6'>
<button
ref={blocksRef}
onClick={() => onTabChange('blocks')}
className={`font-medium text-sm transition-colors hover:text-foreground ${
activeTab === 'blocks' ? 'text-foreground' : 'text-muted-foreground'
}`}
>
Blocks
</button>
<button
ref={toolsRef}
onClick={() => onTabChange('tools')}
className={`font-medium text-sm transition-colors hover:text-foreground ${
activeTab === 'tools' ? 'text-foreground' : 'text-muted-foreground'
}`}
>
Tools
</button>
</div>
<div className='relative mt-2'>
<div className='absolute bottom-0 h-[1px] w-full border-b' />
<div
className='absolute bottom-0 h-[1.5px] bg-foreground transition-transform duration-200'
style={{
width: `${underlineStyle.width}px`,
transform: underlineStyle.transform,
}}
/>
</div>
</div>
)
}

View File

@@ -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<ToolbarButtonProps>(
({ onClick, className, children, tooltipContent, tooltipSide = 'right' }) => (
<Tooltip>
<TooltipTrigger asChild>
<button onClick={onClick} className={className}>
{children}
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltipContent}</TooltipContent>
</Tooltip>
)
)
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<BlockCategory>('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<HTMLInputElement>) => {
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 (
<ToolbarButton
onClick={handleOpenToolbar}
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-20' : 'left-64'} bottom-[18px] z-10 flex h-9 w-9 items-center justify-center rounded-lg border bg-background text-muted-foreground hover:bg-accent hover:text-foreground`}
tooltipContent='Open Toolbar'
tooltipSide='right'
>
<PanelRight className='h-5 w-5' />
<span className='sr-only'>Open Toolbar</span>
</ToolbarButton>
)
}
return (
<div
className={`fixed transition-all duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'} top-16 z-10 h-[calc(100vh-4rem)] w-60 border-r bg-background sm:block`}
>
<div className='flex h-full flex-col'>
<div className='sticky top-0 z-20 bg-background px-4 pt-4 pb-1'>
<div className='relative'>
<Search className='-translate-y-[50%] absolute top-[50%] left-3 h-4 w-4 text-muted-foreground' />
<Input
placeholder='Search...'
className='rounded-md pl-9'
value={searchQuery}
onChange={handleSearchChange}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
</div>
{!searchQuery && (
<div className='sticky top-[72px] z-20 bg-background'>
<ToolbarTabs activeTab={activeTab} onTabChange={handleTabChange} />
</div>
)}
<ScrollArea className='h-[calc(100%-4rem)]'>
<div className='p-4 pb-20'>
<div className='flex flex-col gap-3'>
{blocks.map((block) => (
<ToolbarBlock key={block.type} config={block} disabled={!userPermissions.canEdit} />
))}
{((activeTab === 'blocks' && !searchQuery) ||
(searchQuery && 'loop'.includes(searchQuery.toLowerCase()))) && (
<LoopToolbarItem disabled={!userPermissions.canEdit} />
)}
{((activeTab === 'blocks' && !searchQuery) ||
(searchQuery && 'parallel'.includes(searchQuery.toLowerCase()))) && (
<ParallelToolbarItem disabled={!userPermissions.canEdit} />
)}
</div>
</div>
</ScrollArea>
<div className='absolute right-0 bottom-0 left-0 h-16 border-t bg-background'>
<ToolbarButton
onClick={handleCloseToolbar}
className='absolute right-4 bottom-[18px] flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground'
tooltipContent='Close Toolbar'
tooltipSide='left'
>
<PanelLeftClose className='h-5 w-5' />
<span className='sr-only'>Close Toolbar</span>
</ToolbarButton>
</div>
</div>
</div>
)
})
Toolbar.displayName = 'Toolbar'

View File

@@ -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() || '')
}

View File

@@ -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<HTMLInputElement>(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
)

View File

@@ -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<string | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const addNotification = useNotificationStore((state) => state.addNotification)
// State for conversation history
const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([])
@@ -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)

View File

@@ -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<ExecutionResult | null>(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,

View File

@@ -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<string | null>(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 (
<div className='flex h-screen w-full flex-col overflow-hidden'>
<SkeletonLoading showSkeleton={true} isSidebarCollapsed={isSidebarCollapsed}>
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
</SkeletonLoading>
<Toolbar />
<div
className={`${isSidebarCollapsed ? 'pl-14' : 'pl-60'} relative h-full w-full flex-1 transition-all duration-200`}
>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
<div className='fixed top-0 right-0 z-10'>
<Panel />
<NotificationList />
</div>
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
<div className='workflow-container h-full'>
<Background />
<Background
color='hsl(var(--workflow-dots))'
size={4}
gap={40}
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
/>
</div>
</div>
</div>
@@ -1525,18 +1506,14 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-screen w-full flex-col overflow-hidden'>
<div className={`${isSidebarCollapsed ? 'ml-14' : 'ml-60'} transition-all duration-200`}>
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
</div>
<Toolbar />
<div
className={`${isSidebarCollapsed ? 'pl-14' : 'pl-60'} relative h-full w-full flex-1 transition-all duration-200`}
>
<div className='relative h-full w-full flex-1 transition-all duration-200'>
<div className='fixed top-0 right-0 z-10'>
<Panel />
<NotificationList />
</div>
{/* Floating Control Bar */}
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}
@@ -1583,7 +1560,12 @@ const WorkflowContent = React.memo(() => {
autoPanOnConnect={userPermissions.canEdit}
autoPanOnNodeDrag={userPermissions.canEdit}
>
<Background />
<Background
color='hsl(var(--workflow-dots))'
size={4}
gap={40}
style={{ backgroundColor: 'hsl(var(--workflow-background))' }}
/>
</ReactFlow>
</div>
</div>

View File

@@ -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<string>
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<NodeJS.Timeout | null>(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<ImportControlsRef>(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 (
<>
<Popover open={isHoverOpen}>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 shrink-0 p-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
title='Create'
onClick={handleCreateWorkflow}
onMouseEnter={() => setIsHoverOpen(true)}
onMouseLeave={() => setIsHoverOpen(false)}
className='h-9 w-9 shrink-0 rounded-lg border bg-card shadow-xs hover:bg-accent focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
title='Create Workflow (Hover, right-click, or long press for more options)'
disabled={isCreatingWorkflow}
onClick={handleButtonClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Plus
className={cn(
'stroke-[2px]',
isCollapsed ? 'h-[18px] w-[18px]' : 'h-[16px] w-[16px]'
)}
/>
<span className='sr-only'>Create</span>
<Plus className='h-[18px] w-[18px] stroke-[2px]' />
<span className='sr-only'>Create Workflow</span>
</Button>
</PopoverTrigger>
<PopoverContent
@@ -116,73 +203,39 @@ export function CreateMenu({
'data-[state=closed]:animate-out',
'w-48'
)}
onMouseEnter={() => setIsHoverOpen(true)}
onMouseLeave={() => setIsHoverOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseEnter={handlePopoverMouseEnter}
onMouseLeave={handlePopoverMouseLeave}
>
<button
className={cn(
'flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
isCreatingWorkflow
? 'cursor-not-allowed opacity-50'
: 'hover:bg-accent hover:text-accent-foreground'
'flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50',
isCreatingWorkflow && 'cursor-not-allowed opacity-50'
)}
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow}
>
<File className='h-4 w-4' />
{isCreatingWorkflow ? 'Creating...' : 'New Workflow'}
{isCreatingWorkflow ? 'Creating...' : 'New workflow'}
</button>
<button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
onClick={handleCreateFolder}
>
<Folder className='h-4 w-4' />
New Folder
New folder
</button>
{userPermissions.canEdit && (
<>
<Separator className='my-1' />
<Popover open={isImportSubmenuOpen} onOpenChange={setIsImportSubmenuOpen}>
<PopoverTrigger asChild>
<button
className='flex w-full cursor-default select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
onMouseEnter={() => setIsImportSubmenuOpen(true)}
>
<div className='flex items-center gap-2'>
<Upload className='h-4 w-4' />
<span>Import Workflow</span>
</div>
<ChevronRight className='h-3 w-3' />
</button>
</PopoverTrigger>
<PopoverContent
side='right'
align='start'
sideOffset={4}
className='w-48 p-1'
onMouseEnter={() => setIsImportSubmenuOpen(true)}
onMouseLeave={() => setIsImportSubmenuOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<button
className='flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground'
onClick={handleUploadYaml}
>
<Upload className='h-4 w-4' />
<div className='flex flex-col items-start'>
<span>YAML</span>
<span className='text-muted-foreground text-xs'>.yaml or .yml</span>
</div>
</button>
</PopoverContent>
</Popover>
</>
<button
className='flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-card-foreground text-sm outline-none hover:bg-secondary/50 focus:bg-secondary/50'
onClick={handleImportWorkflow}
>
<Upload className='h-4 w-4' />
Import workflow
</button>
)}
</PopoverContent>
</Popover>
@@ -191,10 +244,7 @@ export function CreateMenu({
<ImportControls
ref={importControlsRef}
disabled={!userPermissions.canEdit}
onClose={() => {
setIsHoverOpen(false)
setIsImportSubmenuOpen(false)
}}
onClose={() => setIsOpen(false)}
/>
{/* Folder creation dialog */}

View File

@@ -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<ImportControlsRef, ImportControlsProps>(
({ 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<ImportControlsRef, ImportControlsProps>
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<ImportControlsRef, ImportControlsProps>
}
}
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<ImportControlsRef, ImportControlsProps>
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<ImportControlsRef, ImportControlsProps>
// 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<ImportControlsRef, ImportControlsProps>
if (result.success) {
setYamlContent('')
setShowYamlDialog(false)
logger.info('YAML import completed successfully')
}
} catch (error) {
@@ -166,8 +153,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
}
}
const isDisabled = disabled || isImporting
return (
<>
{/* Hidden file input */}
@@ -178,103 +163,6 @@ export const ImportControls = forwardRef<ImportControlsRef, ImportControlsProps>
onChange={handleFileUpload}
className='hidden'
/>
{/* YAML Import Dialog */}
<Dialog open={showYamlDialog} onOpenChange={setShowYamlDialog}>
<DialogContent className='flex max-h-[80vh] max-w-4xl flex-col'>
<DialogHeader>
<DialogTitle>Import Workflow from YAML</DialogTitle>
<DialogDescription>
Review the YAML content below and click "Import Workflow" to create a new workflow
with the blocks and connections defined in the YAML.
</DialogDescription>
</DialogHeader>
<div className='flex-1 space-y-4 overflow-hidden'>
<Textarea
placeholder={`version: "1.0"
blocks:
start:
type: "starter"
name: "Start"
inputs:
startWorkflow: "manual"
following:
- "process"
process:
type: "agent"
name: "Process Data"
inputs:
systemPrompt: "You are a helpful assistant"
userPrompt: "Process the data"
model: "gpt-4"
preceding:
- "start"`}
value={yamlContent}
onChange={(e) => setYamlContent(e.target.value)}
className='min-h-[300px] font-mono text-sm'
disabled={isImporting}
/>
{/* Import Result */}
{importResult && (
<div className='space-y-2'>
{importResult.success ? (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium text-green-700'>Import Successful!</div>
{importResult.summary && (
<div className='mt-1 text-sm'>{importResult.summary}</div>
)}
{importResult.warnings.length > 0 && (
<div className='mt-2'>
<div className='font-medium text-sm'>Warnings:</div>
<ul className='mt-1 space-y-1 text-sm'>
{importResult.warnings.map((warning, index) => (
<li key={index} className='text-yellow-700'>
{warning}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium'>Import Failed</div>
{importResult.errors.length > 0 && (
<ul className='mt-2 space-y-1 text-sm'>
{importResult.errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowYamlDialog(false)}
disabled={isImporting}
>
Cancel
</Button>
<Button onClick={handleYamlImport} disabled={isImporting || !yamlContent.trim()}>
{isImporting ? 'Importing...' : 'Import Workflow'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -9,7 +9,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
@@ -27,6 +26,7 @@ interface FolderContextMenuProps {
onCreateWorkflow: (folderId: string) => void
onRename?: (folderId: string, newName: string) => void
onDelete?: (folderId: string) => void
level: number
}
export function FolderContextMenu({
@@ -35,6 +35,7 @@ export function FolderContextMenu({
onCreateWorkflow,
onRename,
onDelete,
level,
}: FolderContextMenuProps) {
const [showSubfolderDialog, setShowSubfolderDialog] = useState(false)
const [showRenameDialog, setShowRenameDialog] = useState(false)
@@ -131,33 +132,50 @@ export function FolderContextMenu({
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100'
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className='h-3 w-3' />
<span className='sr-only'>Folder options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' onClick={(e) => e.stopPropagation()}>
<DropdownMenuContent
align='end'
onClick={(e) => e.stopPropagation()}
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
{userPermissions.canEdit && (
<>
<DropdownMenuItem onClick={handleCreateWorkflow}>
<DropdownMenuItem
onClick={handleCreateWorkflow}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<File className='mr-2 h-4 w-4' />
New Workflow
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCreateSubfolder}>
<Folder className='mr-2 h-4 w-4' />
New Subfolder
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRename}>
{level === 0 && (
<DropdownMenuItem
onClick={handleCreateSubfolder}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Folder className='mr-2 h-4 w-4' />
New Subfolder
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleRename}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Pencil className='mr-2 h-4 w-4' />
Rename
</DropdownMenuItem>
</>
)}
{userPermissions.canAdmin ? (
<DropdownMenuItem onClick={handleDelete} className='text-destructive'>
<DropdownMenuItem
onClick={handleDelete}
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'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</DropdownMenuItem>
@@ -166,7 +184,7 @@ export function FolderContextMenu({
<TooltipTrigger asChild>
<div>
<DropdownMenuItem
className='cursor-not-allowed text-muted-foreground opacity-50'
className='cursor-not-allowed rounded-md px-3 py-2 font-[380] text-muted-foreground text-sm opacity-50'
onClick={(e) => e.preventDefault()}
>
<Trash2 className='mr-2 h-4 w-4' />
@@ -196,6 +214,7 @@ export function FolderContextMenu({
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder='Enter folder name...'
maxLength={50}
autoFocus
required
/>
@@ -226,6 +245,7 @@ export function FolderContextMenu({
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder='Enter folder name...'
maxLength={50}
autoFocus
required
/>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react'
import { Folder, FolderOpen } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -29,6 +29,8 @@ interface FolderItemProps {
onDragOver?: (e: React.DragEvent) => void
onDragLeave?: (e: React.DragEvent) => void
onDrop?: (e: React.DragEvent) => void
isFirstItem?: boolean
level: number
}
export function FolderItem({
@@ -39,10 +41,14 @@ export function FolderItem({
onDragOver,
onDragLeave,
onDrop,
isFirstItem = false,
level,
}: FolderItemProps) {
const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const dragStartedRef = useRef(false)
const params = useParams()
const workspaceId = params.workspaceId as string
const isExpanded = expandedFolders.has(folder.id)
@@ -69,6 +75,39 @@ export function FolderItem({
}, 300)
}, [folder.id, isExpanded, toggleExpanded, updateFolderAPI])
const handleDragStart = (e: React.DragEvent) => {
dragStartedRef.current = true
setIsDragging(true)
e.dataTransfer.setData('folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
// Set global drag state for validation in other components
if (typeof window !== 'undefined') {
;(window as any).currentDragFolderId = folder.id
}
}
const handleDragEnd = () => {
setIsDragging(false)
requestAnimationFrame(() => {
dragStartedRef.current = false
})
// Clear global drag state
if (typeof window !== 'undefined') {
;(window as any).currentDragFolderId = null
}
}
const handleClick = (e: React.MouseEvent) => {
if (dragStartedRef.current) {
e.preventDefault()
return
}
handleToggleExpanded()
}
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
@@ -107,11 +146,17 @@ export function FolderItem({
<Tooltip>
<TooltipTrigger asChild>
<div
className='group mx-auto flex h-8 w-8 cursor-pointer items-center justify-center'
className={clsx(
'group mx-auto mb-1 flex h-9 w-9 cursor-pointer items-center justify-center',
isDragging ? 'opacity-50' : ''
)}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={handleToggleExpanded}
onClick={handleClick}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
className={clsx(
@@ -128,7 +173,7 @@ export function FolderItem({
</div>
</TooltipTrigger>
<TooltipContent side='right'>
<p>{folder.name}</p>
<p className='max-w-[200px] break-words'>{folder.name}</p>
</TooltipContent>
</Tooltip>
@@ -136,7 +181,13 @@ export function FolderItem({
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete "{folder.name}"?</AlertDialogTitle>
<AlertDialogTitle className='break-words'>
Are you sure you want to delete "
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
{folder.name}
</span>
"?
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the folder and all its contents, including subfolders
and workflows. This action cannot be undone.
@@ -160,19 +211,21 @@ export function FolderItem({
return (
<>
<div className='group' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div className='group mb-1' onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div
className='flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent/50'
onClick={handleToggleExpanded}
className={clsx(
'flex h-9 cursor-pointer items-center rounded-lg px-2 py-2 text-sm transition-colors hover:bg-accent/50',
isDragging ? 'opacity-50' : '',
isFirstItem ? 'mr-[44px]' : ''
)}
style={{
maxWidth: isFirstItem ? `${164 - level * 20}px` : `${206 - level * 20}px`,
}}
onClick={handleClick}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className='mr-1 flex h-4 w-4 items-center justify-center'>
{isExpanded ? (
<ChevronDown className='h-3 w-3' />
) : (
<ChevronRight className='h-3 w-3' />
)}
</div>
<div className='mr-2 flex h-4 w-4 flex-shrink-0 items-center justify-center'>
{isExpanded ? (
<FolderOpen className='h-4 w-4 text-foreground/70 dark:text-foreground/60' />
@@ -181,9 +234,7 @@ export function FolderItem({
)}
</div>
<span className='flex-1 cursor-default select-none truncate text-muted-foreground'>
{folder.name}
</span>
<span className='flex-1 select-none truncate text-muted-foreground'>{folder.name}</span>
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<FolderContextMenu
@@ -192,6 +243,7 @@ export function FolderItem({
onCreateWorkflow={onCreateWorkflow}
onRename={handleRename}
onDelete={handleDelete}
level={level}
/>
</div>
</div>
@@ -201,7 +253,13 @@ export function FolderItem({
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete "{folder.name}"?</AlertDialogTitle>
<AlertDialogTitle className='break-words'>
Are you sure you want to delete "
<span className='inline-block max-w-[200px] truncate align-bottom font-semibold'>
{folder.name}
</span>
"?
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the folder and all its contents, including subfolders and
workflows. This action cannot be undone.

View File

@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { createLogger } from '@/lib/logs/console-logger'
import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { WorkflowContextMenu } from '../../workflow-context-menu/workflow-context-menu'
const logger = createLogger('WorkflowItem')
@@ -18,6 +19,7 @@ interface WorkflowItemProps {
isCollapsed?: boolean
level: number
isDragOver?: boolean
isFirstItem?: boolean
}
export function WorkflowItem({
@@ -27,6 +29,7 @@ export function WorkflowItem({
isCollapsed,
level,
isDragOver = false,
isFirstItem = false,
}: WorkflowItemProps) {
const [isDragging, setIsDragging] = useState(false)
const dragStartedRef = useRef(false)
@@ -81,10 +84,11 @@ export function WorkflowItem({
<TooltipTrigger asChild>
<Link
href={`/workspace/${workspaceId}/w/${workflow.id}`}
data-workflow-id={workflow.id}
className={clsx(
'mx-auto flex h-8 w-8 items-center justify-center rounded-md',
'mx-auto mb-1 flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
active && !isDragOver
? 'bg-accent text-accent-foreground'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver
? 'bg-accent/70'
@@ -103,7 +107,7 @@ export function WorkflowItem({
</Link>
</TooltipTrigger>
<TooltipContent side='right'>
<p>
<p className='max-w-[200px] break-words'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</p>
@@ -113,31 +117,49 @@ export function WorkflowItem({
}
return (
<Link
href={`/workspace/${workspaceId}/w/${workflow.id}`}
className={clsx(
'flex items-center rounded-md px-2 py-1.5 font-medium text-sm',
active && !isDragOver
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-accent/70' : '',
isDragging ? 'opacity-50' : '',
!isMarketplace ? 'cursor-move' : ''
)}
style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}
draggable={!isMarketplace}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleClick}
>
<div className='group mb-1'>
<div
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflow.color }}
/>
<span className='truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
</Link>
className={clsx(
'flex h-9 items-center rounded-lg px-2 py-2 font-medium text-sm transition-colors',
active && !isDragOver
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50',
isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-accent/70' : '',
isDragging ? 'opacity-50' : '',
'cursor-pointer',
isFirstItem ? 'mr-[44px]' : ''
)}
style={{
maxWidth: isFirstItem
? `${164 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`
: `${206 - (level >= 0 ? (level + 1) * 20 + 8 : 0) - (level > 0 ? 8 : 0)}px`,
}}
draggable={!isMarketplace}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
data-workflow-id={workflow.id}
>
<Link
href={`/workspace/${workspaceId}/w/${workflow.id}`}
className='flex min-w-0 flex-1 items-center'
onClick={handleClick}
>
<div
className='mr-2 h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflow.color }}
/>
<span className='flex-1 select-none truncate'>
{workflow.name}
{isMarketplace && ' (Preview)'}
</span>
</Link>
{!isMarketplace && (
<div className='flex items-center justify-center' onClick={(e) => e.stopPropagation()}>
<WorkflowContextMenu workflow={workflow} level={level} />
</div>
)}
</div>
</div>
)
}

View File

@@ -1,8 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useParams, usePathname } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -18,12 +19,14 @@ interface FolderSectionProps {
expandedFolders: Set<string>
pathname: string
updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void>
updateFolder: (id: string, updates: any) => Promise<any>
renderFolderTree: (
nodes: FolderTreeNode[],
level: number,
parentDragOver?: boolean
) => React.ReactNode[]
parentDragOver?: boolean
isFirstItem?: boolean
}
function FolderSection({
@@ -35,27 +38,41 @@ function FolderSection({
expandedFolders,
pathname,
updateWorkflow,
updateFolder,
renderFolderTree,
parentDragOver = false,
isFirstItem = false,
}: FolderSectionProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { isDragOver, handleDragOver, handleDragLeave, handleDrop } = useDragHandlers(
updateWorkflow,
folder.id,
`Moved workflow(s) to folder ${folder.id}`
)
const { isDragOver, isInvalidDrop, handleDragOver, handleDragLeave, handleDrop } =
useDragHandlers(
updateWorkflow,
updateFolder,
folder.id,
`Moved workflow(s) to folder ${folder.id}`
)
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isAnyDragOver = isDragOver || parentDragOver
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const isExpanded = expandedFolders.has(folder.id)
return (
<div
className={clsx(isDragOver ? 'rounded-md bg-blue-500/10 dark:bg-blue-400/10' : '')}
className={clsx(
isDragOver
? isInvalidDrop
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
: ''
)}
style={
isDragOver
? {
boxShadow: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
boxShadow: isInvalidDrop
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
}
: {}
}
@@ -70,28 +87,136 @@ function FolderSection({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
isFirstItem={isFirstItem}
level={level}
/>
</div>
{/* Render workflows in this folder */}
{expandedFolders.has(folder.id) && workflowsInFolder.length > 0 && (
<div className='space-y-0.5'>
{workflowsInFolder.map((workflow) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isCollapsed={isCollapsed}
level={level}
isDragOver={isAnyDragOver}
{/* Render children with connecting lines */}
{isExpanded && hasChildren && (
<div className='relative'>
{/* Vertical line from folder icon to children */}
{!isCollapsed && (workflowsInFolder.length > 0 || folder.children.length > 0) && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '-9px',
width: '1px',
height: `${(workflowsInFolder.length + folder.children.length - 1) * 40 + 24}px`,
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
))}
</div>
)}
)}
{/* Render child folders */}
{expandedFolders.has(folder.id) && folder.children.length > 0 && (
<div>{renderFolderTree(folder.children, level + 1, isAnyDragOver)}</div>
{/* Render workflows in this folder */}
{workflowsInFolder.length > 0 && (
<div>
{workflowsInFolder.map((workflow, index) => (
<div key={workflow.id} className='relative'>
{/* Curved corner */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
)}
{/* Horizontal line to workflow */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '7px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
)}
{/* Workflow container with proper indentation */}
<div style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }}>
<WorkflowItem
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isCollapsed={isCollapsed}
level={level}
isDragOver={isAnyDragOver}
/>
</div>
</div>
))}
</div>
)}
{/* Render child folders */}
{folder.children.length > 0 && (
<div>
{folder.children.map((childFolder, index) => (
<div key={childFolder.id} className='relative'>
{/* Curved corner */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 16}px`,
top: '15px',
width: '4px',
height: '4px',
borderLeft: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottom: '1px solid hsl(var(--muted-foreground) / 0.3)',
borderBottomLeftRadius: '4px',
zIndex: 1,
}}
/>
)}
{/* Horizontal line to child folder */}
{!isCollapsed && (
<div
className='pointer-events-none absolute'
style={{
left: `${level * 20 + 20}px`,
top: '18px',
width: '5px',
height: '1px',
background: 'hsl(var(--muted-foreground) / 0.3)',
zIndex: 1,
}}
/>
)}
<div style={{ paddingLeft: isCollapsed ? '0px' : '8px' }}>
<FolderSection
key={childFolder.id}
folder={childFolder}
level={level + 1}
isCollapsed={isCollapsed}
onCreateWorkflow={onCreateWorkflow}
workflowsByFolder={workflowsByFolder}
expandedFolders={expandedFolders}
pathname={pathname}
updateWorkflow={updateWorkflow}
updateFolder={updateFolder}
renderFolderTree={renderFolderTree}
parentDragOver={isAnyDragOver}
isFirstItem={false}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
@@ -100,21 +225,46 @@ function FolderSection({
// Custom hook for drag and drop handling
function useDragHandlers(
updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void>,
updateFolder: (id: string, updates: any) => Promise<any>,
targetFolderId: string | null, // null for root
logMessage?: string
) {
const [isDragOver, setIsDragOver] = useState(false)
const [isInvalidDrop, setIsInvalidDrop] = useState(false)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(true)
// Check if this would be an invalid folder drop
const draggedFolderId =
(typeof window !== 'undefined' && (window as any).currentDragFolderId) || null
if (draggedFolderId && targetFolderId) {
const folderStore = useFolderStore.getState()
const targetFolderPath = folderStore.getFolderPath(targetFolderId)
// Check for circular reference
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
const isCircular =
targetFolderId === draggedFolderId ||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
// Check for deep nesting (target folder already has a parent)
const wouldBeDeepNesting = targetFolderPath.length >= 1
setIsInvalidDrop(isCircular || wouldBeDeepNesting)
} else {
setIsInvalidDrop(false)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
setIsInvalidDrop(false)
}
const handleDrop = async (e: React.DragEvent) => {
@@ -122,6 +272,7 @@ function useDragHandlers(
e.stopPropagation()
setIsDragOver(false)
// Handle workflow drops
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
if (workflowIdsData) {
const workflowIds = JSON.parse(workflowIdsData) as string[]
@@ -136,10 +287,49 @@ function useDragHandlers(
console.error('Failed to move workflows:', error)
}
}
// Handle folder drops
const folderIdData = e.dataTransfer.getData('folder-id')
if (folderIdData) {
try {
// Check if the target folder would create more than 2 levels of nesting
const folderStore = useFolderStore.getState()
const targetFolderPath = targetFolderId ? folderStore.getFolderPath(targetFolderId) : []
// Prevent circular references - don't allow dropping a folder into itself or its descendants
if (targetFolderId === folderIdData) {
console.log('Cannot move folder into itself')
return
}
// Check if target folder is a descendant of the dragged folder
const draggedFolderPath = folderStore.getFolderPath(folderIdData)
if (
targetFolderId &&
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
) {
console.log('Cannot move folder into its own descendant')
return
}
// If target folder is already at level 1 (has 1 parent), we can't nest another folder
if (targetFolderPath.length >= 1) {
console.log('Cannot nest folder: Maximum 2 levels of nesting allowed. Drop prevented.')
return // Prevent the drop entirely
}
// Target folder is at root level, safe to nest
await updateFolder(folderIdData, { parentId: targetFolderId })
console.log(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
} catch (error) {
console.error('Failed to move folder:', error)
}
}
}
return {
isDragOver,
isInvalidDrop,
handleDragOver,
handleDragLeave,
handleDrop,
@@ -170,15 +360,53 @@ export function FolderTree({
fetchFolders,
isLoading: foldersLoading,
clearSelection,
updateFolderAPI,
} = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
// Clean up any existing folders with 3+ levels of nesting
const cleanupDeepNesting = useCallback(async () => {
const { getFolderTree, updateFolderAPI } = useFolderStore.getState()
const folderTree = getFolderTree(workspaceId)
const findDeepFolders = (nodes: FolderTreeNode[], currentLevel = 0): FolderTreeNode[] => {
let deepFolders: FolderTreeNode[] = []
for (const node of nodes) {
if (currentLevel >= 2) {
// This folder is at level 2+ (too deep), add it to cleanup list
deepFolders.push(node)
} else {
// Recursively check children
deepFolders = deepFolders.concat(findDeepFolders(node.children, currentLevel + 1))
}
}
return deepFolders
}
const deepFolders = findDeepFolders(folderTree)
// Move deeply nested folders to root level
for (const folder of deepFolders) {
try {
await updateFolderAPI(folder.id, { parentId: null })
console.log(`Moved deeply nested folder "${folder.name}" to root level`)
} catch (error) {
console.error(`Failed to move folder "${folder.name}":`, error)
}
}
}, [workspaceId])
// Fetch folders when workspace changes
useEffect(() => {
if (workspaceId) {
fetchFolders(workspaceId)
fetchFolders(workspaceId).then(() => {
// Clean up any existing deep nesting after folders are loaded
cleanupDeepNesting()
})
}
}, [workspaceId, fetchFolders])
}, [workspaceId, fetchFolders, cleanupDeepNesting])
useEffect(() => {
clearSelection()
@@ -199,17 +427,18 @@ export function FolderTree({
const {
isDragOver: rootDragOver,
isInvalidDrop: rootInvalidDrop,
handleDragOver: handleRootDragOver,
handleDragLeave: handleRootDragLeave,
handleDrop: handleRootDrop,
} = useDragHandlers(updateWorkflow, null, 'Moved workflow(s) to root')
} = useDragHandlers(updateWorkflow, updateFolderAPI, null, 'Moved workflow(s) to root')
const renderFolderTree = (
nodes: FolderTreeNode[],
level = 0,
parentDragOver = false
): React.ReactNode[] => {
return nodes.map((folder) => (
return nodes.map((folder, index) => (
<FolderSection
key={folder.id}
folder={folder}
@@ -220,33 +449,70 @@ export function FolderTree({
expandedFolders={expandedFolders}
pathname={pathname}
updateWorkflow={updateWorkflow}
updateFolder={updateFolderAPI}
renderFolderTree={renderFolderTree}
parentDragOver={parentDragOver}
isFirstItem={level === 0 && index === 0}
/>
))
}
const showLoading = isLoading || foldersLoading
const rootWorkflows = workflowsByFolder.root || []
// Render skeleton loading state
const renderSkeletonLoading = () => {
if (isCollapsed) {
return (
<div className='space-y-1 py-2'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='mx-auto mb-1 flex h-9 w-9 items-center justify-center'>
<Skeleton className='h-4 w-4 rounded' />
</div>
))}
</div>
)
}
return (
<div className='space-y-1 py-2'>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className='flex h-9 items-center rounded-lg px-2 py-2'>
<Skeleton className='mr-2 h-4 w-4 rounded' />
<Skeleton className='h-4 max-w-32 flex-1' />
</div>
))}
</div>
)
}
if (showLoading) {
return renderSkeletonLoading()
}
return (
<div
className={`space-y-0.5 transition-opacity duration-200 ${showLoading ? 'opacity-60' : ''}`}
>
<div className='space-y-1 py-2'>
{/* Folder tree */}
{renderFolderTree(folderTree)}
{renderFolderTree(folderTree, 0, false)}
{/* Root level workflows (no folder) */}
<div
className={clsx(
'space-y-0.5',
rootDragOver ? 'rounded-md bg-blue-500/10 dark:bg-blue-400/10' : '',
'space-y-1',
rootDragOver
? rootInvalidDrop
? 'rounded-md bg-red-500/10 dark:bg-red-400/10'
: 'rounded-md bg-blue-500/10 dark:bg-blue-400/10'
: '',
// Always provide minimal drop zone when root is empty, but keep it subtle
(workflowsByFolder.root || []).length === 0 ? 'min-h-2 py-1' : ''
rootWorkflows.length === 0 ? 'min-h-2 py-1' : ''
)}
style={
rootDragOver
? {
boxShadow: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
boxShadow: rootInvalidDrop
? 'inset 0 0 0 1px rgb(239 68 68 / 0.5)'
: 'inset 0 0 0 1px rgb(59 130 246 / 0.5)',
}
: {}
}
@@ -254,7 +520,7 @@ export function FolderTree({
onDragLeave={handleRootDragLeave}
onDrop={handleRootDrop}
>
{(workflowsByFolder.root || []).map((workflow) => (
{rootWorkflows.map((workflow, index) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
@@ -262,41 +528,18 @@ export function FolderTree({
isCollapsed={isCollapsed}
level={-1}
isDragOver={rootDragOver}
isFirstItem={folderTree.length === 0 && index === 0}
/>
))}
</div>
{/* Marketplace workflows */}
{marketplaceWorkflows.length > 0 && (
<div className='mt-2 border-border/30 border-t pt-2'>
<h3
className={`mb-1 px-2 font-medium text-muted-foreground text-xs ${
isCollapsed ? 'text-center' : ''
}`}
>
{isCollapsed ? '' : 'Marketplace'}
</h3>
{marketplaceWorkflows.map((workflow) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isMarketplace
isCollapsed={isCollapsed}
level={-1}
isDragOver={false}
/>
))}
</div>
)}
{/* Empty state */}
{!showLoading &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0 &&
folderTree.length === 0 &&
!isCollapsed && (
<div className='px-2 py-1.5 text-muted-foreground text-xs'>
<div className='break-words px-2 py-1.5 text-muted-foreground text-xs'>
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create one
to get started.
</div>

View File

@@ -1,147 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import Link from 'next/link'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
interface NavSectionProps {
children: ReactNode
isLoading?: boolean
itemCount?: number
isCollapsed?: boolean
}
interface NavItemProps {
icon: ReactNode
label: string
href?: string
active?: boolean
onClick?: () => void
isCollapsed?: boolean
shortcutCommand?: string
shortcutCommandPosition?: 'inline' | 'below'
}
export function NavSection({
children,
isLoading = false,
itemCount = 3,
isCollapsed,
}: NavSectionProps) {
if (isLoading) {
return (
<nav className='space-y-1'>
{Array(itemCount)
.fill(0)
.map((_, i) => (
<NavItemSkeleton key={i} isCollapsed={isCollapsed} />
))}
</nav>
)
}
return <nav className='space-y-1'>{children}</nav>
}
function NavItem({
icon,
label,
href,
active,
onClick,
isCollapsed,
shortcutCommand,
shortcutCommandPosition = 'inline',
}: NavItemProps) {
const className = clsx(
'flex items-center gap-2 rounded-md px-2 py-[6px] text-sm font-medium',
active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50',
{
'cursor-pointer': onClick,
'justify-center': isCollapsed,
'w-full': !isCollapsed,
'w-8 mx-auto': isCollapsed,
}
)
const content = (
<>
{isCollapsed ? <div className='p-[1px]'>{icon}</div> : icon}
{!isCollapsed && <span className='truncate'>{label}</span>}
</>
)
if (isCollapsed) {
if (href) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href={href} className={className}>
{content}
</Link>
</TooltipTrigger>
<TooltipContent
side='right'
command={shortcutCommand}
commandPosition={shortcutCommandPosition}
>
{label}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button onClick={onClick} className={className}>
{content}
</button>
</TooltipTrigger>
<TooltipContent
side='right'
command={shortcutCommand}
commandPosition={shortcutCommandPosition}
>
{label}
</TooltipContent>
</Tooltip>
)
}
if (href) {
return (
<Link href={href} className={className}>
{content}
</Link>
)
}
return (
<button onClick={onClick} className={className}>
{content}
</button>
)
}
function NavItemSkeleton({ isCollapsed }: { isCollapsed?: boolean }) {
if (isCollapsed) {
return (
<div className='mx-auto flex h-8 w-8 items-center justify-center'>
<Skeleton className='h-[18px] w-[18px]' />
</div>
)
}
return (
<div className='flex items-center gap-2 rounded-md px-2 py-[6px]'>
<Skeleton className='h-[18px] w-[18px]' />
<Skeleton className='h-4 w-24' />
</div>
)
}
NavSection.Item = NavItem
NavSection.Skeleton = NavItemSkeleton

View File

@@ -209,7 +209,7 @@ export function General() {
disabled={isLoading}
/>
</div>
<div className='flex items-center justify-between py-1'>
{/* <div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-pan' className='font-medium'>
Auto-pan during execution
@@ -237,7 +237,7 @@ export function General() {
onCheckedChange={handleAutoPanChange}
disabled={isLoading}
/>
</div>
</div> */}
</>
)}
</div>

View File

@@ -84,7 +84,7 @@ export function EditMemberLimitDialog({
if (newLimit < member.currentUsage) {
setError(
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage.toFixed(2)})`
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage?.toFixed(2) || 0})`
)
return
}

View File

@@ -300,7 +300,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Current Usage</span>
<span className='font-semibold'>
${organizationBillingData.totalCurrentUsage.toFixed(2)}
${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0}
</span>
</div>
</div>

View File

@@ -1,102 +0,0 @@
'use client'
import { useState } from 'react'
import { PanelRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { type SidebarMode, useSidebarStore } from '@/stores/sidebar/store'
// This component ONLY controls sidebar state, not toolbar state
export function SidebarControl() {
const { mode, setMode, toggleExpanded, isExpanded } = useSidebarStore()
const [open, setOpen] = useState(false)
const handleModeChange = (value: SidebarMode) => {
// When selecting expanded mode, ensure it's expanded
if (value === 'expanded' && !isExpanded) {
toggleExpanded()
}
// Set the new mode
setMode(value)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='icon'
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md p-0 text-muted-foreground hover:bg-accent/50'
>
<PanelRight className='h-[18px] w-[18px] text-muted-foreground' />
<span className='sr-only text-sm'>Sidebar control</span>
</Button>
</PopoverTrigger>
<PopoverContent
className='w-44 overflow-hidden rounded-lg border bg-background p-0 shadow-md'
side='top'
align='start'
sideOffset={5}
>
<div className='border-b px-4 py-[10px]'>
<h4 className='font-[480] text-muted-foreground text-xs'>Sidebar control</h4>
</div>
<div className='px-2 pt-1 pb-2'>
<div className='flex flex-col gap-[1px]'>
<button
className={cn(
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
)}
onClick={() => handleModeChange('expanded')}
>
<span className='flex items-center'>
<span
className={cn(
'mr-1.5 h-1 w-1 rounded-full',
mode === 'expanded' ? 'bg-muted-foreground' : 'bg-transparent'
)}
/>
Expanded
</span>
</button>
<button
className={cn(
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
)}
onClick={() => handleModeChange('collapsed')}
>
<span className='flex items-center'>
<span
className={cn(
'mr-1.5 h-1 w-1 rounded-full',
mode === 'collapsed' ? 'bg-muted-foreground' : 'bg-transparent'
)}
/>
Collapsed
</span>
</button>
<button
className={cn(
'w-full rounded px-2 py-1.5 text-left font-medium text-muted-foreground text-xs hover:bg-accent/50'
)}
onClick={() => handleModeChange('hover')}
>
<span className='flex items-center'>
<span
className={cn(
'mr-1.5 h-1 w-1 rounded-full',
mode === 'hover' ? 'bg-muted-foreground' : 'bg-transparent'
)}
/>
Expand on hover
</span>
</button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -40,28 +40,25 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
<div
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
style={{ backgroundColor: config.bgColor }}
>
<config.icon
className={cn(
'text-white transition-transform duration-200',
!disabled && 'group-hover:scale-110',
config.type === 'agent' ? 'h-[24px] w-[24px]' : 'h-[22px] w-[22px]'
'h-[14px] w-[14px]'
)}
/>
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='font-medium leading-none'>{config.name}</h3>
<p className='text-muted-foreground text-sm leading-snug'>{config.description}</p>
</div>
<span className='font-medium text-sm leading-none'>{config.name}</span>
</div>
)

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { LoopTool } from '../../../loop-node/loop-config'
import { LoopTool } from '../../../../../../[workflowId]/components/loop-node/loop-config'
type LoopToolbarItemProps = {
disabled?: boolean
@@ -49,27 +49,24 @@ export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemPro
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
<div
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
style={{ backgroundColor: LoopTool.bgColor }}
>
<LoopTool.icon
className={cn(
'h-[22px] w-[22px] text-white transition-transform duration-200',
'h-[14px] w-[14px] text-white transition-transform duration-200',
!disabled && 'group-hover:scale-110'
)}
/>
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='font-medium leading-none'>{LoopTool.name}</h3>
<p className='text-muted-foreground text-sm leading-snug'>{LoopTool.description}</p>
</div>
<span className='font-medium text-sm leading-none'>{LoopTool.name}</span>
</div>
)

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { ParallelTool } from '../../../parallel-node/parallel-config'
import { ParallelTool } from '../../../../../../[workflowId]/components/parallel-node/parallel-config'
type ParallelToolbarItemProps = {
disabled?: boolean
@@ -49,27 +49,24 @@ export default function ParallelToolbarItem({ disabled = false }: ParallelToolba
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex items-center gap-3 rounded-lg border bg-card p-3.5 shadow-sm transition-colors',
'group flex items-center gap-3 rounded-lg p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent/50 active:cursor-grabbing'
)}
>
<div
className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg'
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md'
style={{ backgroundColor: ParallelTool.bgColor }}
>
<ParallelTool.icon
className={cn(
'h-[22px] w-[22px] text-white transition-transform duration-200',
'h-[14px] w-[14px] text-white transition-transform duration-200',
!disabled && 'group-hover:scale-110'
)}
/>
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='font-medium leading-none'>{ParallelTool.name}</h3>
<p className='text-muted-foreground text-sm leading-snug'>{ParallelTool.description}</p>
</div>
<span className='font-medium text-sm leading-none'>{ParallelTool.name}</span>
</div>
)

View File

@@ -0,0 +1,138 @@
'use client'
import { useMemo, useState } from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { getAllBlocks } from '@/blocks'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
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'
interface ToolbarProps {
userPermissions: WorkspaceUserPermissions
isWorkspaceSelectorVisible?: boolean
}
interface BlockItem {
name: string
type: string
isCustom: boolean
config?: any
}
export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: ToolbarProps) {
const [searchQuery, setSearchQuery] = useState('')
const { regularBlocks, specialBlocks, tools } = useMemo(() => {
const allBlocks = getAllBlocks()
// Filter blocks based on search query
const filteredBlocks = allBlocks.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())
)
})
// Separate regular blocks (category: 'blocks') and tools (category: 'tools')
const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks')
const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools')
// Create regular block items and sort alphabetically
const regularBlockItems: BlockItem[] = regularBlockConfigs
.map((block) => ({
name: block.name,
type: block.type,
config: block,
isCustom: false,
}))
.sort((a, b) => a.name.localeCompare(b.name))
// Create special blocks (loop and parallel) if they match search
const specialBlockItems: BlockItem[] = []
if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) {
specialBlockItems.push({
name: 'Loop',
type: 'loop',
isCustom: true,
})
}
if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) {
specialBlockItems.push({
name: 'Parallel',
type: 'parallel',
isCustom: true,
})
}
// Sort special blocks alphabetically
specialBlockItems.sort((a, b) => a.name.localeCompare(b.name))
// Sort tools alphabetically
toolConfigs.sort((a, b) => a.name.localeCompare(b.name))
return {
regularBlocks: regularBlockItems,
specialBlocks: specialBlockItems,
tools: toolConfigs,
}
}, [searchQuery])
return (
<div className='flex h-full flex-col'>
{/* Search */}
<div className='flex-shrink-0 p-2'>
<div className='flex h-9 items-center gap-2 rounded-[10px] border bg-background pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search blocks...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='h-6 flex-1 border-0 bg-transparent px-0 font-normal text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
</div>
{/* Content */}
<ScrollArea className='flex-1 px-2' hideScrollbar={true}>
<div className='space-y-1 pb-2'>
{/* Regular Blocks Section */}
{regularBlocks.map((block) => (
<ToolbarBlock
key={block.type}
config={block.config}
disabled={!userPermissions.canEdit}
/>
))}
{/* Special Blocks Section (Loop & Parallel) */}
{specialBlocks.map((block) => {
if (block.type === 'loop') {
return <LoopToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
}
if (block.type === 'parallel') {
return <ParallelToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
}
return null
})}
{/* Tools Section */}
{tools.map((tool) => (
<ToolbarBlock key={tool.type} config={tool} disabled={!userPermissions.canEdit} />
))}
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import { MoreHorizontal, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console-logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('WorkflowContextMenu')
interface WorkflowContextMenuProps {
workflow: WorkflowMetadata
onRename?: (workflowId: string, newName: string) => void
level: number
}
export function WorkflowContextMenu({ workflow, onRename, level }: WorkflowContextMenuProps) {
const [showRenameDialog, setShowRenameDialog] = useState(false)
const [renameName, setRenameName] = useState(workflow.name)
const [isRenaming, setIsRenaming] = useState(false)
// Get user permissions for the workspace
const userPermissions = useUserPermissionsContext()
const { updateWorkflow } = useWorkflowRegistry()
const handleRename = () => {
setRenameName(workflow.name)
setShowRenameDialog(true)
}
const handleRenameSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!renameName.trim()) return
setIsRenaming(true)
try {
if (onRename) {
onRename(workflow.id, renameName.trim())
} else {
// Default rename behavior using updateWorkflow
await updateWorkflow(workflow.id, { name: renameName.trim() })
logger.info(
`Successfully renamed workflow from "${workflow.name}" to "${renameName.trim()}"`
)
}
setShowRenameDialog(false)
} catch (error) {
logger.error('Failed to rename workflow:', {
error,
workflowId: workflow.id,
oldName: workflow.name,
newName: renameName.trim(),
})
} finally {
setIsRenaming(false)
}
}
const handleCancel = () => {
setRenameName(workflow.name)
setShowRenameDialog(false)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-4 w-4 p-0 opacity-0 transition-opacity hover:bg-transparent focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className='h-3 w-3' />
<span className='sr-only'>Workflow options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
onClick={(e) => e.stopPropagation()}
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
{userPermissions.canEdit && (
<DropdownMenuItem
onClick={handleRename}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Pencil className='mr-2 h-4 w-4' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Rename dialog */}
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className='sm:max-w-[425px]' onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Rename Workflow</DialogTitle>
</DialogHeader>
<form onSubmit={handleRenameSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='rename-workflow'>Workflow Name</Label>
<Input
id='rename-workflow'
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder='Enter workflow name...'
maxLength={100}
autoFocus
required
/>
</div>
<div className='flex justify-end space-x-2'>
<Button type='button' variant='outline' onClick={handleCancel}>
Cancel
</Button>
<Button type='submit' disabled={!renameName.trim() || isRenaming}>
{isRenaming ? 'Renaming...' : 'Rename'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -1,42 +1,20 @@
'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, Pencil, Trash2, X } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, PanelLeft } from 'lucide-react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkspaceHeader')
/**
* Workspace entity interface
*/
interface Workspace {
id: string
name: string
@@ -44,698 +22,250 @@ interface Workspace {
role?: string
}
/**
* Main WorkspaceHeader component props
*/
interface WorkspaceHeaderProps {
onCreateWorkflow: () => void
isCollapsed?: boolean
onDropdownOpenChange?: (isOpen: boolean) => void
isWorkspaceSelectorVisible: boolean
onToggleWorkspaceSelector: () => void
activeWorkspace: Workspace | null
isWorkspacesLoading: boolean
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
}
// New WorkspaceModal component
interface WorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreateWorkspace: (name: string) => void
}
const WorkspaceModal = React.memo<WorkspaceModalProps>(
({ open, onOpenChange, onCreateWorkspace }) => {
const [workspaceName, setWorkspaceName] = useState('')
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (workspaceName.trim()) {
onCreateWorkspace(workspaceName.trim())
setWorkspaceName('')
onOpenChange(false)
}
},
[workspaceName, onCreateWorkspace, onOpenChange]
)
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setWorkspaceName(e.target.value)
}, [])
const handleClose = useCallback(() => {
onOpenChange(false)
}, [onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[500px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Create New Workspace</DialogTitle>
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='px-6 pt-4 pb-6'>
<form onSubmit={handleSubmit}>
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name' className='font-medium text-sm'>
Workspace Name
</label>
<Input
id='workspace-name'
value={workspaceName}
onChange={handleNameChange}
placeholder='Enter workspace name'
className='w-full'
autoFocus
/>
</div>
<div className='flex justify-end'>
<Button
type='submit'
size='sm'
disabled={!workspaceName.trim()}
className={cn(
'gap-2 font-medium',
'bg-[#802FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
Create
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
)
}
)
WorkspaceModal.displayName = 'WorkspaceModal'
// New WorkspaceEditModal component
interface WorkspaceEditModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onUpdateWorkspace: (id: string, name: string) => void
workspace: Workspace | null
}
const WorkspaceEditModal = React.memo<WorkspaceEditModalProps>(
({ open, onOpenChange, onUpdateWorkspace, workspace }) => {
const [workspaceName, setWorkspaceName] = useState('')
useEffect(() => {
if (workspace && open) {
setWorkspaceName(workspace.name)
}
}, [workspace, open])
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
if (workspace && workspaceName.trim()) {
onUpdateWorkspace(workspace.id, workspaceName.trim())
setWorkspaceName('')
onOpenChange(false)
}
},
[workspace, workspaceName, onUpdateWorkspace, onOpenChange]
)
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setWorkspaceName(e.target.value)
}, [])
const handleClose = useCallback(() => {
onOpenChange(false)
}, [onOpenChange])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex flex-col gap-0 overflow-hidden p-0 sm:max-w-[500px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Edit Workspace</DialogTitle>
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='px-6 pt-4 pb-6'>
<form onSubmit={handleSubmit}>
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name-edit' className='font-medium text-sm'>
Workspace Name
</label>
<Input
id='workspace-name-edit'
value={workspaceName}
onChange={handleNameChange}
placeholder='Enter workspace name'
className='w-full'
autoFocus
/>
</div>
<div className='flex justify-end'>
<Button
type='submit'
size='sm'
disabled={!workspaceName.trim()}
className={cn(
'gap-2 font-medium',
'bg-[#802FFF] hover:bg-[#7028E6]',
'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none'
)}
>
Update
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
)
}
)
WorkspaceEditModal.displayName = 'WorkspaceEditModal'
/**
* WorkspaceHeader component - Single row header with all elements
*/
export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
({ onCreateWorkflow, isCollapsed, onDropdownOpenChange }) => {
// Get sidebar store state to check current mode
const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, setAnyModalOpen } =
useSidebarStore()
const { data: sessionData, isPending } = useSession()
const { getSubscriptionStatus } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
const getPlanName = (subscription: ReturnType<typeof getSubscriptionStatus>) => {
if (subscription.isEnterprise) return 'Enterprise Plan'
if (subscription.isTeam) return 'Team Plan'
if (subscription.isPro) return 'Pro Plan'
return 'Free Plan'
}
const plan = getPlanName(subscription)
// Use client-side loading instead of isPending to avoid hydration mismatch
({
onCreateWorkflow,
isWorkspaceSelectorVisible,
onToggleWorkspaceSelector,
activeWorkspace,
isWorkspacesLoading,
updateWorkspaceName,
}) => {
// External hooks
const { data: sessionData } = useSession()
const [isClientLoading, setIsClientLoading] = useState(true)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false)
const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const router = useRouter()
const [isEditingName, setIsEditingName] = useState(false)
const [editingName, setEditingName] = useState('')
// Get workflowRegistry state and actions
const { switchToWorkspace } = useWorkflowRegistry()
const params = useParams()
const currentWorkspaceId = params.workspaceId as string
// Get user permissions for the active workspace
const userPermissions = useUserPermissionsContext()
// Refs
const editInputRef = useRef<HTMLInputElement>(null)
// Computed values
const userName = useMemo(
() => sessionData?.user?.name || sessionData?.user?.email || 'User',
[sessionData?.user?.name, sessionData?.user?.email]
)
// Set isClientLoading to false after hydration
useEffect(() => {
setIsClientLoading(false)
}, [])
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Only update workspace if we have a valid currentWorkspaceId from URL
if (currentWorkspaceId) {
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentWorkspaceId
)
if (matchingWorkspace) {
setActiveWorkspace(matchingWorkspace)
} else {
// Log the mismatch for debugging
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
// Current workspace not found, fallback to first workspace
if (fetchedWorkspaces.length > 0) {
const fallbackWorkspace = fetchedWorkspaces[0]
setActiveWorkspace(fallbackWorkspace)
// Navigate to the fallback workspace
router.push(`/workspace/${fallbackWorkspace.id}/w`)
} else {
// No workspaces available - handle this edge case
logger.error('No workspaces available for user')
}
}
}
}
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, [currentWorkspaceId, router])
useEffect(() => {
// Fetch subscription status if user is logged in
if (sessionData?.user?.id) {
fetchWorkspaces()
}
}, [sessionData?.user?.id, fetchWorkspaces])
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, close dropdown and do nothing else
if (activeWorkspace?.id === workspace.id) {
setWorkspaceDropdownOpen(false)
return
}
setActiveWorkspace(workspace)
setWorkspaceDropdownOpen(false)
// Use full workspace switch which now handles localStorage automatically
await switchToWorkspace(workspace.id)
// Update URL to include workspace ID - only after workspace switch completes
router.push(`/workspace/${workspace.id}/w`)
},
[activeWorkspace?.id, switchToWorkspace, router, setWorkspaceDropdownOpen]
)
const handleCreateWorkspace = useCallback(
async (name: string) => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
})
const data = await response.json()
if (data.workspace) {
const newWorkspace = data.workspace as Workspace
setWorkspaces((prev) => [...prev, newWorkspace])
setActiveWorkspace(newWorkspace)
// Use switchToWorkspace to properly load workflows for the new workspace
// This will clear existing workflows, set loading state, and fetch workflows from DB
await switchToWorkspace(newWorkspace.id)
// Update URL to include new workspace ID - only after workspace switch completes
router.push(`/workspace/${newWorkspace.id}/w`)
}
} catch (err) {
logger.error('Error creating workspace:', err)
} finally {
setIsWorkspacesLoading(false)
}
},
[switchToWorkspace, router]
)
const handleUpdateWorkspace = useCallback(
async (id: string, name: string) => {
// For update operations, we need to check permissions for the specific workspace
// Since we can only use hooks at the component level, we'll make the API call
// and let the backend handle the permission check
setIsWorkspacesLoading(true)
try {
const response = await fetch(`/api/workspaces/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
})
if (!response.ok) {
if (response.status === 403) {
logger.error(
'Permission denied: Only users with admin permissions can update workspaces'
)
}
throw new Error('Failed to update workspace')
}
const { workspace: updatedWorkspace } = await response.json()
// Update workspaces list
setWorkspaces((prevWorkspaces) =>
prevWorkspaces.map((w) =>
w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w
)
)
// If active workspace was updated, update it too
if (activeWorkspace && activeWorkspace.id === updatedWorkspace.id) {
setActiveWorkspace({
...activeWorkspace,
name: updatedWorkspace.name,
})
}
} catch (err) {
logger.error('Error updating workspace:', err)
} finally {
setIsWorkspacesLoading(false)
}
},
[activeWorkspace]
)
const handleDeleteWorkspace = useCallback(
async (id: string) => {
// For delete operations, we need to check permissions for the specific workspace
// Since we can only use hooks at the component level, we'll make the API call
// and let the backend handle the permission check
setIsDeleting(true)
try {
const response = await fetch(`/api/workspaces/${id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (response.status === 403) {
logger.error(
'Permission denied: Only users with admin permissions can delete workspaces'
)
}
throw new Error('Failed to delete workspace')
}
// Remove from workspace list
const updatedWorkspaces = workspaces.filter((w) => w.id !== id)
setWorkspaces(updatedWorkspaces)
// If deleted workspace was active, switch to another workspace
if (activeWorkspace?.id === id) {
const newWorkspace = updatedWorkspaces[0]
setActiveWorkspace(newWorkspace)
// Switch to the new workspace (this handles all workflow state management)
await switchToWorkspace(newWorkspace.id)
// Navigate to the new workspace - only after workspace switch completes
router.push(`/workspace/${newWorkspace.id}/w`)
}
setWorkspaceDropdownOpen(false)
} catch (err) {
logger.error('Error deleting workspace:', err)
} finally {
setIsDeleting(false)
}
},
[workspaces, activeWorkspace?.id]
)
const openEditModal = useCallback(
(workspace: Workspace, e: React.MouseEvent) => {
e.stopPropagation()
// Only show edit/delete options for the active workspace if user has admin permissions
if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) {
return
}
setEditingWorkspace(workspace)
setIsEditModalOpen(true)
},
[activeWorkspace?.id, userPermissions.canAdmin]
)
// Determine URL for workspace links
const workspaceUrl = useMemo(
() => (activeWorkspace ? `/workspace/${activeWorkspace.id}/w` : '/workspace'),
[activeWorkspace]
)
// Notify parent component when dropdown opens/closes
const handleDropdownOpenChange = useCallback(
(open: boolean) => {
setWorkspaceDropdownOpen(open)
// Inform the parent component about the dropdown state change
if (onDropdownOpenChange) {
onDropdownOpenChange(open)
}
},
[onDropdownOpenChange, setWorkspaceDropdownOpen]
const displayName = useMemo(
() => activeWorkspace?.name || `${userName}'s Workspace`,
[activeWorkspace?.name, userName]
)
// Special handling for click interactions in hover mode
const handleTriggerClick = useCallback(
(e: React.MouseEvent) => {
// When in hover mode, explicitly prevent bubbling for the trigger
if (mode === 'hover') {
e.stopPropagation()
e.preventDefault()
// Toggle dropdown state
handleDropdownOpenChange(!workspaceDropdownOpen)
}
},
[mode, workspaceDropdownOpen, handleDropdownOpenChange]
)
const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
// In hover mode, prevent clicks on the container from collapsing the sidebar
if (mode === 'hover') {
e.stopPropagation()
}
},
[mode]
)
const handleWorkspaceModalOpenChange = useCallback((open: boolean) => {
setIsWorkspaceModalOpen(open)
}, [])
const handleEditModalOpenChange = useCallback((open: boolean) => {
setIsEditModalOpen(open)
}, [])
// Handle modal open/close state
// Effects
useEffect(() => {
// Update the modal state in the store
setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting)
}, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen])
setIsClientLoading(false)
}, [])
return (
<div className='px-2 py-2'>
{/* Workspace Modal */}
<WorkspaceModal
open={isWorkspaceModalOpen}
onOpenChange={handleWorkspaceModalOpenChange}
onCreateWorkspace={handleCreateWorkspace}
/>
// Focus input when editing starts
useEffect(() => {
if (isEditingName && editInputRef.current) {
editInputRef.current.focus()
editInputRef.current.select()
}
}, [isEditingName])
{/* Edit Workspace Modal */}
<WorkspaceEditModal
open={isEditModalOpen}
onOpenChange={handleEditModalOpenChange}
onUpdateWorkspace={handleUpdateWorkspace}
workspace={editingWorkspace}
/>
// Handle toggle sidebar
const handleToggleSidebar = useCallback(() => {
// This will be implemented when needed - placeholder for now
logger.info('Toggle sidebar clicked')
}, [])
<DropdownMenu open={workspaceDropdownOpen} onOpenChange={handleDropdownOpenChange}>
<div
className={`group relative cursor-pointer rounded-md ${isCollapsed ? 'flex justify-center' : ''}`}
onClick={handleContainerClick}
// Handle workspace name click
const handleWorkspaceNameClick = useCallback(() => {
setEditingName(displayName)
setIsEditingName(true)
}, [displayName])
// Handle workspace name editing actions
const handleEditingAction = useCallback(
(action: 'save' | 'cancel') => {
switch (action) {
case 'save': {
// Exit edit mode immediately, save in background
setIsEditingName(false)
if (activeWorkspace && editingName.trim() !== '') {
updateWorkspaceName(activeWorkspace.id, editingName.trim()).catch((error) => {
logger.error('Failed to update workspace name:', error)
})
}
break
}
case 'cancel': {
// Cancel without saving
setIsEditingName(false)
setEditingName('')
break
}
}
},
[activeWorkspace, editingName, updateWorkspaceName]
)
// Handle keyboard interactions
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleEditingAction('save')
} else if (e.key === 'Escape') {
handleEditingAction('cancel')
}
},
[handleEditingAction]
)
// Handle click away - immediate exit with background save
const handleInputBlur = useCallback(() => {
handleEditingAction('save')
}, [handleEditingAction])
// Render loading state
const renderLoadingState = () => (
<>
{/* Icon */}
<div className='flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'>
<AgentIcon className='h-4 w-4 text-white' />
</div>
{/* Loading workspace name and chevron container */}
<div className='flex min-w-0 flex-1 items-center'>
<div className='min-w-0 flex-1 p-1'>
<Skeleton className='h-4 w-24' />
</div>
{/* Chevron */}
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
disabled
>
{/* Hover background with consistent padding - only when not collapsed */}
{!isCollapsed && (
<div className='absolute inset-0 rounded-md group-hover:bg-accent/50' />
)}
{/* Content with consistent padding */}
{isCollapsed ? (
<div className='relative z-10 flex items-center justify-center px-2 py-[6px]'>
<Link
href={workspaceUrl}
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
>
<AgentIcon className='-translate-y-[0.5px] h-[18px] w-[18px] text-white transition-all group-hover:scale-105' />
</Link>
</div>
{isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<div className='relative'>
<DropdownMenuTrigger asChild>
<div
className='relative z-10 flex w-full items-center px-2 py-[6px]'
onClick={handleTriggerClick}
>
<div className='flex cursor-pointer items-center gap-2 overflow-hidden'>
<Link
href={workspaceUrl}
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
onClick={(e) => {
if (workspaceDropdownOpen) e.preventDefault()
}}
>
<AgentIcon className='-translate-y-[0.5px] h-[18px] w-[18px] text-white transition-all group-hover:scale-105' />
</Link>
{isClientLoading || isWorkspacesLoading ? (
<Skeleton className='h-4 w-[140px]' />
) : (
<div className='flex items-center gap-1'>
<span className='max-w-[120px] truncate font-medium text-sm'>
{activeWorkspace?.name || `${userName}'s Workspace`}
</span>
<ChevronDown className='h-3 w-3 opacity-60' />
</div>
)}
</div>
</div>
</DropdownMenuTrigger>
<ChevronDown className='h-4 w-4' />
)}
</Button>
</div>
{/* Toggle Sidebar - with gap-2 max from chevron */}
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
disabled
>
<PanelLeft className='h-4 w-4' />
</Button>
</div>
</>
)
// Render workspace info
const renderWorkspaceInfo = () => (
<>
{/* Icon - separate from hover area */}
<Link
href={workspaceUrl}
className='group flex h-6 w-6 shrink-0 items-center justify-center rounded bg-[#802FFF]'
>
<AgentIcon className='h-4 w-4 text-white transition-all group-hover:scale-105' />
</Link>
{/* Workspace Name and Chevron Container */}
<div className='flex min-w-0 flex-1 items-center'>
{/* Workspace Name - Editable */}
<div className={`flex min-w-0 items-center p-1 ${isEditingName ? 'flex-1' : ''}`}>
{isEditingName ? (
<input
ref={editInputRef}
type='text'
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
className='m-0 h-auto w-full resize-none truncate border-0 bg-transparent p-0 font-medium text-sm leading-none outline-none'
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
/>
) : (
<div
onClick={handleWorkspaceNameClick}
className='cursor-pointer truncate font-medium text-sm leading-none transition-all hover:brightness-75 dark:hover:brightness-125'
style={{
minHeight: '1rem',
lineHeight: '1rem',
}}
>
{displayName}
</div>
)}
</div>
<DropdownMenuContent align='start' className='min-w-[224px] p-1'>
<div className='space-y-3'>
<div className='flex items-center justify-between p-1'>
<div className='flex items-center gap-2'>
<div className='flex h-8 w-8 shrink-0 items-center justify-center rounded bg-[#802FFF]'>
<AgentIcon className='h-5 w-5 text-white' />
</div>
<div className='flex max-w-full flex-col'>
{isClientLoading || isWorkspacesLoading ? (
<>
<Skeleton className='mb-1 h-4 w-[140px]' />
<Skeleton className='h-3 w-16' />
</>
) : (
<>
<span className='truncate font-medium text-sm'>
{activeWorkspace?.name || `${userName}'s Workspace`}
</span>
<span className='text-muted-foreground text-xs'>{plan}</span>
</>
)}
</div>
</div>
</div>
</div>
<DropdownMenuSeparator />
{/* Workspaces list */}
<div className='px-1 py-1'>
<div className='mb-1 pl-1 font-medium text-muted-foreground text-xs'>Workspaces</div>
{isWorkspacesLoading ? (
<div className='px-2 py-1'>
<Skeleton className='h-5 w-full' />
</div>
{/* Chevron - Next to name, hidden in edit mode */}
{!isEditingName && (
<Button
variant='ghost'
size='icon'
onClick={onToggleWorkspaceSelector}
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
>
{isWorkspaceSelectorVisible ? (
<ChevronUp className='h-4 w-4' />
) : (
<div className='space-y-1'>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className={`cursor-pointer rounded-md px-2 py-1.5 text-sm ${activeWorkspace?.id === workspace.id ? 'bg-accent' : ''} group relative`}
onClick={() => switchWorkspace(workspace)}
>
<span className='truncate pr-16'>{workspace.name}</span>
{userPermissions.canAdmin && activeWorkspace?.id === workspace.id && (
<div className='absolute right-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 p-0 text-muted-foreground'
onClick={(e) => openEditModal(workspace, e)}
>
<Pencil className='h-3.5 w-3.5' />
<span className='sr-only'>Edit</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 p-0 text-muted-foreground'
onClick={(e) => e.stopPropagation()}
disabled={isDeleting || workspaces.length <= 1}
>
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Delete</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{workspace.name}"? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.stopPropagation()
handleDeleteWorkspace(workspace.id)
}}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</DropdownMenuItem>
))}
</div>
<ChevronDown className='h-4 w-4' />
)}
</Button>
)}
</div>
{/* Create new workspace button */}
<DropdownMenuItem
className='mt-1 cursor-pointer rounded-md px-2 py-1.5 text-muted-foreground text-sm'
onClick={() => setIsWorkspaceModalOpen(true)}
{/* Toggle Sidebar - with gap-2 max from chevron */}
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleToggleSidebar}
className='h-6 w-6 text-muted-foreground hover:bg-secondary'
>
<span className='truncate'>+ New workspace</span>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
<PanelLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='bottom'>Toggle sidebar</TooltipContent>
</Tooltip>
</div>
</>
)
// Main render - using h-12 to match control bar height
return (
<div className='h-12 rounded-[14px] border bg-card shadow-xs'>
<div className='flex h-full items-center gap-1 px-3'>
{isClientLoading || isWorkspacesLoading ? renderLoadingState() : renderWorkspaceInfo()}
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, Send, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '../../../providers/workspace-permissions-provider'
import { InviteModal } from './components/invite-modal/invite-modal'
const logger = createLogger('WorkspaceSelector')
/**
* Workspace entity interface
*/
interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
interface WorkspaceSelectorProps {
workspaces: Workspace[]
activeWorkspace: Workspace | null
isWorkspacesLoading: boolean
onWorkspaceUpdate: () => Promise<void>
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
onCreateWorkspace: () => Promise<void>
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
isDeleting: boolean
}
export function WorkspaceSelector({
workspaces,
activeWorkspace,
isWorkspacesLoading,
onWorkspaceUpdate,
onSwitchWorkspace,
onCreateWorkspace,
onDeleteWorkspace,
isDeleting,
}: WorkspaceSelectorProps) {
const userPermissions = useUserPermissionsContext()
// State
const [showInviteMembers, setShowInviteMembers] = useState(false)
const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState<string | null>(null)
// Refs
const scrollAreaRef = useRef<HTMLDivElement>(null)
/**
* Scroll to active workspace on load or when it changes
*/
useEffect(() => {
if (activeWorkspace && !isWorkspacesLoading) {
const scrollContainer = scrollAreaRef.current
if (scrollContainer) {
const activeButton = scrollContainer.querySelector(
`[data-workspace-id="${activeWorkspace.id}"]`
) as HTMLElement
if (activeButton) {
activeButton.scrollIntoView({
block: 'nearest',
})
}
}
}
}, [activeWorkspace, isWorkspacesLoading])
/**
* Confirm delete workspace
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
await onDeleteWorkspace(workspaceToDelete)
},
[onDeleteWorkspace]
)
// Render workspace list
const renderWorkspaceList = () => {
if (isWorkspacesLoading) {
return (
<div className='space-y-1'>
{[1, 2, 3].map((i) => (
<div key={i} className='flex w-full items-center justify-between rounded-lg p-2'>
<Skeleton className='h-[20px] w-32' />
</div>
))}
</div>
)
}
return (
<div className='space-y-1'>
{workspaces.map((workspace) => (
<div
key={workspace.id}
data-workspace-id={workspace.id}
onMouseEnter={() => setHoveredWorkspaceId(workspace.id)}
onMouseLeave={() => setHoveredWorkspaceId(null)}
onClick={() => onSwitchWorkspace(workspace)}
className={cn(
'group flex h-9 w-full cursor-pointer items-center rounded-lg p-2 text-left transition-colors',
activeWorkspace?.id === workspace.id ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
<div className='flex h-full min-w-0 flex-1 items-center text-left'>
<span
className={cn(
'truncate font-medium text-sm',
activeWorkspace?.id === workspace.id ? 'text-foreground' : 'text-muted-foreground'
)}
>
{workspace.name}
</span>
</div>
<div className='flex h-full w-6 flex-shrink-0 items-center justify-center'>
{hoveredWorkspaceId === workspace.id && workspace.permissions === 'admin' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
}}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:text-muted-foreground'
>
<Trash2 className='h-2 w-2' />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{workspace.name}"? This action cannot be
undone and will permanently delete all workflows and data in this workspace.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmDeleteWorkspace(workspace)}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
))}
</div>
)
}
return (
<>
<div className='rounded-[14px] border bg-card shadow-xs'>
<div className='flex h-full flex-col p-2'>
{/* Workspace List */}
<div className='min-h-0 flex-1'>
<ScrollArea ref={scrollAreaRef} className='h-[116px]' hideScrollbar={true}>
{renderWorkspaceList()}
</ScrollArea>
</div>
{/* Bottom Actions */}
<div className='mt-2 flex items-center gap-2 border-t pt-2'>
{/* Send Invite */}
<Button
variant='secondary'
size='sm'
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}
disabled={!userPermissions.canAdmin}
className={cn(
'h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs hover:bg-secondary hover:text-muted-foreground',
!userPermissions.canAdmin && 'cursor-not-allowed opacity-50'
)}
>
<Send className='h-3 w-3' />
<span>Invite</span>
</Button>
{/* Create Workspace */}
<Button
variant='secondary'
size='sm'
onClick={onCreateWorkspace}
className='h-8 flex-1 justify-center gap-2 rounded-[8px] font-medium text-muted-foreground text-xs hover:bg-secondary hover:text-muted-foreground'
>
<Plus className='h-3 w-3' />
<span>Create</span>
</Button>
</div>
</div>
</div>
{/* Invite Modal */}
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
</>
)
}

View File

@@ -1,33 +1,56 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { HelpCircle, LibraryBig, ScrollText, Send, Settings } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import {
getKeyboardShortcutText,
useGlobalShortcuts,
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { CreateMenu } from './components/create-menu/create-menu'
import { FolderTree } from './components/folder-tree/folder-tree'
import { HelpModal } from './components/help-modal/help-modal'
import { InviteModal } from './components/invite-modal/invite-modal'
import { NavSection } from './components/nav-section/nav-section'
import { SettingsModal } from './components/settings-modal/settings-modal'
import { SidebarControl } from './components/sidebar-control/sidebar-control'
import { Toolbar } from './components/toolbar/toolbar'
import { WorkspaceHeader } from './components/workspace-header/workspace-header'
import { InviteModal } from './components/workspace-selector/components/invite-modal/invite-modal'
import { WorkspaceSelector } from './components/workspace-selector/workspace-selector'
const logger = createLogger('Sidebar')
const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
// Heights for dynamic calculation (in px)
const SIDEBAR_HEIGHTS = {
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
WORKSPACE_HEADER: 48, // estimated height of workspace header
SEARCH: 48, // h-12
WORKFLOW_SELECTOR: 212, // h-[212px]
NAVIGATION: 48, // h-12 buttons
WORKSPACE_SELECTOR: 183, // accurate height: p-2(16) + h-[116px](116) + mt-2(8) + border-t(1) + pt-2(8) + h-8(32) = 181px
}
/**
* Workspace entity interface
*/
interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
export function Sidebar() {
useGlobalShortcuts()
@@ -36,41 +59,339 @@ export function Sidebar() {
createWorkflow,
isLoading: workflowsLoading,
loadWorkflows,
switchToWorkspace,
} = useWorkflowRegistry()
const { isPending: sessionLoading } = useSession()
const { data: sessionData, isPending: sessionLoading } = useSession()
const userPermissions = useUserPermissionsContext()
const isLoading = workflowsLoading || sessionLoading
// Add state to prevent multiple simultaneous workflow creations
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string
const pathname = usePathname()
const router = useRouter()
// Refs
const workflowScrollAreaRef = useRef<HTMLDivElement>(null)
const workspaceIdRef = useRef<string>(workspaceId)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const isInitializedRef = useRef<boolean>(false)
const activeWorkspaceRef = useRef<Workspace | null>(null)
// Update refs when values change
workspaceIdRef.current = workspaceId
routerRef.current = router
// Workspace selector visibility state
const [isWorkspaceSelectorVisible, setIsWorkspaceSelectorVisible] = useState(false)
// Workspace management state
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
// Update activeWorkspace ref when state changes
activeWorkspaceRef.current = activeWorkspace
// Check if we're on a workflow page
const isOnWorkflowPage = useMemo(() => {
// Pattern: /workspace/[workspaceId]/w/[workflowId]
const workflowPageRegex = /^\/workspace\/[^/]+\/w\/[^/]+$/
return workflowPageRegex.test(pathname)
}, [pathname])
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
const refreshWorkspaceList = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Only update activeWorkspace if it still exists in the fetched workspaces
// Use current state to avoid dependency on activeWorkspace
setActiveWorkspace((currentActive) => {
if (!currentActive) {
return currentActive
}
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentActive.id
)
if (matchingWorkspace) {
return matchingWorkspace
}
// Active workspace was deleted, clear it
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
return null
})
}
} catch (err) {
logger.error('Error refreshing workspace list:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, []) // Remove activeWorkspace dependency
/**
* Fetch workspaces for the current user with full validation and URL handling
*/
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Handle active workspace selection with URL validation using refs
const currentWorkspaceId = workspaceIdRef.current
const currentRouter = routerRef.current
if (currentWorkspaceId) {
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentWorkspaceId
)
if (matchingWorkspace) {
setActiveWorkspace(matchingWorkspace)
} else {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
// Fallback to first workspace if current not found - FIX: Update URL to match
if (fetchedWorkspaces.length > 0) {
const fallbackWorkspace = fetchedWorkspaces[0]
setActiveWorkspace(fallbackWorkspace)
// Update URL to match the fallback workspace
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
} else {
logger.error('No workspaces available for user')
}
}
}
}
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, []) // Remove workspaceId and router dependencies
/**
* Update workspace name both in API and local state
*/
const updateWorkspaceName = useCallback(
async (workspaceId: string, newName: string): Promise<boolean> => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
// Update local state immediately after successful API call
setActiveWorkspace((prev) => (prev ? { ...prev, name: newName.trim() } : null))
setWorkspaces((prev) =>
prev.map((workspace) =>
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
)
)
logger.info('Successfully updated workspace name to:', newName.trim())
return true
} catch (error) {
logger.error('Error updating workspace name:', error)
return false
}
},
[]
)
/**
* Switch to a different workspace
*/
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, return
if (activeWorkspaceRef.current?.id === workspace.id) {
return
}
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
routerRef.current?.push(`/workspace/${workspace.id}/w`)
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
} catch (error) {
logger.error('Error switching workspace:', error)
}
},
[switchToWorkspace] // Removed activeWorkspace and router dependencies
)
/**
* Handle create workspace
*/
const handleCreateWorkspace = useCallback(async () => {
try {
logger.info('Creating new workspace')
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Untitled workspace',
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create workspace')
}
const data = await response.json()
const newWorkspace = data.workspace
logger.info('Created new workspace:', newWorkspace)
// Refresh workspace list (no URL validation needed for creation)
await refreshWorkspaceList()
// Switch to the new workspace
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
}
}, [refreshWorkspaceList, switchWorkspace])
/**
* Confirm delete workspace
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete workspace')
}
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
// Check if we're deleting the current workspace (either active or in URL)
const isDeletingCurrentWorkspace =
workspaceIdRef.current === workspaceToDelete.id ||
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
// For current workspace deletion, use full fetchWorkspaces with URL validation
logger.info(
'Deleting current workspace - using full workspace refresh with URL validation'
)
await fetchWorkspaces()
// If we deleted the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
}
} else {
// For non-current workspace deletion, just refresh the list without URL validation
logger.info('Deleting non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error deleting workspace:', error)
} finally {
setIsDeleting(false)
}
},
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
)
/**
* Validate workspace exists before making API calls
*/
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`)
return response.ok
} catch {
return false
}
}, [])
// Load workflows for the current workspace when workspaceId changes
// This is the single source of truth for workflow loading
useEffect(() => {
if (workspaceId) {
loadWorkflows(workspaceId)
// Validate workspace exists before loading workflows
isWorkspaceValid(workspaceId).then((valid) => {
if (valid) {
loadWorkflows(workspaceId)
} else {
logger.warn(`Workspace ${workspaceId} no longer exists, triggering workspace refresh`)
fetchWorkspaces() // This will handle the redirect through the fallback logic
}
})
}
}, [workspaceId, loadWorkflows])
}, [workspaceId, loadWorkflows]) // Removed isWorkspaceValid and fetchWorkspaces dependencies
// Initialize workspace data on mount (uses full validation with URL handling)
useEffect(() => {
if (sessionData?.user?.id && !isInitializedRef.current) {
isInitializedRef.current = true
fetchWorkspaces()
}
}, [sessionData?.user?.id]) // Removed fetchWorkspaces dependency
// Scroll to active workflow when it changes
useEffect(() => {
if (workflowId && !isLoading) {
const scrollContainer = workflowScrollAreaRef.current
if (scrollContainer) {
const activeWorkflow = scrollContainer.querySelector(
`[data-workflow-id="${workflowId}"]`
) as HTMLElement
if (activeWorkflow) {
activeWorkflow.scrollIntoView({
block: 'nearest',
})
}
}
}
}, [workflowId, isLoading])
const [showSettings, setShowSettings] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const [showInviteMembers, setShowInviteMembers] = useState(false)
const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, isAnyModalOpen, setAnyModalOpen } =
useSidebarStore()
const [isHovered, setIsHovered] = useState(false)
const [explicitMouseEnter, setExplicitMouseEnter] = useState(false)
useEffect(() => {
const anyModalIsOpen = showSettings || showHelp || showInviteMembers
setAnyModalOpen(anyModalIsOpen)
if (anyModalIsOpen) {
setExplicitMouseEnter(false)
}
}, [showSettings, showHelp, showInviteMembers, setAnyModalOpen])
const [searchQuery, setSearchQuery] = useState('')
// Separate regular workflows from temporary marketplace workflows
const { regularWorkflows, tempWorkflows } = useMemo(() => {
@@ -109,11 +430,10 @@ export function Sidebar() {
}, [workflows, isLoading, workspaceId])
// Create workflow handler
const handleCreateWorkflow = async (folderId?: string) => {
// Prevent multiple simultaneous workflow creations
const handleCreateWorkflow = async (folderId?: string): Promise<string> => {
if (isCreatingWorkflow) {
logger.info('Workflow creation already in progress, ignoring request')
return
throw new Error('Workflow creation already in progress')
}
try {
@@ -122,226 +442,273 @@ export function Sidebar() {
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
router.push(`/workspace/${workspaceId}/w/${id}`)
return id
} catch (error) {
logger.error('Error creating workflow:', error)
throw error
} finally {
setIsCreatingWorkflow(false)
}
}
// Calculate sidebar visibility states
// When in hover mode, sidebar is collapsed until hovered or workspace dropdown is open
// When in expanded/collapsed mode, sidebar follows isExpanded state
const isCollapsed =
mode === 'collapsed' ||
(mode === 'hover' &&
((!isHovered && !workspaceDropdownOpen) || isAnyModalOpen || !explicitMouseEnter))
// Toggle workspace selector visibility
const toggleWorkspaceSelector = () => {
setIsWorkspaceSelectorVisible((prev) => !prev)
}
const showOverlay =
mode === 'hover' &&
((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen)
// Calculate dynamic positions for floating elements
const calculateFloatingPositions = useCallback(() => {
const { CONTAINER_PADDING, WORKSPACE_HEADER, SEARCH, WORKFLOW_SELECTOR, WORKSPACE_SELECTOR } =
SIDEBAR_HEIGHTS
// Start from top padding
let currentTop = CONTAINER_PADDING
// Add workspace header
currentTop += WORKSPACE_HEADER + SIDEBAR_GAP
// Add workspace selector if visible
if (isWorkspaceSelectorVisible) {
currentTop += WORKSPACE_SELECTOR + SIDEBAR_GAP
}
// Add search
currentTop += SEARCH + SIDEBAR_GAP
// Add workflow selector
currentTop += WORKFLOW_SELECTOR - 4
// Toolbar position (for workflow pages) - consistent with sidebar spacing
const toolbarTop = currentTop
// Navigation position (always at bottom) - 16px spacing (space-4)
const navigationBottom = 16
return {
toolbarTop,
navigationBottom,
}
}, [isWorkspaceSelectorVisible])
const { toolbarTop, navigationBottom } = calculateFloatingPositions()
// Navigation items with their respective actions
const navigationItems = [
{
id: 'settings',
icon: Settings,
onClick: () => setShowSettings(true),
tooltip: 'Settings',
},
{
id: 'help',
icon: HelpCircle,
onClick: () => setShowHelp(true),
tooltip: 'Help',
},
{
id: 'logs',
icon: ScrollText,
href: `/workspace/${workspaceId}/logs`,
tooltip: 'Logs',
shortcut: getKeyboardShortcutText('L', true, true),
active: pathname === `/workspace/${workspaceId}/logs`,
},
{
id: 'knowledge',
icon: LibraryBig,
href: `/workspace/${workspaceId}/knowledge`,
tooltip: 'Knowledge',
shortcut: getKeyboardShortcutText('K', true, true),
active: pathname === `/workspace/${workspaceId}/knowledge`,
},
{
id: 'templates',
icon: Shapes,
href: `/workspace/${workspaceId}/templates`,
tooltip: 'Templates',
shortcut: getKeyboardShortcutText('T', true, true),
active: pathname === `/workspace/${workspaceId}/templates`,
},
]
return (
<aside
className={clsx(
'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background transition-all duration-200 sm:flex',
isCollapsed ? 'w-14' : 'w-60',
showOverlay && 'shadow-lg',
mode === 'hover' && 'main-content-overlay'
)}
onMouseEnter={() => {
if (mode === 'hover' && !isAnyModalOpen) {
setIsHovered(true)
setExplicitMouseEnter(true)
}
}}
onMouseLeave={() => {
if (mode === 'hover') {
setIsHovered(false)
}
}}
>
{/* Workspace Header */}
<div className='flex-shrink-0'>
<WorkspaceHeader
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={isCollapsed}
onDropdownOpenChange={setWorkspaceDropdownOpen}
/>
</div>
<>
{/* Main Sidebar - Overlay */}
<aside className='pointer-events-none fixed inset-y-0 left-0 z-10 w-64'>
<div
className='pointer-events-none flex h-full flex-col p-4'
style={{ gap: `${SIDEBAR_GAP}px` }}
>
{/* 1. Workspace Header */}
<div className='pointer-events-auto flex-shrink-0'>
<WorkspaceHeader
onCreateWorkflow={handleCreateWorkflow}
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
onToggleWorkspaceSelector={toggleWorkspaceSelector}
activeWorkspace={activeWorkspace}
isWorkspacesLoading={isWorkspacesLoading}
updateWorkspaceName={updateWorkspaceName}
/>
</div>
{/* Scrollable Content Area */}
<div className='scrollbar-none flex flex-1 flex-col overflow-auto px-2 py-0'>
{/* Workflows Section */}
<div className='flex-shrink-0'>
<div
className={`${isCollapsed ? 'justify-center' : ''} mb-1 flex items-center justify-between px-2`}
>
<h2
className={`${isCollapsed ? 'hidden' : ''} font-medium text-muted-foreground text-xs`}
>
{isLoading ? <Skeleton className='h-4 w-16' /> : 'Workflows'}
</h2>
{!isCollapsed && !isLoading && (
<CreateMenu
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={false}
isCreatingWorkflow={isCreatingWorkflow}
{/* 2. Workspace Selector - Conditionally rendered */}
{isWorkspaceSelectorVisible && (
<div className='pointer-events-auto flex-shrink-0'>
<WorkspaceSelector
workspaces={workspaces}
activeWorkspace={activeWorkspace}
isWorkspacesLoading={isWorkspacesLoading}
onWorkspaceUpdate={refreshWorkspaceList}
onSwitchWorkspace={switchWorkspace}
onCreateWorkspace={handleCreateWorkspace}
onDeleteWorkspace={confirmDeleteWorkspace}
isDeleting={isDeleting}
/>
)}
</div>
<FolderTree
regularWorkflows={regularWorkflows}
marketplaceWorkflows={tempWorkflows}
isCollapsed={isCollapsed}
isLoading={isLoading}
onCreateWorkflow={handleCreateWorkflow}
/>
</div>
{/* Navigation Section */}
<div className='mt-6 flex-shrink-0'>
<NavSection isLoading={isLoading} itemCount={3} isCollapsed={isCollapsed}>
<NavSection.Item
icon={<ScrollText className='h-[18px] w-[18px]' />}
href={`/workspace/${workspaceId}/logs`}
label='Logs'
active={pathname === `/workspace/${workspaceId}/logs`}
isCollapsed={isCollapsed}
shortcutCommand={getKeyboardShortcutText('L', true, true)}
shortcutCommandPosition='below'
/>
<NavSection.Item
icon={<LibraryBig className='h-[18px] w-[18px]' />}
href={`/workspace/${workspaceId}/knowledge`}
label='Knowledge'
active={pathname === `/workspace/${workspaceId}/knowledge`}
isCollapsed={isCollapsed}
shortcutCommand={getKeyboardShortcutText('K', true, true)}
shortcutCommandPosition='below'
/>
<NavSection.Item
icon={<Settings className='h-[18px] w-[18px]' />}
onClick={() => setShowSettings(true)}
label='Settings'
isCollapsed={isCollapsed}
/>
</NavSection>
</div>
<div className='flex-grow' />
</div>
{/* Bottom Controls */}
{isCollapsed ? (
<div className='flex-shrink-0 px-3 pt-1 pb-3'>
<div className='flex flex-col space-y-[1px]'>
{!isDev && (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={
userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined
}
className={clsx(
'mx-auto flex h-8 w-8 items-center justify-center rounded-md font-medium text-sm',
userPermissions.canAdmin
? 'cursor-pointer text-muted-foreground hover:bg-accent/50'
: 'cursor-not-allowed text-muted-foreground/50'
)}
>
<Send className='h-[18px] w-[18px]' />
</div>
</TooltipTrigger>
<TooltipContent side='right'>
{userPermissions.canAdmin
? 'Invite Members'
: 'Admin permission required to invite members'}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => setShowHelp(true)}
className='mx-auto flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50'
>
<HelpCircle className='h-[18px] w-[18px]' />
</div>
</TooltipTrigger>
<TooltipContent side='right'>Help</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SidebarControl />
</TooltipTrigger>
<TooltipContent side='right'>Toggle sidebar</TooltipContent>
</Tooltip>
</div>
</div>
) : (
<>
{!isDev && (
<div className='flex-shrink-0 px-3 pt-1'>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={
userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined
}
className={clsx(
'flex items-center rounded-md px-2 py-1.5 font-medium text-sm',
userPermissions.canAdmin
? 'cursor-pointer text-muted-foreground hover:bg-accent/50'
: 'cursor-not-allowed text-muted-foreground/50'
)}
>
<Send className='h-[18px] w-[18px]' />
<span className='ml-2'>Invite members</span>
</div>
</TooltipTrigger>
<TooltipContent side='top'>
{userPermissions.canAdmin
? 'Invite new members to this workspace'
: 'Admin permission required to invite members'}
</TooltipContent>
</Tooltip>
</div>
)}
<div className='flex-shrink-0 px-3 pt-1 pb-3'>
<div className='flex justify-between'>
<Tooltip>
<TooltipTrigger asChild>
<SidebarControl />
</TooltipTrigger>
<TooltipContent side='top'>Toggle sidebar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => setShowHelp(true)}
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50'
>
<HelpCircle className='h-[18px] w-[18px]' />
<span className='sr-only'>Help</span>
</div>
</TooltipTrigger>
<TooltipContent side='top'>Help, contact</TooltipContent>
</Tooltip>
{/* 3. Search */}
{/* <div className='pointer-events-auto flex-shrink-0'>
<div className='flex h-12 items-center gap-2 rounded-[14px] border bg-card pr-2 pl-3 shadow-xs'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search anything'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='h-8 flex-1 border-0 bg-transparent px-0 font-normal text-base text-muted-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<kbd className='flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]'>
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
<span className='text-lg'>⌘</span>
<span className='text-xs'>K</span>
</span>
</kbd>
</div>
</div> */}
{/* 4. Workflow Selector */}
<div className='pointer-events-auto relative h-[272px] flex-shrink-0 rounded-[14px] border bg-card shadow-xs'>
<div className='px-2'>
<ScrollArea ref={workflowScrollAreaRef} className='h-[270px]' hideScrollbar={true}>
<FolderTree
regularWorkflows={regularWorkflows}
marketplaceWorkflows={tempWorkflows}
isCollapsed={false}
isLoading={isLoading}
onCreateWorkflow={handleCreateWorkflow}
/>
</ScrollArea>
</div>
{!isLoading && (
<div className='absolute top-2 right-2'>
<CreateMenu
onCreateWorkflow={handleCreateWorkflow}
isCollapsed={false}
isCreatingWorkflow={isCreatingWorkflow}
/>
</div>
)}
</div>
</>
</div>
</aside>
{/* Floating Toolbar - Only on workflow pages */}
{isOnWorkflowPage && (
<div
className='pointer-events-auto fixed left-4 z-50 w-56 rounded-[14px] border bg-card shadow-xs'
style={{
top: `${toolbarTop}px`,
bottom: `${navigationBottom + 42 + 12}px`, // Navigation height + gap
}}
>
<Toolbar
userPermissions={userPermissions}
isWorkspaceSelectorVisible={isWorkspaceSelectorVisible}
/>
</div>
)}
{/* Floating Navigation - Always visible */}
<div
className='pointer-events-auto fixed left-4 z-50 w-56'
style={{ bottom: `${navigationBottom}px` }}
>
<div className='flex items-center gap-1'>
{navigationItems.map((item) => (
<NavigationItem key={item.id} item={item} />
))}
</div>
</div>
{/* Modals */}
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
{!isDev && <InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />}
</aside>
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
</>
)
}
// Navigation Item Component
interface NavigationItemProps {
item: {
id: string
icon: React.ElementType
onClick?: () => void
href?: string
tooltip: string
shortcut?: string
active?: boolean
disabled?: boolean
}
}
const NavigationItem = ({ item }: NavigationItemProps) => {
// Settings and help buttons get gray hover, others get purple hover
const isGrayHover = item.id === 'settings' || item.id === 'help'
const content = item.disabled ? (
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<item.icon className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={item.onClick}
className={cn(
'h-[42px] w-[42px] rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',
!isGrayHover && 'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
item.active && 'border-[#701FFC] bg-[#701FFC] text-white'
)}
>
<item.icon className='h-4 w-4' />
</Button>
)
if (item.href && !item.disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a href={item.href} className='inline-block'>
{content}
</a>
</TooltipTrigger>
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
<span>{item.tooltip}</span>
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side='top' className='flex flex-col items-center gap-1'>
<span>{item.tooltip}</span>
{item.shortcut && <span className='text-muted-foreground text-xs'>{item.shortcut}</span>}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -58,53 +58,40 @@ export function WorkflowPreview({
defaultZoom,
onNodeClick,
}: WorkflowPreviewProps) {
// Handle migrated logs that don't have complete workflow state
if (!workflowState || !workflowState.blocks || !workflowState.edges) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
const blocksStructure = useMemo(
() => ({
// Check if the workflow state is valid
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}),
[workflowState.blocks]
)
}
}, [workflowState.blocks, isValidWorkflowState])
const loopsStructure = useMemo(
() => ({
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}),
[workflowState.loops]
)
}
}, [workflowState.loops, isValidWorkflowState])
const parallelsStructure = useMemo(
() => ({
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}),
[workflowState.parallels]
)
}
}, [workflowState.parallels, isValidWorkflowState])
const edgesStructure = useMemo(
() => ({
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}),
[workflowState.edges]
)
}
}, [workflowState.edges, isValidWorkflowState])
const calculateAbsolutePosition = (
block: any,
@@ -129,6 +116,8 @@ export function WorkflowPreview({
}
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
@@ -236,9 +225,18 @@ export function WorkflowPreview({
})
return nodeArray
}, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks])
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
showSubBlocks,
workflowState.blocks,
isValidWorkflowState,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
return (workflowState.edges || []).map((edge) => ({
id: edge.id,
source: edge.source,
@@ -247,7 +245,24 @@ export function WorkflowPreview({
targetHandle: edge.targetHandle,
type: 'workflowEdge',
}))
}, [edgesStructure, workflowState.edges])
}, [edgesStructure, workflowState.edges, isValidWorkflowState])
// Handle migrated logs that don't have complete workflow state
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
return (
<ReactFlowProvider>

View File

@@ -0,0 +1,66 @@
'use client'
import { forwardRef, useMemo, useState } from 'react'
import { HexColorPicker } from 'react-colorful'
import type { ButtonProps } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useForwardedRef } from '@/lib/use-forwarded-ref'
import { cn } from '@/lib/utils'
interface ColorPickerProps {
value: string
onChange: (value: string) => void
onBlur?: () => void
}
const ColorPicker = forwardRef<
HTMLInputElement,
Omit<ButtonProps, 'value' | 'onChange' | 'onBlur'> & ColorPickerProps & ButtonProps
>(({ disabled, value, onChange, onBlur, name, className, size, ...props }, forwardedRef) => {
const ref = useForwardedRef(forwardedRef)
const [open, setOpen] = useState(false)
const parsedValue = useMemo(() => {
return value || '#FFFFFF'
}, [value])
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
<Button
{...props}
className={cn('block', className)}
name={name}
onClick={() => {
setOpen(true)
}}
size={size}
style={{
backgroundColor: parsedValue,
}}
variant='outline'
>
<div />
</Button>
</PopoverTrigger>
<PopoverContent className='w-full'>
<div className='space-y-3'>
<HexColorPicker color={parsedValue} onChange={onChange} />
<Input
maxLength={7}
onChange={(e) => {
onChange(e?.currentTarget?.value)
}}
ref={ref}
value={parsedValue}
/>
</div>
</PopoverContent>
</Popover>
)
})
ColorPicker.displayName = 'ColorPicker'
export { ColorPicker }

View File

@@ -6,8 +6,10 @@ import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
hideScrollbar?: boolean
}
>(({ className, children, hideScrollbar = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
@@ -16,7 +18,7 @@ const ScrollArea = React.forwardRef<
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar hidden={hideScrollbar} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
@@ -24,8 +26,10 @@ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {
hidden?: boolean
}
>(({ className, orientation = 'vertical', hidden = false, ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
@@ -33,11 +37,14 @@ const ScrollBar = React.forwardRef<
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
hidden && 'pointer-events-none w-0 border-0 p-0 opacity-0',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-border' />
<ScrollAreaPrimitive.ScrollAreaThumb
className={cn('relative flex-1 rounded-full bg-border', hidden && 'hidden')}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName

View File

@@ -258,12 +258,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}
// Create variable tags
const variableTags = workflowVariables.map(
// Create variable tags - filter out variables with empty names
const validVariables = workflowVariables.filter(
(variable: Variable) => variable.name.trim() !== ''
)
const variableTags = validVariables.map(
(variable: Variable) => `variable.${variable.name.replace(/\s+/g, '')}`
)
const variableInfoMap = workflowVariables.reduce(
const variableInfoMap = validVariables.reduce(
(acc, variable) => {
const tagName = `variable.${variable.name.replace(/\s+/g, '')}`
acc[tagName] = {

View File

@@ -1036,3 +1036,83 @@ export const copilotChats = pgTable(
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),
})
)
export const templates = pgTable(
'templates',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
author: text('author').notNull(),
views: integer('views').notNull().default(0),
stars: integer('stars').notNull().default(0),
color: text('color').notNull().default('#3972F6'),
icon: text('icon').notNull().default('FileText'), // Lucide icon name as string
category: text('category').notNull(),
state: jsonb('state').notNull(), // Using jsonb for better performance
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId),
userIdIdx: index('templates_user_id_idx').on(table.userId),
categoryIdx: index('templates_category_idx').on(table.category),
// Sorting indexes for popular/trending templates
viewsIdx: index('templates_views_idx').on(table.views),
starsIdx: index('templates_stars_idx').on(table.stars),
// Composite indexes for common queries
categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views),
categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars),
userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category),
// Temporal indexes
createdAtIdx: index('templates_created_at_idx').on(table.createdAt),
updatedAtIdx: index('templates_updated_at_idx').on(table.updatedAt),
})
)
export const templateStars = pgTable(
'template_stars',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
templateId: text('template_id')
.notNull()
.references(() => templates.id, { onDelete: 'cascade' }),
starredAt: timestamp('starred_at').notNull().defaultNow(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('template_stars_user_id_idx').on(table.userId),
templateIdIdx: index('template_stars_template_id_idx').on(table.templateId),
// Composite indexes for common queries
userTemplateIdx: index('template_stars_user_template_idx').on(table.userId, table.templateId),
templateUserIdx: index('template_stars_template_user_idx').on(table.templateId, table.userId),
// Temporal indexes for analytics
starredAtIdx: index('template_stars_starred_at_idx').on(table.starredAt),
templateStarredAtIdx: index('template_stars_template_starred_at_idx').on(
table.templateId,
table.starredAt
),
// Uniqueness constraint - prevent duplicate stars
uniqueUserTemplateConstraint: uniqueIndex('template_stars_user_template_unique').on(
table.userId,
table.templateId
),
})
)

View File

@@ -1347,6 +1347,7 @@ export class Executor {
// For streaming blocks, we'll add the console entry after stream processing
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
addConsole({
input: blockLog.input,
output: blockLog.output,
success: true,
durationMs: blockLog.durationMs,
@@ -1416,6 +1417,7 @@ export class Executor {
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
addConsole({
input: blockLog.input,
output: blockLog.output,
success: true,
durationMs: blockLog.durationMs,
@@ -1483,6 +1485,7 @@ export class Executor {
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== 'loop' && block.metadata?.id !== 'parallel') {
addConsole({
input: blockLog.input,
output: {},
success: false,
error:

View File

@@ -201,7 +201,7 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
currentUsage: usageData.currentUsage,
limit: usageData.limit,
message: usageData.isExceeded
? `Usage limit exceeded: ${usageData.currentUsage.toFixed(2)}$ used of ${usageData.limit}$ limit. Please upgrade your plan to continue.`
? `Usage limit exceeded: ${usageData.currentUsage?.toFixed(2) || 0}$ used of ${usageData.limit?.toFixed(2) || 0}$ limit. Please upgrade your plan to continue.`
: undefined,
}
} catch (error) {

View File

@@ -0,0 +1,25 @@
import { type MutableRefObject, useEffect, useRef } from 'react'
/**
* A hook that handles forwarded refs and returns a mutable ref object
* Useful for components that need both a forwarded ref and a local ref
* @param forwardedRef The forwarded ref from React.forwardRef
* @returns A mutable ref object that can be used locally
*/
export function useForwardedRef<T>(
forwardedRef: React.ForwardedRef<T>
): MutableRefObject<T | null> {
const innerRef = useRef<T | null>(null)
useEffect(() => {
if (!forwardedRef) return
if (typeof forwardedRef === 'function') {
forwardedRef(innerRef.current)
} else {
forwardedRef.current = innerRef.current
}
}, [forwardedRef])
return innerRef
}

View File

@@ -0,0 +1,29 @@
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Build workflow state in the same format as the deployment process
* This utility ensures consistent state format between template creation and deployment
*/
export function buildWorkflowStateForTemplate(workflowId: string) {
const workflowStore = useWorkflowStore.getState()
const { activeWorkflowId } = useWorkflowRegistry.getState()
// Get current workflow state
const { blocks, edges } = workflowStore
// Generate loops and parallels in the same format as deployment
const loops = workflowStore.generateLoopBlocks()
const parallels = workflowStore.generateParallelBlocks()
// Build the state object in the same format as deployment
const state = {
blocks,
edges,
loops,
parallels,
lastSaved: Date.now(),
}
return state
}

View File

@@ -34,6 +34,7 @@
"@browserbasehq/stagehand": "^2.0.0",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
"@hookform/resolvers": "^4.1.3",
"@next/font": "14.2.15",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-collector": "^0.25.0",
"@opentelemetry/exporter-jaeger": "^2.0.0",
@@ -82,6 +83,7 @@
"drizzle-orm": "^0.41.0",
"framer-motion": "^12.5.0",
"freestyle-sandboxes": "^0.0.38",
"geist": "1.4.2",
"fuse.js": "7.1.0",
"groq-sdk": "^0.15.0",
"input-otp": "^1.4.2",
@@ -100,6 +102,7 @@
"postgres": "^3.4.5",
"prismjs": "^1.30.0",
"react": "19.1.0",
"react-colorful": "5.6.1",
"react-day-picker": "8.10.1",
"react-dom": "19.1.0",
"react-google-drive-picker": "^1.2.2",

View File

@@ -1,23 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useSession } from '@/lib/auth-client'
import { SocketProvider } from '@/contexts/socket-context'
interface WorkspaceProviderProps {
children: ReactNode
}
export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
const session = useSession()
const user = session.data?.user
? {
id: session.data.user.id,
name: session.data.user.name,
email: session.data.user.email,
}
: undefined
return <SocketProvider user={user}>{children}</SocketProvider>
}

Some files were not shown because too many files have changed in this diff Show More