mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 01:47:59 -05:00
Compare commits
5 Commits
feat/reord
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca7f43e35 | ||
|
|
2423255944 | ||
|
|
3aee702340 | ||
|
|
7c8ca1c14a | ||
|
|
3f1dccd6aa |
@@ -52,6 +52,9 @@ const ChatMessageSchema = z.object({
|
||||
'gpt-5.1-high',
|
||||
'gpt-5-codex',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2-pro',
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'o3',
|
||||
|
||||
@@ -15,11 +15,14 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-5-medium': false,
|
||||
'gpt-5-high': false,
|
||||
'gpt-5.1-fast': false,
|
||||
'gpt-5.1': true,
|
||||
'gpt-5.1-medium': true,
|
||||
'gpt-5.1': false,
|
||||
'gpt-5.1-medium': false,
|
||||
'gpt-5.1-high': false,
|
||||
'gpt-5-codex': false,
|
||||
'gpt-5.1-codex': true,
|
||||
'gpt-5.1-codex': false,
|
||||
'gpt-5.2': false,
|
||||
'gpt-5.2-codex': true,
|
||||
'gpt-5.2-pro': true,
|
||||
o3: true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
|
||||
@@ -14,7 +14,6 @@ const updateFolderSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
isExpanded: z.boolean().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
// PUT - Update a folder
|
||||
@@ -39,7 +38,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
|
||||
const { name, color, isExpanded, parentId } = validationResult.data
|
||||
|
||||
// Verify the folder exists
|
||||
const existingFolder = await db
|
||||
@@ -82,12 +81,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() }
|
||||
// Update the folder
|
||||
const updates: any = { updatedAt: new Date() }
|
||||
if (name !== undefined) updates.name = name.trim()
|
||||
if (color !== undefined) updates.color = color
|
||||
if (isExpanded !== undefined) updates.isExpanded = isExpanded
|
||||
if (parentId !== undefined) updates.parentId = parentId || null
|
||||
if (sortOrder !== undefined) updates.sortOrder = sortOrder
|
||||
|
||||
const [updatedFolder] = await db
|
||||
.update(workflowFolder)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FolderReorderAPI')
|
||||
|
||||
const ReorderSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
updates: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
parentId: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId, updates } = ReorderSchema.parse(body)
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const folderIds = updates.map((u) => u.id)
|
||||
const existingFolders = await db
|
||||
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
|
||||
.from(workflowFolder)
|
||||
.where(inArray(workflowFolder.id, folderIds))
|
||||
|
||||
const validIds = new Set(
|
||||
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
|
||||
)
|
||||
|
||||
const validUpdates = updates.filter((u) => validIds.has(u.id))
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
sortOrder: update.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
if (update.parentId !== undefined) {
|
||||
updateData.parentId = update.parentId
|
||||
}
|
||||
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid folder reorder data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering folders`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
|
||||
const { name, workspaceId, parentId, color } = body
|
||||
|
||||
if (!name || !workspaceId) {
|
||||
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
|
||||
@@ -81,26 +81,25 @@ export async function POST(request: NextRequest) {
|
||||
// Generate a new ID
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Use transaction to ensure sortOrder consistency
|
||||
const newFolder = await db.transaction(async (tx) => {
|
||||
let sortOrder: number
|
||||
if (providedSortOrder !== undefined) {
|
||||
sortOrder = providedSortOrder
|
||||
} else {
|
||||
const existingFolders = await tx
|
||||
.select({ sortOrder: workflowFolder.sortOrder })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
||||
)
|
||||
// Get the next sort order for the parent (or root level)
|
||||
// Consider all folders in the workspace, not just those created by current user
|
||||
const existingFolders = await tx
|
||||
.select({ sortOrder: workflowFolder.sortOrder })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
|
||||
)
|
||||
.orderBy(desc(workflowFolder.sortOrder))
|
||||
.limit(1)
|
||||
)
|
||||
.orderBy(desc(workflowFolder.sortOrder))
|
||||
.limit(1)
|
||||
|
||||
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
|
||||
}
|
||||
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
|
||||
|
||||
// Insert the new folder within the same transaction
|
||||
const [folder] = await tx
|
||||
.insert(workflowFolder)
|
||||
.values({
|
||||
@@ -110,7 +109,7 @@ export async function POST(request: NextRequest) {
|
||||
workspaceId,
|
||||
parentId: parentId || null,
|
||||
color: color || '#6B7280',
|
||||
sortOrder,
|
||||
sortOrder: nextSortOrder,
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ const UpdateWorkflowSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -439,12 +438,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
// Build update object
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
if (updates.name !== undefined) updateData.name = updates.name
|
||||
if (updates.description !== undefined) updateData.description = updates.description
|
||||
if (updates.color !== undefined) updateData.color = updates.color
|
||||
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
|
||||
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
|
||||
|
||||
// Update the workflow
|
||||
const [updatedWorkflow] = await db
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowReorderAPI')
|
||||
|
||||
const ReorderSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
updates: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
folderId: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId, updates } = ReorderSchema.parse(body)
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workflowIds = updates.map((u) => u.id)
|
||||
const existingWorkflows = await db
|
||||
.select({ id: workflow.id, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(inArray(workflow.id, workflowIds))
|
||||
|
||||
const validIds = new Set(
|
||||
existingWorkflows.filter((w) => w.workspaceId === workspaceId).map((w) => w.id)
|
||||
)
|
||||
|
||||
const validUpdates = updates.filter((u) => validIds.has(u.id))
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid workflows to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
sortOrder: update.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
if (update.folderId !== undefined) {
|
||||
updateData.folderId = update.folderId
|
||||
}
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, update.id))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reordered ${validUpdates.length} workflows in workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid reorder data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering workflows`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, max } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -17,7 +17,6 @@ const CreateWorkflowSchema = z.object({
|
||||
color: z.string().optional().default('#3972F6'),
|
||||
workspaceId: z.string().optional(),
|
||||
folderId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
@@ -90,14 +89,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder: providedSortOrder,
|
||||
} = CreateWorkflowSchema.parse(body)
|
||||
const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body)
|
||||
|
||||
if (workspaceId) {
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
@@ -135,28 +127,11 @@ export async function POST(req: NextRequest) {
|
||||
// Silently fail
|
||||
})
|
||||
|
||||
let sortOrder: number
|
||||
if (providedSortOrder !== undefined) {
|
||||
sortOrder = providedSortOrder
|
||||
} else {
|
||||
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
|
||||
const [maxResult] = await db
|
||||
.select({ maxOrder: max(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(
|
||||
workspaceId
|
||||
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
|
||||
: and(eq(workflow.userId, session.user.id), folderCondition)
|
||||
)
|
||||
sortOrder = (maxResult?.maxOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
workspaceId: workspaceId || null,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
@@ -177,7 +152,6 @@ export async function POST(req: NextRequest) {
|
||||
color,
|
||||
workspaceId,
|
||||
folderId,
|
||||
sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ const logger = createLogger('Workspaces')
|
||||
|
||||
const createWorkspaceSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required'),
|
||||
skipDefaultWorkflow: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
// Get all workspaces for the current user
|
||||
@@ -64,9 +63,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { name, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json())
|
||||
const { name } = createWorkspaceSchema.parse(await req.json())
|
||||
|
||||
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
|
||||
const newWorkspace = await createWorkspace(session.user.id, name)
|
||||
|
||||
return NextResponse.json({ workspace: newWorkspace })
|
||||
} catch (error) {
|
||||
@@ -81,7 +80,7 @@ async function createDefaultWorkspace(userId: string, userName?: string | null)
|
||||
return createWorkspace(userId, workspaceName)
|
||||
}
|
||||
|
||||
async function createWorkspace(userId: string, name: string, skipDefaultWorkflow = false) {
|
||||
async function createWorkspace(userId: string, name: string) {
|
||||
const workspaceId = crypto.randomUUID()
|
||||
const workflowId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
@@ -98,6 +97,7 @@ async function createWorkspace(userId: string, name: string, skipDefaultWorkflow
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Create admin permissions for the workspace owner
|
||||
await tx.insert(permissions).values({
|
||||
id: crypto.randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
@@ -108,41 +108,37 @@ async function createWorkspace(userId: string, name: string, skipDefaultWorkflow
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!skipDefaultWorkflow) {
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
}
|
||||
// Create initial workflow for the workspace (empty canvas)
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
name: 'default-agent',
|
||||
description: 'Your first workflow - start building here!',
|
||||
color: '#3972F6',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
logger.info(
|
||||
skipDefaultWorkflow
|
||||
? `Created workspace ${workspaceId} for user ${userId}`
|
||||
: `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
`Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
|
||||
)
|
||||
})
|
||||
|
||||
if (!skipDefaultWorkflow) {
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
|
||||
const { workflowState } = buildDefaultWorkflowArtifacts()
|
||||
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
|
||||
|
||||
if (!seedResult.success) {
|
||||
throw new Error(seedResult.error || 'Failed to seed default workflow state')
|
||||
}
|
||||
if (!seedResult.success) {
|
||||
throw new Error(seedResult.error || 'Failed to seed default workflow state')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create workspace ${workspaceId}:`, error)
|
||||
logger.error(`Failed to create workspace ${workspaceId} with initial workflow:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -2,29 +2,9 @@ import { memo, useEffect, useRef, useState } from 'react'
|
||||
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||
|
||||
/**
|
||||
* Minimum delay between characters (fast catch-up mode)
|
||||
* Character animation delay in milliseconds
|
||||
*/
|
||||
const MIN_DELAY = 1
|
||||
|
||||
/**
|
||||
* Maximum delay between characters (when waiting for content)
|
||||
*/
|
||||
const MAX_DELAY = 12
|
||||
|
||||
/**
|
||||
* Default delay when streaming normally
|
||||
*/
|
||||
const DEFAULT_DELAY = 4
|
||||
|
||||
/**
|
||||
* How far behind (in characters) before we speed up
|
||||
*/
|
||||
const CATCH_UP_THRESHOLD = 20
|
||||
|
||||
/**
|
||||
* How close to content before we slow down
|
||||
*/
|
||||
const SLOW_DOWN_THRESHOLD = 5
|
||||
const CHARACTER_DELAY = 3
|
||||
|
||||
/**
|
||||
* StreamingIndicator shows animated dots during message streaming
|
||||
@@ -54,50 +34,21 @@ interface SmoothStreamingTextProps {
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates adaptive delay based on how far behind animation is from actual content
|
||||
*
|
||||
* @param displayedLength - Current displayed content length
|
||||
* @param totalLength - Total available content length
|
||||
* @returns Delay in milliseconds
|
||||
*/
|
||||
function calculateAdaptiveDelay(displayedLength: number, totalLength: number): number {
|
||||
const charsRemaining = totalLength - displayedLength
|
||||
|
||||
if (charsRemaining > CATCH_UP_THRESHOLD) {
|
||||
// Far behind - speed up to catch up
|
||||
// Scale from MIN_DELAY to DEFAULT_DELAY based on how far behind
|
||||
const catchUpFactor = Math.min(1, (charsRemaining - CATCH_UP_THRESHOLD) / 50)
|
||||
return MIN_DELAY + (DEFAULT_DELAY - MIN_DELAY) * (1 - catchUpFactor)
|
||||
}
|
||||
|
||||
if (charsRemaining <= SLOW_DOWN_THRESHOLD) {
|
||||
// Close to content edge - slow down to feel natural
|
||||
// The closer we are, the slower we go (up to MAX_DELAY)
|
||||
const slowFactor = 1 - charsRemaining / SLOW_DOWN_THRESHOLD
|
||||
return DEFAULT_DELAY + (MAX_DELAY - DEFAULT_DELAY) * slowFactor
|
||||
}
|
||||
|
||||
// Normal streaming speed
|
||||
return DEFAULT_DELAY
|
||||
}
|
||||
|
||||
/**
|
||||
* SmoothStreamingText component displays text with character-by-character animation
|
||||
* Creates a smooth streaming effect for AI responses with adaptive speed
|
||||
*
|
||||
* Uses adaptive pacing: speeds up when catching up, slows down near content edge
|
||||
* Creates a smooth streaming effect for AI responses
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Streaming text with smooth animation
|
||||
*/
|
||||
export const SmoothStreamingText = memo(
|
||||
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
||||
const [displayedContent, setDisplayedContent] = useState('')
|
||||
// Initialize with full content when not streaming to avoid flash on page load
|
||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||
const contentRef = useRef(content)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const indexRef = useRef(0)
|
||||
const lastFrameTimeRef = useRef<number>(0)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// Initialize index based on streaming state
|
||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||
const isAnimatingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,42 +61,33 @@ export const SmoothStreamingText = memo(
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
if (indexRef.current < content.length && !isAnimatingRef.current) {
|
||||
isAnimatingRef.current = true
|
||||
lastFrameTimeRef.current = performance.now()
|
||||
|
||||
const animateText = (timestamp: number) => {
|
||||
if (indexRef.current < content.length) {
|
||||
const animateText = () => {
|
||||
const currentContent = contentRef.current
|
||||
const currentIndex = indexRef.current
|
||||
const elapsed = timestamp - lastFrameTimeRef.current
|
||||
|
||||
// Calculate adaptive delay based on how far behind we are
|
||||
const delay = calculateAdaptiveDelay(currentIndex, currentContent.length)
|
||||
|
||||
if (elapsed >= delay) {
|
||||
if (currentIndex < currentContent.length) {
|
||||
const newDisplayed = currentContent.slice(0, currentIndex + 1)
|
||||
setDisplayedContent(newDisplayed)
|
||||
indexRef.current = currentIndex + 1
|
||||
lastFrameTimeRef.current = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if (indexRef.current < currentContent.length) {
|
||||
rafRef.current = requestAnimationFrame(animateText)
|
||||
if (currentIndex < currentContent.length) {
|
||||
const newDisplayed = currentContent.slice(0, currentIndex + 1)
|
||||
setDisplayedContent(newDisplayed)
|
||||
indexRef.current = currentIndex + 1
|
||||
timeoutRef.current = setTimeout(animateText, CHARACTER_DELAY)
|
||||
} else {
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(animateText)
|
||||
} else if (indexRef.current < content.length && isAnimatingRef.current) {
|
||||
// Animation already running, it will pick up new content automatically
|
||||
if (!isAnimatingRef.current) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
isAnimatingRef.current = true
|
||||
animateText()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming ended - show full content immediately
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
setDisplayedContent(content)
|
||||
indexRef.current = content.length
|
||||
@@ -153,8 +95,8 @@ export const SmoothStreamingText = memo(
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
|
||||
@@ -46,12 +46,14 @@ interface SmoothThinkingTextProps {
|
||||
*/
|
||||
const SmoothThinkingText = memo(
|
||||
({ content, isStreaming }: SmoothThinkingTextProps) => {
|
||||
const [displayedContent, setDisplayedContent] = useState('')
|
||||
// Initialize with full content when not streaming to avoid flash on page load
|
||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||
const [showGradient, setShowGradient] = useState(false)
|
||||
const contentRef = useRef(content)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const indexRef = useRef(0)
|
||||
// Initialize index based on streaming state
|
||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||
const lastFrameTimeRef = useRef<number>(0)
|
||||
const isAnimatingRef = useRef(false)
|
||||
|
||||
|
||||
@@ -1952,7 +1952,12 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
}, [params])
|
||||
|
||||
// Skip rendering some internal tools
|
||||
if (toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress') return null
|
||||
if (
|
||||
toolCall.name === 'checkoff_todo' ||
|
||||
toolCall.name === 'mark_todo_in_progress' ||
|
||||
toolCall.name === 'tool_search_tool_regex'
|
||||
)
|
||||
return null
|
||||
|
||||
// Special rendering for subagent tools - show as thinking text with tool calls at top level
|
||||
const SUBAGENT_TOOLS = [
|
||||
|
||||
@@ -32,13 +32,6 @@ function getModelIconComponent(modelValue: string) {
|
||||
return <IconComponent className='h-3.5 w-3.5' />
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a model should display the MAX badge
|
||||
*/
|
||||
function isMaxModel(modelValue: string): boolean {
|
||||
return modelValue === 'claude-4.5-sonnet' || modelValue === 'claude-4.5-opus'
|
||||
}
|
||||
|
||||
/**
|
||||
* Model selector dropdown for choosing AI model.
|
||||
* Displays model icon and label.
|
||||
@@ -139,11 +132,6 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model
|
||||
>
|
||||
{getModelIconComponent(option.value)}
|
||||
<span>{option.label}</span>
|
||||
{isMaxModel(option.value) && (
|
||||
<Badge size='sm' className='ml-auto'>
|
||||
MAX
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverScrollArea>
|
||||
|
||||
@@ -238,8 +238,8 @@ export const MODEL_OPTIONS = [
|
||||
{ value: 'claude-4.5-opus', label: 'Claude 4.5 Opus' },
|
||||
{ value: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' },
|
||||
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT 5.1 Codex' },
|
||||
{ value: 'gpt-5.1-medium', label: 'GPT 5.1 Medium' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT 5.2 Codex' },
|
||||
{ value: 'gpt-5.2-pro', label: 'GPT 5.2 Pro' },
|
||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ interface FolderItemProps {
|
||||
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
|
||||
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
|
||||
}
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,13 +46,7 @@ interface FolderItemProps {
|
||||
* @param props - Component props
|
||||
* @returns Folder item with drag and expand support
|
||||
*/
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
hoverHandlers,
|
||||
onDragStart: onDragStartProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: FolderItemProps) {
|
||||
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -143,6 +135,11 @@ export function FolderItem({
|
||||
}
|
||||
}, [createFolderMutation, workspaceId, folder.id, expandFolder])
|
||||
|
||||
/**
|
||||
* Drag start handler - sets folder data for drag operation
|
||||
*
|
||||
* @param e - React drag event
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditing) {
|
||||
@@ -152,25 +149,14 @@ export function FolderItem({
|
||||
|
||||
e.dataTransfer.setData('folder-id', folder.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[folder.id, onDragStartProp]
|
||||
[folder.id]
|
||||
)
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
shouldPreventClickRef,
|
||||
handleDragStart,
|
||||
handleDragEnd: handleDragEndBase,
|
||||
} = useItemDrag({
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position,
|
||||
|
||||
@@ -29,8 +29,6 @@ interface WorkflowItemProps {
|
||||
active: boolean
|
||||
level: number
|
||||
onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,14 +38,7 @@ interface WorkflowItemProps {
|
||||
* @param props - Component props
|
||||
* @returns Workflow item with drag and selection support
|
||||
*/
|
||||
export function WorkflowItem({
|
||||
workflow,
|
||||
active,
|
||||
level,
|
||||
onWorkflowClick,
|
||||
onDragStart: onDragStartProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: WorkflowItemProps) {
|
||||
export function WorkflowItem({ workflow, active, level, onWorkflowClick }: WorkflowItemProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { selectedWorkflows } = useFolderStore()
|
||||
@@ -113,7 +104,30 @@ export function WorkflowItem({
|
||||
[workflow.id, updateWorkflow]
|
||||
)
|
||||
|
||||
const isEditingRef = useRef(false)
|
||||
/**
|
||||
* Drag start handler - handles workflow dragging with multi-selection support
|
||||
*
|
||||
* @param e - React drag event
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const workflowIds =
|
||||
isSelected && selectedWorkflows.size > 1 ? Array.from(selectedWorkflows) : [workflow.id]
|
||||
|
||||
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
},
|
||||
[isSelected, selectedWorkflows, workflow.id]
|
||||
)
|
||||
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -218,43 +232,6 @@ export function WorkflowItem({
|
||||
itemId: workflow.id,
|
||||
})
|
||||
|
||||
isEditingRef.current = isEditing
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (isEditingRef.current) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const currentSelection = useFolderStore.getState().selectedWorkflows
|
||||
const isCurrentlySelected = currentSelection.has(workflow.id)
|
||||
const workflowIds =
|
||||
isCurrentlySelected && currentSelection.size > 1
|
||||
? Array.from(currentSelection)
|
||||
: [workflow.id]
|
||||
|
||||
e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
onDragStartProp?.()
|
||||
},
|
||||
[workflow.id, onDragStartProp]
|
||||
)
|
||||
|
||||
const {
|
||||
isDragging,
|
||||
shouldPreventClickRef,
|
||||
handleDragStart,
|
||||
handleDragEnd: handleDragEndBase,
|
||||
} = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
handleDragEndBase()
|
||||
onDragEndProp?.()
|
||||
}, [handleDragEndBase, onDragEndProp])
|
||||
|
||||
/**
|
||||
* Handle double-click on workflow name to enter rename mode
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
|
||||
@@ -14,6 +14,9 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/**
|
||||
* Constants for tree layout and styling
|
||||
*/
|
||||
const TREE_SPACING = {
|
||||
INDENT_PER_LEVEL: 20,
|
||||
} as const
|
||||
@@ -26,24 +29,12 @@ interface WorkflowListProps {
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const DropIndicatorLine = memo(function DropIndicatorLine({
|
||||
show,
|
||||
level = 0,
|
||||
}: {
|
||||
show: boolean
|
||||
level?: number
|
||||
}) {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-none absolute right-0 left-0 z-20 flex items-center'
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
>
|
||||
<div className='h-[2px] flex-1 rounded-full bg-[#33b4ff]/70' />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Workflow list with folders and drag-drop support
|
||||
*/
|
||||
export function WorkflowList({
|
||||
regularWorkflows,
|
||||
isLoading = false,
|
||||
@@ -57,21 +48,20 @@ export function WorkflowList({
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
||||
|
||||
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
|
||||
|
||||
const {
|
||||
dropIndicator,
|
||||
dropTargetId,
|
||||
isDragging,
|
||||
setScrollContainer,
|
||||
createWorkflowDragHandlers,
|
||||
createFolderDragHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
createRootDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
createItemDragHandlers,
|
||||
createRootDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
} = useDragDrop()
|
||||
|
||||
// Set scroll container when ref changes
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
setScrollContainer(scrollContainerRef.current)
|
||||
@@ -86,22 +76,23 @@ export function WorkflowList({
|
||||
return activeWorkflow?.folderId || null
|
||||
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
|
||||
|
||||
const workflowsByFolder = useMemo(() => {
|
||||
const grouped = regularWorkflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
)
|
||||
for (const folderId of Object.keys(grouped)) {
|
||||
grouped[folderId].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
return grouped
|
||||
}, [regularWorkflows])
|
||||
const workflowsByFolder = useMemo(
|
||||
() =>
|
||||
regularWorkflows.reduce(
|
||||
(acc, workflow) => {
|
||||
const folderId = workflow.folderId || 'root'
|
||||
if (!acc[folderId]) acc[folderId] = []
|
||||
acc[folderId].push(workflow)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, WorkflowMetadata[]>
|
||||
),
|
||||
[regularWorkflows]
|
||||
)
|
||||
|
||||
/**
|
||||
* Build a flat list of all workflow IDs in display order for range selection
|
||||
*/
|
||||
const orderedWorkflowIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
|
||||
@@ -115,10 +106,12 @@ export function WorkflowList({
|
||||
}
|
||||
}
|
||||
|
||||
// Collect from folders first
|
||||
for (const folder of folderTree) {
|
||||
collectWorkflowIds(folder)
|
||||
}
|
||||
|
||||
// Then collect root workflows
|
||||
const rootWorkflows = workflowsByFolder.root || []
|
||||
for (const workflow of rootWorkflows) {
|
||||
ids.push(workflow.id)
|
||||
@@ -127,24 +120,30 @@ export function WorkflowList({
|
||||
return ids
|
||||
}, [folderTree, workflowsByFolder])
|
||||
|
||||
// Workflow selection hook - uses active workflow ID as anchor for range selection
|
||||
const { handleWorkflowClick } = useWorkflowSelection({
|
||||
workflowIds: orderedWorkflowIds,
|
||||
activeWorkflowId: workflowId,
|
||||
})
|
||||
|
||||
const isWorkflowActive = useCallback(
|
||||
(wfId: string) => pathname === `/workspace/${workspaceId}/w/${wfId}`,
|
||||
(workflowId: string) => pathname === `/workspace/${workspaceId}/w/${workflowId}`,
|
||||
[pathname, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Auto-expand folders and select active workflow.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!workflowId || isLoading || foldersLoading) return
|
||||
|
||||
// Expand folder path to reveal workflow
|
||||
if (activeWorkflowFolderId) {
|
||||
const folderPath = getFolderPath(activeWorkflowFolderId)
|
||||
folderPath.forEach((folder) => setExpanded(folder.id, true))
|
||||
}
|
||||
|
||||
// Select workflow if not already selected
|
||||
const { selectedWorkflows, selectOnly } = useFolderStore.getState()
|
||||
if (!selectedWorkflows.has(workflowId)) {
|
||||
selectOnly(workflowId)
|
||||
@@ -152,40 +151,23 @@ export function WorkflowList({
|
||||
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
|
||||
|
||||
const renderWorkflowItem = useCallback(
|
||||
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {
|
||||
const showBefore =
|
||||
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'before'
|
||||
const showAfter =
|
||||
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'after'
|
||||
|
||||
return (
|
||||
<div key={workflow.id} className='relative'>
|
||||
<DropIndicatorLine show={showBefore} level={level} />
|
||||
<div
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
{...createWorkflowDragHandlers(workflow.id, folderId)}
|
||||
>
|
||||
<WorkflowItem
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={level}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
onDragStart={() => handleDragStart('workflow', folderId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
<DropIndicatorLine show={showAfter} level={level} />
|
||||
(workflow: WorkflowMetadata, level: number, parentFolderId: string | null = null) => (
|
||||
<div key={workflow.id} className='relative' {...createItemDragHandlers(parentFolderId)}>
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px`,
|
||||
}}
|
||||
>
|
||||
<WorkflowItem
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={level}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
dropIndicator,
|
||||
isWorkflowActive,
|
||||
createWorkflowDragHandlers,
|
||||
handleWorkflowClick,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
]
|
||||
</div>
|
||||
),
|
||||
[isWorkflowActive, createItemDragHandlers, handleWorkflowClick]
|
||||
)
|
||||
|
||||
const renderFolderSection = useCallback(
|
||||
@@ -197,75 +179,45 @@ export function WorkflowList({
|
||||
const workflowsInFolder = workflowsByFolder[folder.id] || []
|
||||
const isExpanded = expandedFolders.has(folder.id)
|
||||
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
|
||||
|
||||
const showBefore =
|
||||
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'before'
|
||||
const showAfter = dropIndicator?.targetId === folder.id && dropIndicator?.position === 'after'
|
||||
const showInside =
|
||||
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'inside'
|
||||
|
||||
const childItems: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const childFolder of folder.children) {
|
||||
childItems.push({
|
||||
type: 'folder',
|
||||
id: childFolder.id,
|
||||
sortOrder: childFolder.sortOrder,
|
||||
data: childFolder,
|
||||
})
|
||||
}
|
||||
for (const workflow of workflowsInFolder) {
|
||||
childItems.push({
|
||||
type: 'workflow',
|
||||
id: workflow.id,
|
||||
sortOrder: workflow.sortOrder,
|
||||
data: workflow,
|
||||
})
|
||||
}
|
||||
childItems.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const isDropTarget = dropTargetId === folder.id
|
||||
|
||||
return (
|
||||
<div key={folder.id} className='relative'>
|
||||
<DropIndicatorLine show={showBefore} level={level} />
|
||||
{/* Drop target highlight overlay - covers entire folder section */}
|
||||
<div key={folder.id} className='relative' {...createFolderDragHandlers(folder.id)}>
|
||||
{/* Drop target highlight overlay - always rendered for stable DOM */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
isDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
|
||||
{...createFolderDragHandlers(folder.id, parentFolderId)}
|
||||
{...createItemDragHandlers(folder.id)}
|
||||
>
|
||||
<FolderItem
|
||||
folder={folder}
|
||||
level={level}
|
||||
onDragStart={() => handleDragStart('folder', parentFolderId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
hoverHandlers={createFolderHeaderHoverHandlers(folder.id)}
|
||||
/>
|
||||
</div>
|
||||
<DropIndicatorLine show={showAfter} level={level} />
|
||||
|
||||
{isExpanded && (hasChildren || isDragging) && (
|
||||
<div className='relative' {...createFolderContentDropZone(folder.id)}>
|
||||
{isExpanded && hasChildren && (
|
||||
<div className='relative' {...createItemDragHandlers(folder.id)}>
|
||||
{/* Vertical line - positioned to align under folder chevron */}
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
|
||||
style={{ left: `${level * TREE_SPACING.INDENT_PER_LEVEL + 12}px` }}
|
||||
/>
|
||||
<div className='mt-[2px] space-y-[2px] pl-[2px]'>
|
||||
{childItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
|
||||
)}
|
||||
{!hasChildren && isDragging && (
|
||||
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
|
||||
{workflowsInFolder.map((workflow: WorkflowMetadata) =>
|
||||
renderWorkflowItem(workflow, level + 1, folder.id)
|
||||
)}
|
||||
{folder.children.map((childFolder) => (
|
||||
<div key={childFolder.id} className='relative'>
|
||||
{renderFolderSection(childFolder, level + 1, folder.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -275,47 +227,29 @@ export function WorkflowList({
|
||||
[
|
||||
workflowsByFolder,
|
||||
expandedFolders,
|
||||
dropIndicator,
|
||||
dropTargetId,
|
||||
isDragging,
|
||||
createFolderDragHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
createItemDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
renderWorkflowItem,
|
||||
]
|
||||
)
|
||||
|
||||
const rootDropZoneHandlers = createRootDropZone()
|
||||
const handleRootDragEvents = createRootDragHandlers()
|
||||
const rootWorkflows = workflowsByFolder.root || []
|
||||
const isRootDropTarget = dropTargetId === 'root'
|
||||
const hasRootWorkflows = rootWorkflows.length > 0
|
||||
const hasFolders = folderTree.length > 0
|
||||
|
||||
const rootItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
type: 'folder' | 'workflow'
|
||||
id: string
|
||||
sortOrder: number
|
||||
data: FolderTreeNode | WorkflowMetadata
|
||||
}> = []
|
||||
for (const folder of folderTree) {
|
||||
items.push({ type: 'folder', id: folder.id, sortOrder: folder.sortOrder, data: folder })
|
||||
}
|
||||
for (const workflow of rootWorkflows) {
|
||||
items.push({
|
||||
type: 'workflow',
|
||||
id: workflow.id,
|
||||
sortOrder: workflow.sortOrder,
|
||||
data: workflow,
|
||||
})
|
||||
}
|
||||
return items.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}, [folderTree, rootWorkflows])
|
||||
|
||||
const hasRootItems = rootItems.length > 0
|
||||
const showRootInside = dropIndicator?.targetId === 'root' && dropIndicator?.position === 'inside'
|
||||
|
||||
/**
|
||||
* Handle click on empty space to revert to active workflow selection
|
||||
*/
|
||||
const handleContainerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only handle clicks directly on the container (empty space)
|
||||
if (e.target !== e.currentTarget) return
|
||||
|
||||
const { selectOnly, clearSelection } = useFolderStore.getState()
|
||||
workflowId ? selectOnly(workflowId) : clearSelection()
|
||||
},
|
||||
@@ -324,23 +258,36 @@ export function WorkflowList({
|
||||
|
||||
return (
|
||||
<div className='flex min-h-full flex-col pb-[8px]' onClick={handleContainerClick}>
|
||||
{/* Folders Section */}
|
||||
{hasFolders && (
|
||||
<div className='mb-[2px] space-y-[2px]'>
|
||||
{folderTree.map((folder) => renderFolderSection(folder, 0))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Workflows Section - Expands to fill remaining space */}
|
||||
<div
|
||||
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
|
||||
{...rootDropZoneHandlers}
|
||||
className={clsx('relative flex-1', !hasRootWorkflows && 'min-h-[26px]')}
|
||||
{...handleRootDragEvents}
|
||||
>
|
||||
{/* Root drop target highlight overlay */}
|
||||
{/* Root drop target highlight overlay - always rendered for stable DOM */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
|
||||
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
|
||||
isRootDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-[2px]'>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'folder'
|
||||
? renderFolderSection(item.data as FolderTreeNode, 0, null)
|
||||
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
|
||||
)}
|
||||
{rootWorkflows.map((workflow: WorkflowMetadata) => (
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
active={isWorkflowActive(workflow.id)}
|
||||
level={0}
|
||||
onWorkflowClick={handleWorkflowClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { useAutoScroll } from './use-auto-scroll'
|
||||
export { useContextMenu } from './use-context-menu'
|
||||
export { type DropIndicator, useDragDrop } from './use-drag-drop'
|
||||
export { useDragDrop } from './use-drag-drop'
|
||||
export { useFolderExpand } from './use-folder-expand'
|
||||
export { useFolderOperations } from './use-folder-operations'
|
||||
export { useItemDrag } from './use-item-drag'
|
||||
|
||||
@@ -1,40 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReorderFolders } from '@/hooks/queries/folders'
|
||||
import { useReorderWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('WorkflowList:DragDrop')
|
||||
|
||||
const SCROLL_THRESHOLD = 60
|
||||
const SCROLL_SPEED = 8
|
||||
const HOVER_EXPAND_DELAY = 400
|
||||
/**
|
||||
* Constants for auto-scroll behavior
|
||||
*/
|
||||
const SCROLL_THRESHOLD = 60 // Distance from edge to trigger scroll
|
||||
const SCROLL_SPEED = 8 // Pixels per frame
|
||||
|
||||
export interface DropIndicator {
|
||||
targetId: string
|
||||
position: 'before' | 'after' | 'inside'
|
||||
folderId: string | null
|
||||
}
|
||||
/**
|
||||
* Constants for folder auto-expand on hover during drag
|
||||
*/
|
||||
const HOVER_EXPAND_DELAY = 400 // Milliseconds to wait before expanding folder
|
||||
|
||||
/**
|
||||
* Custom hook for handling drag and drop operations for workflows and folders.
|
||||
* Includes auto-scrolling, drop target highlighting, and hover-to-expand.
|
||||
*
|
||||
* @returns Drag and drop state and event handlers
|
||||
*/
|
||||
export function useDragDrop() {
|
||||
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
|
||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollIntervalRef = useRef<number | null>(null)
|
||||
const hoverExpandTimerRef = useRef<number | null>(null)
|
||||
const lastDragYRef = useRef<number>(0)
|
||||
const draggedTypeRef = useRef<'workflow' | 'folder' | null>(null)
|
||||
const draggedSourceFolderRef = useRef<string | null>(null)
|
||||
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const reorderWorkflowsMutation = useReorderWorkflows()
|
||||
const reorderFoldersMutation = useReorderFolders()
|
||||
const updateFolderMutation = useUpdateFolder()
|
||||
const { setExpanded, expandedFolders } = useFolderStore()
|
||||
const { updateWorkflow } = useWorkflowRegistry()
|
||||
|
||||
/**
|
||||
* Auto-scroll handler - scrolls container when dragging near edges
|
||||
*/
|
||||
const handleAutoScroll = useCallback(() => {
|
||||
if (!scrollContainerRef.current || !isDragging) return
|
||||
|
||||
@@ -42,17 +49,22 @@ export function useDragDrop() {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const mouseY = lastDragYRef.current
|
||||
|
||||
// Only scroll if mouse is within container bounds
|
||||
if (mouseY < rect.top || mouseY > rect.bottom) return
|
||||
|
||||
// Calculate distance from top and bottom edges
|
||||
const distanceFromTop = mouseY - rect.top
|
||||
const distanceFromBottom = rect.bottom - mouseY
|
||||
|
||||
let scrollDelta = 0
|
||||
|
||||
// Scroll up if near top and not at scroll top
|
||||
if (distanceFromTop < SCROLL_THRESHOLD && container.scrollTop > 0) {
|
||||
const intensity = Math.max(0, Math.min(1, 1 - distanceFromTop / SCROLL_THRESHOLD))
|
||||
scrollDelta = -SCROLL_SPEED * intensity
|
||||
} else if (distanceFromBottom < SCROLL_THRESHOLD) {
|
||||
}
|
||||
// Scroll down if near bottom and not at scroll bottom
|
||||
else if (distanceFromBottom < SCROLL_THRESHOLD) {
|
||||
const maxScroll = container.scrollHeight - container.clientHeight
|
||||
if (container.scrollTop < maxScroll) {
|
||||
const intensity = Math.max(0, Math.min(1, 1 - distanceFromBottom / SCROLL_THRESHOLD))
|
||||
@@ -65,9 +77,12 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
/**
|
||||
* Start auto-scroll animation loop
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10)
|
||||
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10) // ~100fps for smoother response
|
||||
} else {
|
||||
if (scrollIntervalRef.current) {
|
||||
clearInterval(scrollIntervalRef.current)
|
||||
@@ -82,17 +97,30 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [isDragging, handleAutoScroll])
|
||||
|
||||
/**
|
||||
* Handle hover folder changes - start/clear expand timer
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Clear existing timer when hover folder changes
|
||||
if (hoverExpandTimerRef.current) {
|
||||
clearTimeout(hoverExpandTimerRef.current)
|
||||
hoverExpandTimerRef.current = null
|
||||
}
|
||||
|
||||
if (!isDragging || !hoverFolderId) return
|
||||
if (expandedFolders.has(hoverFolderId)) return
|
||||
// Don't start timer if not dragging or no folder is hovered
|
||||
if (!isDragging || !hoverFolderId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't expand if folder is already expanded
|
||||
if (expandedFolders.has(hoverFolderId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer to expand folder after delay
|
||||
hoverExpandTimerRef.current = window.setTimeout(() => {
|
||||
setExpanded(hoverFolderId, true)
|
||||
logger.info(`Auto-expanded folder ${hoverFolderId} during drag`)
|
||||
}, HOVER_EXPAND_DELAY)
|
||||
|
||||
return () => {
|
||||
@@ -103,471 +131,249 @@ export function useDragDrop() {
|
||||
}
|
||||
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
|
||||
|
||||
/**
|
||||
* Cleanup hover state when dragging stops
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setHoverFolderId(null)
|
||||
setDropIndicator(null)
|
||||
draggedTypeRef.current = null
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
const calculateDropPosition = useCallback(
|
||||
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const midY = rect.top + rect.height / 2
|
||||
return e.clientY < midY ? 'before' : 'after'
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const calculateFolderDropPosition = useCallback(
|
||||
(e: React.DragEvent, element: HTMLElement): 'before' | 'inside' | 'after' => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const relativeY = e.clientY - rect.top
|
||||
const height = rect.height
|
||||
// Top 25% = before, middle 50% = inside, bottom 25% = after
|
||||
if (relativeY < height * 0.25) return 'before'
|
||||
if (relativeY > height * 0.75) return 'after'
|
||||
return 'inside'
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
type SiblingItem = { type: 'folder' | 'workflow'; id: string; sortOrder: number }
|
||||
|
||||
const getDestinationFolderId = useCallback((indicator: DropIndicator): string | null => {
|
||||
return indicator.position === 'inside'
|
||||
? indicator.targetId === 'root'
|
||||
? null
|
||||
: indicator.targetId
|
||||
: indicator.folderId
|
||||
}, [])
|
||||
|
||||
const calculateInsertIndex = useCallback(
|
||||
(remaining: SiblingItem[], indicator: DropIndicator): number => {
|
||||
return indicator.position === 'inside'
|
||||
? remaining.length
|
||||
: remaining.findIndex((item) => item.id === indicator.targetId) +
|
||||
(indicator.position === 'after' ? 1 : 0)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const buildAndSubmitUpdates = useCallback(
|
||||
async (newOrder: SiblingItem[], destinationFolderId: string | null) => {
|
||||
const indexed = newOrder.map((item, i) => ({ ...item, sortOrder: i }))
|
||||
|
||||
const folderUpdates = indexed
|
||||
.filter((item) => item.type === 'folder')
|
||||
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, parentId: destinationFolderId }))
|
||||
|
||||
const workflowUpdates = indexed
|
||||
.filter((item) => item.type === 'workflow')
|
||||
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, folderId: destinationFolderId }))
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
folderUpdates.length > 0 &&
|
||||
reorderFoldersMutation.mutateAsync({
|
||||
workspaceId: workspaceId!,
|
||||
updates: folderUpdates,
|
||||
}),
|
||||
workflowUpdates.length > 0 &&
|
||||
reorderWorkflowsMutation.mutateAsync({
|
||||
workspaceId: workspaceId!,
|
||||
updates: workflowUpdates,
|
||||
}),
|
||||
].filter(Boolean)
|
||||
)
|
||||
},
|
||||
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
|
||||
)
|
||||
|
||||
const isLeavingElement = useCallback((e: React.DragEvent<HTMLElement>): boolean => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
return !relatedTarget || !currentTarget.contains(relatedTarget)
|
||||
}, [])
|
||||
|
||||
const initDragOver = useCallback((e: React.DragEvent<HTMLElement>, stopPropagation = true) => {
|
||||
e.preventDefault()
|
||||
if (stopPropagation) e.stopPropagation()
|
||||
lastDragYRef.current = e.clientY
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const getSiblingItems = useCallback((folderId: string | null): SiblingItem[] => {
|
||||
const currentFolders = useFolderStore.getState().folders
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
return [
|
||||
...Object.values(currentFolders)
|
||||
.filter((f) => f.parentId === folderId)
|
||||
.map((f) => ({ type: 'folder' as const, id: f.id, sortOrder: f.sortOrder })),
|
||||
...Object.values(currentWorkflows)
|
||||
.filter((w) => w.folderId === folderId)
|
||||
.map((w) => ({ type: 'workflow' as const, id: w.id, sortOrder: w.sortOrder })),
|
||||
].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}, [])
|
||||
|
||||
const setNormalizedDropIndicator = useCallback(
|
||||
(indicator: DropIndicator | null) => {
|
||||
setDropIndicator((prev) => {
|
||||
let next: DropIndicator | null = indicator
|
||||
|
||||
if (indicator && indicator.position === 'after' && indicator.targetId !== 'root') {
|
||||
const siblings = getSiblingItems(indicator.folderId)
|
||||
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
|
||||
const nextSibling = siblings[currentIdx + 1]
|
||||
if (nextSibling) {
|
||||
next = {
|
||||
targetId: nextSibling.id,
|
||||
position: 'before',
|
||||
folderId: indicator.folderId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
prev?.targetId === next?.targetId &&
|
||||
prev?.position === next?.position &&
|
||||
prev?.folderId === next?.folderId
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[getSiblingItems]
|
||||
)
|
||||
|
||||
const isNoOpMove = useCallback(
|
||||
(
|
||||
indicator: DropIndicator,
|
||||
draggedIds: string[],
|
||||
draggedType: 'folder' | 'workflow',
|
||||
destinationFolderId: string | null,
|
||||
currentFolderId: string | null | undefined
|
||||
): boolean => {
|
||||
if (indicator.position !== 'inside' && draggedIds.includes(indicator.targetId)) {
|
||||
return true
|
||||
}
|
||||
if (currentFolderId !== destinationFolderId) {
|
||||
return false
|
||||
}
|
||||
const siblingItems = getSiblingItems(destinationFolderId)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === draggedType && draggedIds.includes(item.id))
|
||||
)
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
const originalIdx = siblingItems.findIndex(
|
||||
(item) => item.type === draggedType && item.id === draggedIds[0]
|
||||
)
|
||||
return insertAt === originalIdx
|
||||
},
|
||||
[getSiblingItems, calculateInsertIndex]
|
||||
)
|
||||
|
||||
/**
|
||||
* Moves one or more workflows to a target folder
|
||||
*
|
||||
* @param workflowIds - Array of workflow IDs to move
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleWorkflowDrop = useCallback(
|
||||
async (workflowIds: string[], indicator: DropIndicator) => {
|
||||
if (!workflowIds.length || !workspaceId) return
|
||||
async (workflowIds: string[], targetFolderId: string | null) => {
|
||||
if (!workflowIds.length) {
|
||||
logger.warn('No workflows to move')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const destinationFolderId = getDestinationFolderId(indicator)
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const firstWorkflow = currentWorkflows[workflowIds[0]]
|
||||
|
||||
if (
|
||||
isNoOpMove(
|
||||
indicator,
|
||||
workflowIds,
|
||||
'workflow',
|
||||
destinationFolderId,
|
||||
firstWorkflow?.folderId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const siblingItems = getSiblingItems(destinationFolderId)
|
||||
const movingSet = new Set(workflowIds)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
|
||||
await Promise.all(
|
||||
workflowIds.map((workflowId) => updateWorkflow(workflowId, { folderId: targetFolderId }))
|
||||
)
|
||||
const moving = workflowIds
|
||||
.map((id) => ({
|
||||
type: 'workflow' as const,
|
||||
id,
|
||||
sortOrder: currentWorkflows[id]?.sortOrder ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
|
||||
const newOrder: SiblingItem[] = [
|
||||
...remaining.slice(0, insertAt),
|
||||
...moving,
|
||||
...remaining.slice(insertAt),
|
||||
]
|
||||
|
||||
await buildAndSubmitUpdates(newOrder, destinationFolderId)
|
||||
logger.info(`Moved ${workflowIds.length} workflow(s)`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reorder workflows:', error)
|
||||
logger.error('Failed to move workflows:', error)
|
||||
}
|
||||
},
|
||||
[
|
||||
getDestinationFolderId,
|
||||
getSiblingItems,
|
||||
calculateInsertIndex,
|
||||
isNoOpMove,
|
||||
buildAndSubmitUpdates,
|
||||
]
|
||||
[updateWorkflow]
|
||||
)
|
||||
|
||||
const handleFolderDrop = useCallback(
|
||||
async (draggedFolderId: string, indicator: DropIndicator) => {
|
||||
if (!draggedFolderId || !workspaceId) return
|
||||
/**
|
||||
* Moves a folder to a new parent folder, with validation
|
||||
*
|
||||
* @param draggedFolderId - ID of the folder being moved
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleFolderMove = useCallback(
|
||||
async (draggedFolderId: string, targetFolderId: string | null) => {
|
||||
if (!draggedFolderId) {
|
||||
logger.warn('No folder to move')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const folderStore = useFolderStore.getState()
|
||||
const currentFolders = folderStore.folders
|
||||
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
|
||||
|
||||
const targetParentId = getDestinationFolderId(indicator)
|
||||
// Prevent moving folder into its own descendant
|
||||
if (
|
||||
targetFolderId &&
|
||||
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
|
||||
) {
|
||||
logger.info('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
|
||||
if (draggedFolderId === targetParentId) {
|
||||
// Prevent moving folder into itself
|
||||
if (draggedFolderId === targetFolderId) {
|
||||
logger.info('Cannot move folder into itself')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetParentId) {
|
||||
const targetPath = folderStore.getFolderPath(targetParentId)
|
||||
if (targetPath.some((f) => f.id === draggedFolderId)) {
|
||||
logger.info('Cannot move folder into its own descendant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const draggedFolder = currentFolders[draggedFolderId]
|
||||
if (
|
||||
isNoOpMove(
|
||||
indicator,
|
||||
[draggedFolderId],
|
||||
'folder',
|
||||
targetParentId,
|
||||
draggedFolder?.parentId
|
||||
)
|
||||
) {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspaceId available for folder move')
|
||||
return
|
||||
}
|
||||
|
||||
const siblingItems = getSiblingItems(targetParentId)
|
||||
const remaining = siblingItems.filter(
|
||||
(item) => !(item.type === 'folder' && item.id === draggedFolderId)
|
||||
)
|
||||
|
||||
const insertAt = calculateInsertIndex(remaining, indicator)
|
||||
|
||||
const newOrder: SiblingItem[] = [
|
||||
...remaining.slice(0, insertAt),
|
||||
{ type: 'folder', id: draggedFolderId, sortOrder: 0 },
|
||||
...remaining.slice(insertAt),
|
||||
]
|
||||
|
||||
await buildAndSubmitUpdates(newOrder, targetParentId)
|
||||
await updateFolderMutation.mutateAsync({
|
||||
workspaceId,
|
||||
id: draggedFolderId,
|
||||
updates: { parentId: targetFolderId },
|
||||
})
|
||||
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reorder folder:', error)
|
||||
logger.error('Failed to move folder:', error)
|
||||
}
|
||||
},
|
||||
[
|
||||
workspaceId,
|
||||
getDestinationFolderId,
|
||||
getSiblingItems,
|
||||
calculateInsertIndex,
|
||||
isNoOpMove,
|
||||
buildAndSubmitUpdates,
|
||||
]
|
||||
[updateFolderMutation, workspaceId]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
/**
|
||||
* Handles drop events for both workflows and folders
|
||||
*
|
||||
* @param e - React drag event
|
||||
* @param targetFolderId - Target folder ID or null for root
|
||||
*/
|
||||
const handleFolderDrop = useCallback(
|
||||
async (e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const indicator = dropIndicator
|
||||
setDropIndicator(null)
|
||||
setDropTargetId(null)
|
||||
setIsDragging(false)
|
||||
|
||||
if (!indicator) return
|
||||
|
||||
try {
|
||||
// Check if dropping workflows
|
||||
const workflowIdsData = e.dataTransfer.getData('workflow-ids')
|
||||
if (workflowIdsData) {
|
||||
const workflowIds = JSON.parse(workflowIdsData) as string[]
|
||||
await handleWorkflowDrop(workflowIds, indicator)
|
||||
await handleWorkflowDrop(workflowIds, targetFolderId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if dropping a folder
|
||||
const folderIdData = e.dataTransfer.getData('folder-id')
|
||||
if (folderIdData) {
|
||||
await handleFolderDrop(folderIdData, indicator)
|
||||
if (folderIdData && targetFolderId !== folderIdData) {
|
||||
await handleFolderMove(folderIdData, targetFolderId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle drop:', error)
|
||||
}
|
||||
},
|
||||
[dropIndicator, handleWorkflowDrop, handleFolderDrop]
|
||||
)
|
||||
|
||||
const createWorkflowDragHandlers = useCallback(
|
||||
(workflowId: string, folderId: string | null) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e)
|
||||
const isSameFolder = draggedSourceFolderRef.current === folderId
|
||||
if (isSameFolder) {
|
||||
const position = calculateDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: workflowId, position, folderId })
|
||||
} else {
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId || 'root',
|
||||
position: 'inside',
|
||||
folderId: null,
|
||||
})
|
||||
}
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[initDragOver, calculateDropPosition, setNormalizedDropIndicator, handleDrop]
|
||||
[handleWorkflowDrop, handleFolderMove]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for a specific folder section
|
||||
* These handlers are attached to the entire folder section container
|
||||
*
|
||||
* @param folderId - Folder ID to create handlers for
|
||||
* @returns Object containing drag event handlers
|
||||
*/
|
||||
const createFolderDragHandlers = useCallback(
|
||||
(folderId: string, parentFolderId: string | null) => ({
|
||||
(folderId: string) => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e)
|
||||
if (draggedTypeRef.current === 'folder') {
|
||||
const isSameParent = draggedSourceFolderRef.current === parentFolderId
|
||||
if (isSameParent) {
|
||||
const position = calculateDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
|
||||
} else {
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId,
|
||||
position: 'inside',
|
||||
folderId: parentFolderId,
|
||||
})
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
} else {
|
||||
// Workflow being dragged over a folder
|
||||
const isSameParent = draggedSourceFolderRef.current === parentFolderId
|
||||
if (isSameParent) {
|
||||
// Same level - use three zones: top=before, middle=inside, bottom=after
|
||||
const position = calculateFolderDropPosition(e, e.currentTarget)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
|
||||
if (position === 'inside') {
|
||||
setHoverFolderId(folderId)
|
||||
} else {
|
||||
setHoverFolderId(null)
|
||||
}
|
||||
} else {
|
||||
// Different container - drop into folder
|
||||
setNormalizedDropIndicator({
|
||||
targetId: folderId,
|
||||
position: 'inside',
|
||||
folderId: parentFolderId,
|
||||
})
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId(folderId)
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isLeavingElement(e)) setHoverFolderId(null)
|
||||
e.preventDefault()
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the folder section completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setDropTargetId(null)
|
||||
}
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, folderId),
|
||||
}),
|
||||
[
|
||||
initDragOver,
|
||||
calculateDropPosition,
|
||||
calculateFolderDropPosition,
|
||||
setNormalizedDropIndicator,
|
||||
isLeavingElement,
|
||||
handleDrop,
|
||||
]
|
||||
[handleFolderDrop]
|
||||
)
|
||||
|
||||
const createEmptyFolderDropZone = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e)
|
||||
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId })
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[initDragOver, setNormalizedDropIndicator, handleDrop]
|
||||
)
|
||||
|
||||
const createFolderContentDropZone = useCallback(
|
||||
(folderId: string) => ({
|
||||
/**
|
||||
* Creates drag event handlers for items (workflows/folders) that belong to a parent folder
|
||||
* When dragging over an item, highlights the parent folder section
|
||||
*
|
||||
* @param parentFolderId - Parent folder ID or null for root
|
||||
* @returns Object containing drag event handlers
|
||||
*/
|
||||
const createItemDragHandlers = useCallback(
|
||||
(parentFolderId: string | null) => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId(parentFolderId || 'root')
|
||||
setIsDragging(true)
|
||||
if (e.target === e.currentTarget && draggedSourceFolderRef.current !== folderId) {
|
||||
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId: null })
|
||||
}
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[setNormalizedDropIndicator, handleDrop]
|
||||
)
|
||||
|
||||
const createRootDropZone = useCallback(
|
||||
() => ({
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
initDragOver(e, false)
|
||||
if (e.target === e.currentTarget) {
|
||||
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
|
||||
}
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isLeavingElement(e)) setNormalizedDropIndicator(null)
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
}),
|
||||
[initDragOver, setNormalizedDropIndicator, isLeavingElement, handleDrop]
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(type: 'workflow' | 'folder', sourceFolderId: string | null) => {
|
||||
draggedTypeRef.current = type
|
||||
draggedSourceFolderRef.current = sourceFolderId
|
||||
setIsDragging(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
setDropIndicator(null)
|
||||
draggedTypeRef.current = null
|
||||
draggedSourceFolderRef.current = null
|
||||
setHoverFolderId(null)
|
||||
}, [])
|
||||
/**
|
||||
* Creates drag event handlers for the root drop zone
|
||||
*
|
||||
* @returns Object containing drag event handlers for root
|
||||
*/
|
||||
const createRootDragHandlers = useCallback(
|
||||
() => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragOver: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
lastDragYRef.current = e.clientY
|
||||
setDropTargetId('root')
|
||||
setIsDragging(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the root completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setDropTargetId(null)
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, null),
|
||||
}),
|
||||
[handleFolderDrop]
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates drag event handlers for folder header (the clickable part)
|
||||
* These handlers trigger folder expansion on hover during drag
|
||||
*
|
||||
* @param folderId - Folder ID to handle hover for
|
||||
* @returns Object containing drag event handlers for folder header
|
||||
*/
|
||||
const createFolderHeaderHoverHandlers = useCallback(
|
||||
(folderId: string) => ({
|
||||
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
|
||||
if (isDragging) {
|
||||
setHoverFolderId(folderId)
|
||||
}
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const currentTarget = e.currentTarget as HTMLElement
|
||||
// Only clear if we're leaving the folder header completely
|
||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||
setHoverFolderId(null)
|
||||
}
|
||||
},
|
||||
}),
|
||||
[isDragging]
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the scroll container ref for auto-scrolling
|
||||
*
|
||||
* @param element - Scrollable container element
|
||||
*/
|
||||
const setScrollContainer = useCallback((element: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = element
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dropIndicator,
|
||||
dropTargetId,
|
||||
isDragging,
|
||||
setScrollContainer,
|
||||
createWorkflowDragHandlers,
|
||||
createFolderDragHandlers,
|
||||
createEmptyFolderDropZone,
|
||||
createFolderContentDropZone,
|
||||
createRootDropZone,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
createItemDragHandlers,
|
||||
createRootDragHandlers,
|
||||
createFolderHeaderHoverHandlers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
extractWorkflowsFromFiles,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { folderKeys, useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows'
|
||||
@@ -41,7 +40,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
* Import a single workflow
|
||||
*/
|
||||
const importSingleWorkflow = useCallback(
|
||||
async (content: string, filename: string, folderId?: string, sortOrder?: number) => {
|
||||
async (content: string, filename: string, folderId?: string) => {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
|
||||
|
||||
if (!workflowData || parseErrors.length > 0) {
|
||||
@@ -61,7 +60,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
description: workflowData.metadata?.description || 'Imported from JSON',
|
||||
workspaceId,
|
||||
folderId: folderId || undefined,
|
||||
sortOrder,
|
||||
})
|
||||
const newWorkflowId = result.id
|
||||
|
||||
@@ -142,55 +140,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
})
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
if (metadata?.folders && metadata.folders.length > 0) {
|
||||
type ExportedFolder = {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
const foldersById = new Map<string, ExportedFolder>(
|
||||
metadata.folders.map((f) => [f.id, f])
|
||||
)
|
||||
const oldIdToNewId = new Map<string, string>()
|
||||
|
||||
const buildPath = (folderId: string): string => {
|
||||
const pathParts: string[] = []
|
||||
let currentId: string | null = folderId
|
||||
while (currentId && foldersById.has(currentId)) {
|
||||
const folder: ExportedFolder = foldersById.get(currentId)!
|
||||
pathParts.unshift(sanitizePathSegment(folder.name))
|
||||
currentId = folder.parentId
|
||||
}
|
||||
return pathParts.join('/')
|
||||
}
|
||||
|
||||
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
|
||||
if (oldIdToNewId.has(folder.id)) {
|
||||
return oldIdToNewId.get(folder.id)!
|
||||
}
|
||||
|
||||
let parentId = importFolder.id
|
||||
if (folder.parentId && foldersById.has(folder.parentId)) {
|
||||
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
|
||||
}
|
||||
|
||||
const newFolder = await createFolderMutation.mutateAsync({
|
||||
name: folder.name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
oldIdToNewId.set(folder.id, newFolder.id)
|
||||
folderMap.set(buildPath(folder.id), newFolder.id)
|
||||
return newFolder.id
|
||||
}
|
||||
|
||||
for (const folder of metadata.folders) {
|
||||
await createFolderRecursive(folder)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
let targetFolderId = importFolder.id
|
||||
@@ -198,17 +147,15 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
if (folderMap.has(folderPathKey)) {
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
} else {
|
||||
if (!folderMap.has(folderPathKey)) {
|
||||
let parentId = importFolder.id
|
||||
|
||||
for (let i = 0; i < workflow.folderPath.length; i++) {
|
||||
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
|
||||
const folderNameForSegment = workflow.folderPath[i]
|
||||
|
||||
if (!folderMap.has(pathSegment)) {
|
||||
const subFolder = await createFolderMutation.mutateAsync({
|
||||
name: folderNameForSegment,
|
||||
name: workflow.folderPath[i],
|
||||
workspaceId,
|
||||
parentId,
|
||||
})
|
||||
@@ -218,15 +165,15 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
parentId = folderMap.get(pathSegment)!
|
||||
}
|
||||
}
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
}
|
||||
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
}
|
||||
|
||||
const workflowId = await importSingleWorkflow(
|
||||
workflow.content,
|
||||
workflow.name,
|
||||
targetFolderId,
|
||||
workflow.sortOrder
|
||||
targetFolderId
|
||||
)
|
||||
if (workflowId) importedWorkflowIds.push(workflowId)
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -60,7 +59,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const createResponse = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: workspaceName, skipDefaultWorkflow: true }),
|
||||
body: JSON.stringify({ name: workspaceName }),
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
@@ -72,55 +71,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
if (metadata?.folders && metadata.folders.length > 0) {
|
||||
type ExportedFolder = {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
const foldersById = new Map<string, ExportedFolder>(
|
||||
metadata.folders.map((f) => [f.id, f])
|
||||
)
|
||||
const oldIdToNewId = new Map<string, string>()
|
||||
|
||||
const buildPath = (folderId: string): string => {
|
||||
const pathParts: string[] = []
|
||||
let currentId: string | null = folderId
|
||||
while (currentId && foldersById.has(currentId)) {
|
||||
const folder: ExportedFolder = foldersById.get(currentId)!
|
||||
pathParts.unshift(sanitizePathSegment(folder.name))
|
||||
currentId = folder.parentId
|
||||
}
|
||||
return pathParts.join('/')
|
||||
}
|
||||
|
||||
const createFolderRecursive = async (folder: ExportedFolder): Promise<string> => {
|
||||
if (oldIdToNewId.has(folder.id)) {
|
||||
return oldIdToNewId.get(folder.id)!
|
||||
}
|
||||
|
||||
let parentId: string | undefined
|
||||
if (folder.parentId && foldersById.has(folder.parentId)) {
|
||||
parentId = await createFolderRecursive(foldersById.get(folder.parentId)!)
|
||||
}
|
||||
|
||||
const newFolder = await createFolderMutation.mutateAsync({
|
||||
name: folder.name,
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId,
|
||||
sortOrder: folder.sortOrder,
|
||||
})
|
||||
oldIdToNewId.set(folder.id, newFolder.id)
|
||||
folderMap.set(buildPath(folder.id), newFolder.id)
|
||||
return newFolder.id
|
||||
}
|
||||
|
||||
for (const folder of metadata.folders) {
|
||||
await createFolderRecursive(folder)
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
|
||||
@@ -134,10 +84,9 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
if (folderMap.has(folderPathKey)) {
|
||||
targetFolderId = folderMap.get(folderPathKey)!
|
||||
} else {
|
||||
let parentId: string | undefined
|
||||
if (!folderMap.has(folderPathKey)) {
|
||||
let parentId: string | null = null
|
||||
|
||||
for (let i = 0; i < workflow.folderPath.length; i++) {
|
||||
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
|
||||
|
||||
@@ -145,7 +94,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const subFolder = await createFolderMutation.mutateAsync({
|
||||
name: workflow.folderPath[i],
|
||||
workspaceId: newWorkspace.id,
|
||||
parentId,
|
||||
parentId: parentId || undefined,
|
||||
})
|
||||
folderMap.set(pathSegment, subFolder.id)
|
||||
parentId = subFolder.id
|
||||
@@ -153,8 +102,9 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
parentId = folderMap.get(pathSegment)!
|
||||
}
|
||||
}
|
||||
targetFolderId = folderMap.get(folderPathKey) || null
|
||||
}
|
||||
|
||||
targetFolderId = folderMap.get(folderPathKey) || null
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(workflow.content, workflow.name)
|
||||
|
||||
@@ -172,7 +172,7 @@ async function executeWebhookJobInternal(
|
||||
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
|
||||
|
||||
// Merge subblock states (matching workflow-execution pattern)
|
||||
const mergedStates = mergeSubblockState(blocks, {})
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Create serialized workflow
|
||||
const serializer = new Serializer()
|
||||
|
||||
@@ -68,7 +68,6 @@ interface CreateFolderVariables {
|
||||
name: string
|
||||
parentId?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface UpdateFolderVariables {
|
||||
@@ -161,20 +160,18 @@ export function useCreateFolder() {
|
||||
parentId: variables.parentId || null,
|
||||
color: variables.color || '#808080',
|
||||
isExpanded: false,
|
||||
sortOrder:
|
||||
variables.sortOrder ??
|
||||
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
|
||||
mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => {
|
||||
const response = await fetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, workspaceId, sortOrder }),
|
||||
body: JSON.stringify({ ...payload, workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -288,66 +285,9 @@ export function useDuplicateFolderMutation() {
|
||||
},
|
||||
...handlers,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Invalidate both folders and workflows (duplicated folder may contain workflows)
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderFoldersVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
parentId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderFolders() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderFoldersVariables): Promise<void> => {
|
||||
const response = await fetch('/api/folders/reorder', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to reorder folders')
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
|
||||
const snapshot = { ...useFolderStore.getState().folders }
|
||||
|
||||
useFolderStore.setState((state) => {
|
||||
const updated = { ...state.folders }
|
||||
for (const update of variables.updates) {
|
||||
if (updated[update.id]) {
|
||||
updated[update.id] = {
|
||||
...updated[update.id],
|
||||
sortOrder: update.sortOrder,
|
||||
parentId:
|
||||
update.parentId !== undefined ? update.parentId : updated[update.id].parentId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { folders: updated }
|
||||
})
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
useFolderStore.setState({ folders: context.snapshot })
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ function mapWorkflow(workflow: any): WorkflowMetadata {
|
||||
color: workflow.color,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
sortOrder: workflow.sortOrder ?? 0,
|
||||
createdAt: new Date(workflow.createdAt),
|
||||
lastModified: new Date(workflow.updatedAt || workflow.createdAt),
|
||||
}
|
||||
@@ -92,7 +91,6 @@ interface CreateWorkflowVariables {
|
||||
description?: string
|
||||
color?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface CreateWorkflowResult {
|
||||
@@ -102,7 +100,6 @@ interface CreateWorkflowResult {
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowVariables {
|
||||
@@ -121,7 +118,6 @@ interface DuplicateWorkflowResult {
|
||||
color: string
|
||||
workspaceId: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
@@ -165,7 +161,6 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
|
||||
color: data.color,
|
||||
workspaceId: data.workspaceId,
|
||||
folderId: data.folderId,
|
||||
sortOrder: 'sortOrder' in data ? data.sortOrder : 0,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
@@ -184,36 +179,21 @@ export function useCreateWorkflow() {
|
||||
const handlers = createWorkflowMutationHandlers<CreateWorkflowVariables>(
|
||||
queryClient,
|
||||
'CreateWorkflow',
|
||||
(variables, tempId) => {
|
||||
let sortOrder: number
|
||||
if (variables.sortOrder !== undefined) {
|
||||
sortOrder = variables.sortOrder
|
||||
} else {
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const targetFolderId = variables.folderId || null
|
||||
const workflowsInFolder = Object.values(currentWorkflows).filter(
|
||||
(w) => w.folderId === targetFolderId
|
||||
)
|
||||
sortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1) + 1
|
||||
}
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
color: variables.color || getNextWorkflowColor(),
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
sortOrder,
|
||||
}
|
||||
}
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description || 'New workflow',
|
||||
color: variables.color || getNextWorkflowColor(),
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
|
||||
const { workspaceId, name, description, color, folderId, sortOrder } = variables
|
||||
const { workspaceId, name, description, color, folderId } = variables
|
||||
|
||||
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
|
||||
|
||||
@@ -226,7 +206,6 @@ export function useCreateWorkflow() {
|
||||
color: color || getNextWorkflowColor(),
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -264,13 +243,13 @@ export function useCreateWorkflow() {
|
||||
color: createdWorkflow.color,
|
||||
workspaceId,
|
||||
folderId: createdWorkflow.folderId,
|
||||
sortOrder: createdWorkflow.sortOrder ?? 0,
|
||||
}
|
||||
},
|
||||
...handlers,
|
||||
onSuccess: (data, variables, context) => {
|
||||
handlers.onSuccess(data, variables, context)
|
||||
|
||||
// Initialize subblock values for new workflow
|
||||
const { subBlockValues } = buildDefaultWorkflowArtifacts()
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
@@ -288,26 +267,16 @@ export function useDuplicateWorkflowMutation() {
|
||||
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
|
||||
queryClient,
|
||||
'DuplicateWorkflow',
|
||||
(variables, tempId) => {
|
||||
const currentWorkflows = useWorkflowRegistry.getState().workflows
|
||||
const targetFolderId = variables.folderId || null
|
||||
const workflowsInFolder = Object.values(currentWorkflows).filter(
|
||||
(w) => w.folderId === targetFolderId
|
||||
)
|
||||
const maxSortOrder = workflowsInFolder.reduce((max, w) => Math.max(max, w.sortOrder ?? 0), -1)
|
||||
|
||||
return {
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
color: variables.color,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder: maxSortOrder + 1,
|
||||
}
|
||||
}
|
||||
(variables, tempId) => ({
|
||||
id: tempId,
|
||||
name: variables.name,
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: variables.description,
|
||||
color: variables.color,
|
||||
workspaceId: variables.workspaceId,
|
||||
folderId: variables.folderId || null,
|
||||
})
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
@@ -348,7 +317,6 @@ export function useDuplicateWorkflowMutation() {
|
||||
color: duplicatedWorkflow.color || color,
|
||||
workspaceId,
|
||||
folderId: duplicatedWorkflow.folderId ?? folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
blocksCount: duplicatedWorkflow.blocksCount || 0,
|
||||
edgesCount: duplicatedWorkflow.edgesCount || 0,
|
||||
subflowsCount: duplicatedWorkflow.subflowsCount || 0,
|
||||
@@ -430,61 +398,3 @@ export function useRevertToVersion() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ReorderWorkflowsVariables {
|
||||
workspaceId: string
|
||||
updates: Array<{
|
||||
id: string
|
||||
sortOrder: number
|
||||
folderId?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function useReorderWorkflows() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables: ReorderWorkflowsVariables): Promise<void> => {
|
||||
const response = await fetch('/api/workflows/reorder', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.error || 'Failed to reorder workflows')
|
||||
}
|
||||
},
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
|
||||
const snapshot = { ...useWorkflowRegistry.getState().workflows }
|
||||
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const updated = { ...state.workflows }
|
||||
for (const update of variables.updates) {
|
||||
if (updated[update.id]) {
|
||||
updated[update.id] = {
|
||||
...updated[update.id],
|
||||
sortOrder: update.sortOrder,
|
||||
folderId:
|
||||
update.folderId !== undefined ? update.folderId : updated[update.id].folderId,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { workflows: updated }
|
||||
})
|
||||
|
||||
return { snapshot }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.snapshot) {
|
||||
useWorkflowRegistry.setState({ workflows: context.snapshot })
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ export interface SendMessageRequest {
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface WorkflowExportData {
|
||||
description?: string
|
||||
color?: string
|
||||
folderId?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
state: WorkflowState
|
||||
variables?: Record<string, Variable>
|
||||
@@ -26,7 +25,6 @@ export interface FolderExportData {
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceExportStructure {
|
||||
@@ -188,12 +186,7 @@ export async function exportWorkspaceToZip(
|
||||
name: workspaceName,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
folders: folders.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
sortOrder: f.sortOrder,
|
||||
})),
|
||||
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
|
||||
}
|
||||
|
||||
zip.file('_workspace.json', JSON.stringify(metadata, null, 2))
|
||||
@@ -206,7 +199,6 @@ export async function exportWorkspaceToZip(
|
||||
name: workflow.workflow.name,
|
||||
description: workflow.workflow.description,
|
||||
color: workflow.workflow.color,
|
||||
sortOrder: workflow.workflow.sortOrder,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflow.variables,
|
||||
@@ -287,27 +279,11 @@ export interface ImportedWorkflow {
|
||||
content: string
|
||||
name: string
|
||||
folderPath: string[]
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface WorkspaceImportMetadata {
|
||||
workspaceName: string
|
||||
exportedAt?: string
|
||||
folders?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
sortOrder?: number
|
||||
}>
|
||||
}
|
||||
|
||||
function extractSortOrder(content: string): number | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractWorkflowsFromZip(
|
||||
@@ -327,7 +303,6 @@ export async function extractWorkflowsFromZip(
|
||||
metadata = {
|
||||
workspaceName: parsed.workspace?.name || 'Imported Workspace',
|
||||
exportedAt: parsed.workspace?.exportedAt,
|
||||
folders: parsed.folders,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse workspace metadata:', error)
|
||||
@@ -346,7 +321,6 @@ export async function extractWorkflowsFromZip(
|
||||
content,
|
||||
name: filename,
|
||||
folderPath: pathParts,
|
||||
sortOrder: extractSortOrder(content),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to extract ${path}:`, error)
|
||||
@@ -364,12 +338,10 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
|
||||
workflows.push({
|
||||
content,
|
||||
name: file.name,
|
||||
folderPath: [],
|
||||
sortOrder: extractSortOrder(content),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read ${file.name}:`, error)
|
||||
|
||||
@@ -53,8 +53,6 @@ export interface ExportWorkflowState {
|
||||
metadata?: {
|
||||
name?: string
|
||||
description?: string
|
||||
color?: string
|
||||
sortOrder?: number
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: Array<{
|
||||
|
||||
80
apps/sim/lib/workflows/subblocks.ts
Normal file
80
apps/sim/lib/workflows/subblocks.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export const DEFAULT_SUBBLOCK_TYPE = 'short-input'
|
||||
|
||||
/**
|
||||
* Merges subblock values into the provided subblock structures.
|
||||
* Falls back to a default subblock shape when a value has no structure.
|
||||
* @param subBlocks - Existing subblock definitions from the workflow
|
||||
* @param values - Stored subblock values keyed by subblock id
|
||||
* @returns Merged subblock structures with updated values
|
||||
*/
|
||||
export function mergeSubBlockValues(
|
||||
subBlocks: Record<string, unknown> | undefined,
|
||||
values: Record<string, unknown> | undefined
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...(subBlocks || {}) } as Record<string, any>
|
||||
|
||||
if (!values) return merged
|
||||
|
||||
Object.entries(values).forEach(([subBlockId, value]) => {
|
||||
if (merged[subBlockId] && typeof merged[subBlockId] === 'object') {
|
||||
merged[subBlockId] = {
|
||||
...(merged[subBlockId] as Record<string, unknown>),
|
||||
value,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
merged[subBlockId] = {
|
||||
id: subBlockId,
|
||||
type: DEFAULT_SUBBLOCK_TYPE,
|
||||
value,
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges workflow block states with explicit subblock values while maintaining block structure.
|
||||
* Values that are null or undefined do not override existing subblock values.
|
||||
* @param blocks - Block configurations from workflow state
|
||||
* @param subBlockValues - Subblock values keyed by blockId -> subBlockId -> value
|
||||
* @param blockId - Optional specific block ID to merge (merges all if not provided)
|
||||
* @returns Merged block states with updated subblocks
|
||||
*/
|
||||
export function mergeSubblockStateWithValues(
|
||||
blocks: Record<string, BlockState>,
|
||||
subBlockValues: Record<string, Record<string, unknown>> = {},
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
if (!block) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const blockSubBlocks = block.subBlocks || {}
|
||||
const blockValues = subBlockValues[id] || {}
|
||||
const filteredValues = Object.fromEntries(
|
||||
Object.entries(blockValues).filter(([, value]) => value !== null && value !== undefined)
|
||||
)
|
||||
|
||||
const mergedSubBlocks = mergeSubBlockValues(blockSubBlocks, filteredValues) as Record<
|
||||
string,
|
||||
SubBlockState
|
||||
>
|
||||
|
||||
acc[id] = {
|
||||
...block,
|
||||
subBlocks: mergedSubBlocks,
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, BlockState>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import postgres from 'postgres'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { mergeSubBlockValues } from '@/lib/workflows/subblocks'
|
||||
import {
|
||||
BLOCK_OPERATIONS,
|
||||
BLOCKS_OPERATIONS,
|
||||
@@ -455,7 +456,7 @@ async function handleBlocksOperationTx(
|
||||
}
|
||||
|
||||
case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: {
|
||||
const { blocks, edges, loops, parallels } = payload
|
||||
const { blocks, edges, loops, parallels, subBlockValues } = payload
|
||||
|
||||
logger.info(`Batch adding blocks to workflow ${workflowId}`, {
|
||||
blockCount: blocks?.length || 0,
|
||||
@@ -465,22 +466,30 @@ async function handleBlocksOperationTx(
|
||||
})
|
||||
|
||||
if (blocks && blocks.length > 0) {
|
||||
const blockValues = blocks.map((block: Record<string, unknown>) => ({
|
||||
id: block.id as string,
|
||||
workflowId,
|
||||
type: block.type as string,
|
||||
name: block.name as string,
|
||||
positionX: (block.position as { x: number; y: number }).x,
|
||||
positionY: (block.position as { x: number; y: number }).y,
|
||||
data: (block.data as Record<string, unknown>) || {},
|
||||
subBlocks: (block.subBlocks as Record<string, unknown>) || {},
|
||||
outputs: (block.outputs as Record<string, unknown>) || {},
|
||||
enabled: (block.enabled as boolean) ?? true,
|
||||
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
|
||||
advancedMode: (block.advancedMode as boolean) ?? false,
|
||||
triggerMode: (block.triggerMode as boolean) ?? false,
|
||||
height: (block.height as number) || 0,
|
||||
}))
|
||||
const blockValues = blocks.map((block: Record<string, unknown>) => {
|
||||
const blockId = block.id as string
|
||||
const mergedSubBlocks = mergeSubBlockValues(
|
||||
block.subBlocks as Record<string, unknown>,
|
||||
subBlockValues?.[blockId]
|
||||
)
|
||||
|
||||
return {
|
||||
id: blockId,
|
||||
workflowId,
|
||||
type: block.type as string,
|
||||
name: block.name as string,
|
||||
positionX: (block.position as { x: number; y: number }).x,
|
||||
positionY: (block.position as { x: number; y: number }).y,
|
||||
data: (block.data as Record<string, unknown>) || {},
|
||||
subBlocks: mergedSubBlocks,
|
||||
outputs: (block.outputs as Record<string, unknown>) || {},
|
||||
enabled: (block.enabled as boolean) ?? true,
|
||||
horizontalHandles: (block.horizontalHandles as boolean) ?? true,
|
||||
advancedMode: (block.advancedMode as boolean) ?? false,
|
||||
triggerMode: (block.triggerMode as boolean) ?? false,
|
||||
height: (block.height as number) || 0,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowBlocks).values(blockValues)
|
||||
|
||||
|
||||
@@ -422,7 +422,8 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
|
||||
* Loads messages from DB for UI rendering.
|
||||
* Messages are stored exactly as they render, so we just need to:
|
||||
* 1. Register client tool instances for any tool calls
|
||||
* 2. Return the messages as-is
|
||||
* 2. Clear any streaming flags (messages loaded from DB are never actively streaming)
|
||||
* 3. Return the messages
|
||||
*/
|
||||
function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
|
||||
try {
|
||||
@@ -438,23 +439,54 @@ function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Register client tool instances for all tool calls so they can be looked up
|
||||
// Register client tool instances and clear streaming flags for all tool calls
|
||||
for (const message of messages) {
|
||||
if (message.contentBlocks) {
|
||||
for (const block of message.contentBlocks as any[]) {
|
||||
if (block?.type === 'tool_call' && block.toolCall) {
|
||||
registerToolCallInstances(block.toolCall)
|
||||
clearStreamingFlags(block.toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also clear from toolCalls array (legacy format)
|
||||
if (message.toolCalls) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
clearStreamingFlags(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return messages as-is - they're already in the correct format for rendering
|
||||
return messages
|
||||
} catch {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clears streaming flags from a tool call and its nested subagent tool calls.
|
||||
* This ensures messages loaded from DB don't appear to be streaming.
|
||||
*/
|
||||
function clearStreamingFlags(toolCall: any): void {
|
||||
if (!toolCall) return
|
||||
|
||||
// Always set subAgentStreaming to false - messages loaded from DB are never streaming
|
||||
toolCall.subAgentStreaming = false
|
||||
|
||||
// Clear nested subagent tool calls
|
||||
if (Array.isArray(toolCall.subAgentBlocks)) {
|
||||
for (const block of toolCall.subAgentBlocks) {
|
||||
if (block?.type === 'subagent_tool_call' && block.toolCall) {
|
||||
clearStreamingFlags(block.toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(toolCall.subAgentToolCalls)) {
|
||||
for (const subTc of toolCall.subAgentToolCalls) {
|
||||
clearStreamingFlags(subTc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively registers client tool instances for a tool call and its nested subagent tool calls.
|
||||
*/
|
||||
|
||||
@@ -106,6 +106,9 @@ export interface CopilotState {
|
||||
| 'gpt-5.1-high'
|
||||
| 'gpt-5-codex'
|
||||
| 'gpt-5.1-codex'
|
||||
| 'gpt-5.2'
|
||||
| 'gpt-5.2-codex'
|
||||
| 'gpt-5.2-pro'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4.1'
|
||||
| 'o3'
|
||||
|
||||
@@ -476,6 +476,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
// Use the server-generated ID
|
||||
const id = duplicatedWorkflow.id
|
||||
|
||||
// Generate new workflow metadata using the server-generated ID
|
||||
const newWorkflow: WorkflowMetadata = {
|
||||
id,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
@@ -483,9 +484,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
createdAt: new Date(),
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
workspaceId,
|
||||
folderId: sourceWorkflow.folderId,
|
||||
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
|
||||
workspaceId, // Include the workspaceId in the new workflow
|
||||
folderId: sourceWorkflow.folderId, // Include the folderId from source workflow
|
||||
}
|
||||
|
||||
// Get the current workflow state to copy from
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface WorkflowMetadata {
|
||||
color: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type HydrationPhase =
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* or React hooks, making it safe for use in Next.js API routes.
|
||||
*/
|
||||
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Server-safe version of mergeSubblockState for API routes
|
||||
@@ -26,72 +27,7 @@ export function mergeSubblockState(
|
||||
subBlockValues: Record<string, Record<string, any>> = {},
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
// Skip if block is undefined
|
||||
if (!block) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Initialize subBlocks if not present
|
||||
const blockSubBlocks = block.subBlocks || {}
|
||||
|
||||
// Get stored values for this block
|
||||
const blockValues = subBlockValues[id] || {}
|
||||
|
||||
// Create a deep copy of the block's subBlocks to maintain structure
|
||||
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
|
||||
(subAcc, [subBlockId, subBlock]) => {
|
||||
// Skip if subBlock is undefined
|
||||
if (!subBlock) {
|
||||
return subAcc
|
||||
}
|
||||
|
||||
// Get the stored value for this subblock
|
||||
const storedValue = blockValues[subBlockId]
|
||||
|
||||
// Create a new subblock object with the same structure but updated value
|
||||
subAcc[subBlockId] = {
|
||||
...subBlock,
|
||||
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
|
||||
}
|
||||
|
||||
return subAcc
|
||||
},
|
||||
{} as Record<string, SubBlockState>
|
||||
)
|
||||
|
||||
// Return the full block state with updated subBlocks
|
||||
acc[id] = {
|
||||
...block,
|
||||
subBlocks: mergedSubBlocks,
|
||||
}
|
||||
|
||||
// Add any values that exist in the provided values but aren't in the block structure
|
||||
// This handles cases where block config has been updated but values still exist
|
||||
Object.entries(blockValues).forEach(([subBlockId, value]) => {
|
||||
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
|
||||
// Create a minimal subblock structure
|
||||
mergedSubBlocks[subBlockId] = {
|
||||
id: subBlockId,
|
||||
type: 'short-input', // Default type that's safe to use
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update the block with the final merged subBlocks (including orphaned values)
|
||||
acc[id] = {
|
||||
...block,
|
||||
subBlocks: mergedSubBlocks,
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, BlockState>
|
||||
)
|
||||
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
|
||||
return edgesToAdd.filter((edge) => {
|
||||
if (edge.source === edge.target) return false
|
||||
return !currentEdges.some(
|
||||
(e) =>
|
||||
e.source === edge.source &&
|
||||
e.sourceHandle === edge.sourceHandle &&
|
||||
e.target === edge.target &&
|
||||
e.targetHandle === edge.targetHandle
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -32,6 +19,19 @@ const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
|
||||
|
||||
export { normalizeName }
|
||||
|
||||
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
|
||||
return edgesToAdd.filter((edge) => {
|
||||
if (edge.source === edge.target) return false
|
||||
return !currentEdges.some(
|
||||
(e) =>
|
||||
e.source === edge.source &&
|
||||
e.sourceHandle === edge.sourceHandle &&
|
||||
e.target === edge.target &&
|
||||
e.targetHandle === edge.targetHandle
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export interface RegeneratedState {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
@@ -201,27 +201,20 @@ export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOp
|
||||
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
|
||||
)
|
||||
|
||||
const mergedSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
|
||||
: {}
|
||||
|
||||
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
|
||||
if (field in mergedSubBlocks) {
|
||||
delete mergedSubBlocks[field]
|
||||
if (field in baseSubBlocks) {
|
||||
delete baseSubBlocks[field]
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => {
|
||||
if (mergedSubBlocks[subblockId]) {
|
||||
mergedSubBlocks[subblockId].value = value as SubBlockState['value']
|
||||
} else {
|
||||
mergedSubBlocks[subblockId] = {
|
||||
id: subblockId,
|
||||
type: 'short-input',
|
||||
value: value as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
})
|
||||
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
|
||||
string,
|
||||
SubBlockState
|
||||
>
|
||||
|
||||
const block: BlockState = {
|
||||
id: newId,
|
||||
@@ -256,11 +249,16 @@ export function mergeSubblockState(
|
||||
workflowId?: string,
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
|
||||
|
||||
if (workflowId) {
|
||||
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
|
||||
}
|
||||
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
if (!block) {
|
||||
@@ -339,9 +337,15 @@ export async function mergeSubblockStateAsync(
|
||||
workflowId?: string,
|
||||
blockId?: string
|
||||
): Promise<Record<string, BlockState>> {
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
|
||||
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
|
||||
}
|
||||
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
// Process blocks in parallel for better performance
|
||||
const processedBlockEntries = await Promise.all(
|
||||
Object.entries(blocksToProcess).map(async ([id, block]) => {
|
||||
@@ -358,16 +362,7 @@ export async function mergeSubblockStateAsync(
|
||||
return null
|
||||
}
|
||||
|
||||
let storedValue = null
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId]
|
||||
if (workflowValues?.[id]) {
|
||||
storedValue = workflowValues[id][subBlockId]
|
||||
}
|
||||
} else {
|
||||
storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
}
|
||||
const storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
|
||||
return [
|
||||
subBlockId,
|
||||
@@ -386,23 +381,6 @@ export async function mergeSubblockStateAsync(
|
||||
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
|
||||
) as Record<string, SubBlockState>
|
||||
|
||||
// Add any values that exist in the store but aren't in the block structure
|
||||
// This handles cases where block config has been updated but values still exist
|
||||
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId]
|
||||
const blockValues = workflowValues?.[id] || {}
|
||||
Object.entries(blockValues).forEach(([subBlockId, value]) => {
|
||||
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
|
||||
mergedSubBlocks[subBlockId] = {
|
||||
id: subBlockId,
|
||||
type: 'short-input',
|
||||
value: value as SubBlockState['value'],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Return the full block state with updated subBlocks (including orphaned values)
|
||||
return [
|
||||
id,
|
||||
|
||||
@@ -639,7 +639,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
|
||||
const newName = getUniqueBlockName(block.name, get().blocks)
|
||||
|
||||
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
|
||||
|
||||
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
|
||||
(acc, [subId, subBlock]) => ({
|
||||
@@ -668,7 +669,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
parallels: get().generateParallelBlocks(),
|
||||
}
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId) {
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "workflow" ADD COLUMN "sort_order" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX "workflow_folder_sort_idx" ON "workflow" USING btree ("folder_id","sort_order");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -981,13 +981,6 @@
|
||||
"when": 1768366574848,
|
||||
"tag": "0140_fuzzy_the_twelve",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 141,
|
||||
"version": "7",
|
||||
"when": 1768421319400,
|
||||
"tag": "0141_daffy_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -149,7 +149,6 @@ export const workflow = pgTable(
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
folderId: text('folder_id').references(() => workflowFolder.id, { onDelete: 'set null' }),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
color: text('color').notNull().default('#3972F6'),
|
||||
@@ -166,7 +165,6 @@ export const workflow = pgTable(
|
||||
userIdIdx: index('workflow_user_id_idx').on(table.userId),
|
||||
workspaceIdIdx: index('workflow_workspace_id_idx').on(table.workspaceId),
|
||||
userWorkspaceIdx: index('workflow_user_workspace_idx').on(table.userId, table.workspaceId),
|
||||
folderSortIdx: index('workflow_folder_sort_idx').on(table.folderId, table.sortOrder),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user