Compare commits

...

13 Commits

Author SHA1 Message Date
waleed
a2df89ee29 optimized 2026-01-14 14:00:19 -08:00
waleed
e350a9c611 cahnged color, removed flicker on folder container 2026-01-14 13:50:27 -08:00
waleed
df54029344 updated to use brand tertiary color, allow worfklows to be dropped above/below folders at the same level 2026-01-14 13:34:04 -08:00
Vikhyath Mondreti
2ffc55b92b fix bun lock 2026-01-14 12:12:46 -08:00
Vikhyath Mondreti
4b145a89a9 add migration 2026-01-14 12:09:04 -08:00
Vikhyath Mondreti
7d4671674c Merge remote-tracking branch 'origin/staging' into feat/reorder 2026-01-14 12:08:17 -08:00
Waleed
468ec2ea81 fix(terminal-colors): change algo to compute colors based on hash of execution id and pointer from bottom (#2817) 2026-01-14 12:06:02 -08:00
Vikhyath Mondreti
78d6082235 fix edge cases 2026-01-14 12:06:01 -08:00
Waleed
d7e0d9ba43 fix(i18n): update translations action to run once per week on sunday (#2816) 2026-01-14 11:23:26 -08:00
Waleed
51477c12cc fix(terminal): pop all entries from a single execution when the limit is exceeded (#2815) 2026-01-14 11:05:38 -08:00
Waleed
a3535639f1 fix(copilot): rewrote user input popover to optimize UX (#2814)
* fix(copilot): rewrote user input popover to optimize UX

* cleanup

* make keyboard and moues share state

* escape goes one level up on slash popover
2026-01-14 11:04:53 -08:00
Vikhyath Mondreti
f0d22246a7 progress 2026-01-14 10:33:19 -08:00
Vikhyath Mondreti
7dc4919220 feat(reorder): allow workflow/folder reordering 2026-01-13 12:18:14 -08:00
46 changed files with 13033 additions and 2292 deletions

View File

@@ -1,11 +1,10 @@
name: 'Auto-translate Documentation'
on:
push:
branches: [ staging ]
paths:
- 'apps/docs/content/docs/en/**'
- 'apps/docs/i18n.json'
schedule:
# Run every Sunday at midnight UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual triggers
permissions:
contents: write
@@ -20,6 +19,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: staging
token: ${{ secrets.GH_PAT }}
fetch-depth: 0
@@ -68,12 +68,11 @@ jobs:
title: "feat(i18n): update translations"
body: |
## Summary
Automated translation updates triggered by changes to documentation.
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
**Original trigger**: ${{ github.event.head_commit.message }}
**Commit**: ${{ github.sha }}
Automated weekly translation updates for documentation.
This PR was automatically created by the scheduled weekly i18n workflow, updating translations for all supported languages using Lingo.dev AI translation engine.
**Triggered**: Weekly scheduled run
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
## Type of Change
@@ -107,7 +106,7 @@ jobs:
## Screenshots/Videos
<!-- Translation changes are text-based - no visual changes expected -->
<!-- Reviewers should check the documentation site renders correctly for all languages -->
branch: auto-translate/staging-merge-${{ github.run_id }}
branch: auto-translate/weekly-${{ github.run_id }}
base: staging
labels: |
i18n
@@ -145,6 +144,8 @@ jobs:
bun install --frozen-lockfile
- name: Build documentation to verify translations
env:
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
run: |
cd apps/docs
bun run build
@@ -153,7 +154,7 @@ jobs:
run: |
cd apps/docs
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
echo "**Weekly scheduled translation run**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
en_count=$(find content/docs/en -name "*.mdx" | wc -l)

View File

@@ -14,6 +14,7 @@ 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
@@ -38,7 +39,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 })
}
const { name, color, isExpanded, parentId } = validationResult.data
const { name, color, isExpanded, parentId, sortOrder } = validationResult.data
// Verify the folder exists
const existingFolder = await db
@@ -81,12 +82,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
}
}
// Update the folder
const updates: any = { updatedAt: new Date() }
const updates: Record<string, unknown> = { 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)

View File

@@ -0,0 +1,91 @@
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 })
}
}

View File

@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const { name, workspaceId, parentId, color } = body
const { name, workspaceId, parentId, color, sortOrder: providedSortOrder } = body
if (!name || !workspaceId) {
return NextResponse.json({ error: 'Name and workspace ID are required' }, { status: 400 })
@@ -81,25 +81,26 @@ 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) => {
// 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)
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)
)
)
)
.orderBy(desc(workflowFolder.sortOrder))
.limit(1)
.orderBy(desc(workflowFolder.sortOrder))
.limit(1)
const nextSortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
}
// Insert the new folder within the same transaction
const [folder] = await tx
.insert(workflowFolder)
.values({
@@ -109,7 +110,7 @@ export async function POST(request: NextRequest) {
workspaceId,
parentId: parentId || null,
color: color || '#6B7280',
sortOrder: nextSortOrder,
sortOrder,
})
.returning()

View File

@@ -20,6 +20,7 @@ const UpdateWorkflowSchema = z.object({
description: z.string().optional(),
color: z.string().optional(),
folderId: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
})
/**
@@ -438,12 +439,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Build update object
const updateData: any = { updatedAt: new Date() }
const updateData: Record<string, unknown> = { 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

View File

@@ -0,0 +1,91 @@
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 })
}
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { and, eq, isNull, max } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -17,6 +17,7 @@ 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)
@@ -89,7 +90,14 @@ export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body)
const {
name,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
} = CreateWorkflowSchema.parse(body)
if (workspaceId) {
const workspacePermission = await getUserEntityPermissions(
@@ -127,11 +135,28 @@ 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,
@@ -152,6 +177,7 @@ export async function POST(req: NextRequest) {
color,
workspaceId,
folderId,
sortOrder,
createdAt: now,
updatedAt: now,
})

View File

@@ -13,6 +13,7 @@ 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
@@ -63,9 +64,9 @@ export async function POST(req: Request) {
}
try {
const { name } = createWorkspaceSchema.parse(await req.json())
const { name, skipDefaultWorkflow } = createWorkspaceSchema.parse(await req.json())
const newWorkspace = await createWorkspace(session.user.id, name)
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
return NextResponse.json({ workspace: newWorkspace })
} catch (error) {
@@ -80,7 +81,7 @@ async function createDefaultWorkspace(userId: string, userName?: string | null)
return createWorkspace(userId, workspaceName)
}
async function createWorkspace(userId: string, name: string) {
async function createWorkspace(userId: string, name: string, skipDefaultWorkflow = false) {
const workspaceId = crypto.randomUUID()
const workflowId = crypto.randomUUID()
const now = new Date()
@@ -97,7 +98,6 @@ async function createWorkspace(userId: string, name: string) {
updatedAt: now,
})
// Create admin permissions for the workspace owner
await tx.insert(permissions).values({
id: crypto.randomUUID(),
entityType: 'workspace' as const,
@@ -108,37 +108,41 @@ async function createWorkspace(userId: string, name: string) {
updatedAt: now,
})
// 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: {},
})
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: {},
})
}
logger.info(
`Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
skipDefaultWorkflow
? `Created workspace ${workspaceId} for user ${userId}`
: `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
})
const { workflowState } = buildDefaultWorkflowArtifacts()
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!skipDefaultWorkflow) {
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} with initial workflow:`, error)
logger.error(`Failed to create workspace ${workspaceId}:`, error)
throw error
}

View File

@@ -8,7 +8,6 @@ import { Button, Code, getCodeEditorProps, highlight, languages } from '@/compon
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
// Initialize all tool UI configs
import '@/lib/copilot/tools/client/init-tool-configs'
import {
getSubagentLabels as getSubagentLabelsFromConfig,

View File

@@ -1,6 +1,6 @@
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills'
export { MentionMenu } from './mention-menu/mention-menu'
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'
export { SlashMenu } from './slash-menu/slash-menu'
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'

View File

@@ -0,0 +1,151 @@
'use client'
import type { ComponentType, ReactNode, SVGProps } from 'react'
import { PopoverItem } from '@/components/emcn'
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
MENU_STATE_TEXT_CLASSES,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
const ICON_CONTAINER =
'relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
export function BlockIcon({
bgColor,
Icon,
}: {
bgColor?: string
Icon?: ComponentType<SVGProps<SVGSVGElement>>
}) {
return (
<div className={ICON_CONTAINER} style={{ background: bgColor || '#6B7280' }}>
{Icon && <Icon className='!h-[10px] !w-[10px] !text-white' />}
</div>
)
}
export function WorkflowColorDot({ color }: { color?: string }) {
return <div className={ICON_CONTAINER} style={{ backgroundColor: color || '#3972F6' }} />
}
interface FolderContentProps {
/** Folder ID to render content for */
folderId: MentionFolderId
/** Items to render (already filtered) */
items: any[]
/** Whether data is loading */
isLoading: boolean
/** Current search query (for determining empty vs no-match message) */
currentQuery: string
/** Currently active item index (for keyboard navigation) */
activeIndex: number
/** Callback when an item is clicked */
onItemClick: (item: any) => void
}
export function renderItemIcon(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'workflows':
return <WorkflowColorDot color={item.color} />
case 'blocks':
case 'workflow-blocks':
return <BlockIcon bgColor={item.bgColor} Icon={item.iconComponent} />
default:
return null
}
}
function renderItemSuffix(folderId: MentionFolderId, item: any): ReactNode {
switch (folderId) {
case 'templates':
return <span className='text-[10px] text-[var(--text-muted)]'>{item.stars}</span>
case 'logs':
return (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>{(item.trigger || 'manual').toLowerCase()}</span>
</>
)
default:
return null
}
}
export function FolderContent({
folderId,
items,
isLoading,
currentQuery,
activeIndex,
onItemClick,
}: FolderContentProps) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return (
<div className={MENU_STATE_TEXT_CLASSES}>
{currentQuery ? config.noMatchMessage : config.emptyMessage}
</div>
)
}
return (
<>
{items.map((item, index) => (
<PopoverItem
key={config.getId(item)}
onClick={() => onItemClick(item)}
data-idx={index}
active={index === activeIndex}
>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}
export function FolderPreviewContent({
folderId,
items,
isLoading,
onItemClick,
}: Omit<FolderContentProps, 'currentQuery' | 'activeIndex'>) {
const config = FOLDER_CONFIGS[folderId]
if (isLoading) {
return <div className={MENU_STATE_TEXT_CLASSES}>Loading...</div>
}
if (items.length === 0) {
return <div className={MENU_STATE_TEXT_CLASSES}>{config.emptyMessage}</div>
}
return (
<>
{items.map((item) => (
<PopoverItem key={config.getId(item)} onClick={() => onItemClick(item)}>
{renderItemIcon(folderId, item)}
<span className={folderId === 'logs' ? 'min-w-0 flex-1 truncate' : 'truncate'}>
{config.getLabel(item)}
</span>
{renderItemSuffix(folderId, item)}
</PopoverItem>
))}
</>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,47 +9,43 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import type { useMentionData } from '../../hooks/use-mention-data'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
const STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
const LoadingState = () => <div className={STATE_TEXT_CLASSES}>Loading...</div>
const EmptyState = ({ message }: { message: string }) => (
<div className={STATE_TEXT_CLASSES}>{message}</div>
)
import { formatCompactTimestamp } from '@/lib/core/utils/formatting'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
MENU_STATE_TEXT_CLASSES,
type MentionCategory,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useCaretViewport,
type useMentionData,
type useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
getFolderLoading as getFolderLoadingUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import { FolderContent, FolderPreviewContent, renderItemIcon } from './folder-content'
interface AggregatedItem {
id: string
label: string
category:
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
| 'docs'
category: MentionCategory
data: any
icon?: React.ReactNode
}
export interface MentionFolderNav {
isInFolder: boolean
currentFolder: string | null
openFolder: (id: string, title: string) => void
closeFolder: () => void
}
interface MentionMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
mentionData: ReturnType<typeof useMentionData>
@@ -64,170 +60,124 @@ interface MentionMenuProps {
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
onFolderNavChange?: (nav: MentionFolderNav) => void
}
export function MentionMenu({
type InsertHandlerMap = Record<MentionFolderId, (item: any) => void>
function MentionMenuContent({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
mentionMenuRef,
menuListRef,
getActiveMentionQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
setSubmenuActiveIndex,
} = mentionMenu
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Get the current query string after @
*/
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveMentionQueryAtPosition])
/**
* Collect and filter all available items based on query
*/
const isInFolder = currentFolder !== null
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
useEffect(() => {
setSubmenuActiveIndex(0)
}, [isInFolder, setSubmenuActiveIndex])
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
currentFolder,
openFolder,
closeFolder,
})
}
}, [onFolderNavChange, isInFolder, currentFolder, openFolder, closeFolder])
const insertHandlerMap = useMemo(
(): InsertHandlerMap => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
const getFolderLoading = useCallback(
(folderId: MentionFolderId) => getFolderLoadingUtil(mentionData, folderId),
[mentionData]
)
const getEnsureLoaded = useCallback(
(folderId: MentionFolderId) => getFolderEnsureLoadedUtil(mentionData, folderId),
[mentionData]
)
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
const getFilteredFolderItems = useCallback(
(folderId: MentionFolderId): any[] => {
return isInFolder ? filterFolderItems(folderId, currentQuery) : getFolderData(folderId)
},
[isInFolder, currentQuery, filterFolderItems, getFolderData]
)
const filteredAggregatedItems = useMemo(() => {
if (!currentQuery) return []
const items: AggregatedItem[] = []
const q = currentQuery.toLowerCase()
// Chats
mentionData.pastChats.forEach((chat) => {
const label = chat.title || 'New Chat'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `chat-${chat.id}`,
label,
category: 'chats',
data: chat,
})
}
})
for (const folderId of FOLDER_ORDER) {
const config = FOLDER_CONFIGS[folderId]
const folderData = getFolderData(folderId)
// Workflows
mentionData.workflows.forEach((wf) => {
const label = wf.name || 'Untitled Workflow'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `workflow-${wf.id}`,
label,
category: 'workflows',
data: wf,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
),
})
}
})
folderData.forEach((item) => {
if (config.filterFn(item, q)) {
items.push({
id: `${folderId}-${config.getId(item)}`,
label: config.getLabel(item),
category: folderId as MentionCategory,
data: item,
icon: renderItemIcon(folderId, item),
})
}
})
}
// Knowledge bases
mentionData.knowledgeBases.forEach((kb) => {
const label = kb.name || 'Untitled'
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `knowledge-${kb.id}`,
label,
category: 'knowledge',
data: kb,
})
}
})
// Blocks
mentionData.blocksList.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `block-${blk.id}`,
label,
category: 'blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Workflow blocks
mentionData.workflowBlocks.forEach((blk) => {
const label = blk.name || blk.id
if (label.toLowerCase().includes(currentQuery)) {
const Icon = blk.iconComponent
items.push({
id: `workflow-block-${blk.id}`,
label,
category: 'workflow-blocks',
data: blk,
icon: (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
),
})
}
})
// Templates
mentionData.templatesList.forEach((tpl) => {
const label = tpl.name
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `template-${tpl.id}`,
label,
category: 'templates',
data: tpl,
})
}
})
// Logs
mentionData.logsList.forEach((log) => {
const label = log.workflowName
if (label.toLowerCase().includes(currentQuery)) {
items.push({
id: `log-${log.id}`,
label,
category: 'logs',
data: log,
})
}
})
// Docs
if ('docs'.includes(currentQuery)) {
if ('docs'.includes(q)) {
items.push({
id: 'docs',
label: 'Docs',
@@ -237,107 +187,114 @@ export function MentionMenu({
}
return items
}, [currentQuery, mentionData])
}, [currentQuery, getFolderData])
/**
* Handle click on aggregated item
*/
const handleAggregatedItemClick = (item: AggregatedItem) => {
switch (item.category) {
case 'chats':
insertPastChatMention(item.data)
break
case 'workflows':
insertWorkflowMention(item.data)
break
case 'knowledge':
insertKnowledgeMention(item.data)
break
case 'blocks':
insertBlockMention(item.data)
break
case 'workflow-blocks':
insertWorkflowBlockMention(item.data)
break
case 'templates':
insertTemplateMention(item.data)
break
case 'logs':
insertLogMention(item.data)
break
case 'docs':
insertDocsMention()
break
}
}
// Open state derived directly from mention menu
const open = !!mentionMenu.showMentionMenu
// Show filtered aggregated view when there's a query
const showAggregatedView = currentQuery.length > 0
// Folder order for keyboard navigation - matches render order
const FOLDER_ORDER = [
'Chats', // 0
'Workflows', // 1
'Knowledge', // 2
'Blocks', // 3
'Workflow Blocks', // 4
'Templates', // 5
'Logs', // 6
'Docs', // 7
] as const
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
const handleAggregatedItemClick = useCallback(
(item: AggregatedItem) => {
if (item.category === 'docs') {
insertHandlers.insertDocsMention()
return
}
const handler = insertHandlerMap[item.category as MentionFolderId]
if (handler) {
handler(item.data)
}
},
[insertHandlerMap, insertHandlers]
)
return (
<Popover open={open} onOpenChange={() => {}}>
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<FolderContent
folderId={currentFolder as MentionFolderId}
items={getFilteredFolderItems(currentFolder as MentionFolderId)}
isLoading={getFolderLoading(currentFolder as MentionFolderId)}
currentQuery={currentQuery}
activeIndex={submenuActiveIndex}
onItemClick={insertHandlerMap[currentFolder as MentionFolderId]}
/>
) : showAggregatedView ? (
<>
{filteredAggregatedItems.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No results found</div>
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatCompactTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
<>
{FOLDER_ORDER.map((folderId, folderIndex) => {
const config = FOLDER_CONFIGS[folderId]
const ensureLoaded = getEnsureLoaded(folderId)
return (
<PopoverFolder
key={folderId}
id={folderId}
title={config.title}
onOpen={() => ensureLoaded?.()}
active={isInFolderNavigationMode && mentionActiveIndex === folderIndex}
data-idx={folderIndex}
>
<FolderPreviewContent
folderId={folderId}
items={getFolderData(folderId)}
isLoading={getFolderLoading(folderId)}
onItemClick={insertHandlerMap[folderId]}
/>
</PopoverFolder>
)
})}
<PopoverItem
rootOnly
onClick={() => insertHandlers.insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === FOLDER_ORDER.length}
data-idx={FOLDER_ORDER.length}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
)
}
export function MentionMenu({
mentionMenu,
mentionData,
message,
insertHandlers,
onFolderNavChange,
}: MentionMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const { caretViewport, side } = useCaretViewport({ textareaRef, message, caretPos })
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
<PopoverAnchor asChild>
<div
style={{
@@ -357,401 +314,19 @@ export function MentionMenu({
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{
width: `224px`,
}}
style={{ width: '224px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor ? (
// Submenu view - showing contents of a specific folder
<>
{openSubmenuFor === 'Chats' && (
<>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat, index) => (
<PopoverItem
key={chat.id}
onClick={() => insertPastChatMention(chat)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Workflows' && (
<>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf, index) => (
<PopoverItem
key={wf.id}
onClick={() => insertWorkflowMention(wf)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Knowledge' && (
<>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb, index) => (
<PopoverItem
key={kb.id}
onClick={() => insertKnowledgeMention(kb)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Blocks' && (
<>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Workflow Blocks' && (
<>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk, index) => {
const Icon = blk.iconComponent
return (
<PopoverItem
key={blk.id}
onClick={() => insertWorkflowBlockMention(blk)}
data-idx={index}
active={index === submenuActiveIndex}
>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</>
)}
{openSubmenuFor === 'Templates' && (
<>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl, index) => (
<PopoverItem
key={tpl.id}
onClick={() => insertTemplateMention(tpl)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</>
)}
{openSubmenuFor === 'Logs' && (
<>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log, index) => (
<PopoverItem
key={log.id}
onClick={() => insertLogMention(log)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</>
)}
</>
) : showAggregatedView ? (
// Aggregated filtered view
<>
{filteredAggregatedItems.length === 0 ? (
<EmptyState message='No results found' />
) : (
filteredAggregatedItems.map((item, index) => (
<PopoverItem
key={item.id}
onClick={() => handleAggregatedItemClick(item)}
data-idx={index}
active={index === submenuActiveIndex}
>
{item.icon}
<span className='flex-1 truncate'>{item.label}</span>
{item.category === 'logs' && (
<>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(item.data.createdAt)}
</span>
</>
)}
</PopoverItem>
))
)}
</>
) : (
// Folder navigation view
<>
<PopoverFolder
id='chats'
title='Chats'
onOpen={() => mentionData.ensurePastChatsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 0}
data-idx={0}
>
{mentionData.isLoadingPastChats ? (
<LoadingState />
) : mentionData.pastChats.length === 0 ? (
<EmptyState message='No past chats' />
) : (
mentionData.pastChats.map((chat) => (
<PopoverItem key={chat.id} onClick={() => insertPastChatMention(chat)}>
<span className='truncate'>{chat.title || 'New Chat'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='workflows'
title='All workflows'
onOpen={() => mentionData.ensureWorkflowsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 1}
data-idx={1}
>
{mentionData.isLoadingWorkflows ? (
<LoadingState />
) : mentionData.workflows.length === 0 ? (
<EmptyState message='No workflows' />
) : (
mentionData.workflows.map((wf) => (
<PopoverItem key={wf.id} onClick={() => insertWorkflowMention(wf)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ backgroundColor: wf.color || '#3972F6' }}
/>
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='knowledge'
title='Knowledge Bases'
onOpen={() => mentionData.ensureKnowledgeLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 2}
data-idx={2}
>
{mentionData.isLoadingKnowledge ? (
<LoadingState />
) : mentionData.knowledgeBases.length === 0 ? (
<EmptyState message='No knowledge bases' />
) : (
mentionData.knowledgeBases.map((kb) => (
<PopoverItem key={kb.id} onClick={() => insertKnowledgeMention(kb)}>
<span className='truncate'>{kb.name || 'Untitled'}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='blocks'
title='Blocks'
onOpen={() => mentionData.ensureBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 3}
data-idx={3}
>
{mentionData.isLoadingBlocks ? (
<LoadingState />
) : mentionData.blocksList.length === 0 ? (
<EmptyState message='No blocks found' />
) : (
mentionData.blocksList.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='workflow-blocks'
title='Workflow Blocks'
onOpen={() => mentionData.ensureWorkflowBlocksLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 4}
data-idx={4}
>
{mentionData.isLoadingWorkflowBlocks ? (
<LoadingState />
) : mentionData.workflowBlocks.length === 0 ? (
<EmptyState message='No blocks in this workflow' />
) : (
mentionData.workflowBlocks.map((blk) => {
const Icon = blk.iconComponent
return (
<PopoverItem key={blk.id} onClick={() => insertWorkflowBlockMention(blk)}>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: blk.bgColor || '#6B7280' }}
>
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate'>{blk.name || blk.id}</span>
</PopoverItem>
)
})
)}
</PopoverFolder>
<PopoverFolder
id='templates'
title='Templates'
onOpen={() => mentionData.ensureTemplatesLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 5}
data-idx={5}
>
{mentionData.isLoadingTemplates ? (
<LoadingState />
) : mentionData.templatesList.length === 0 ? (
<EmptyState message='No templates found' />
) : (
mentionData.templatesList.map((tpl) => (
<PopoverItem key={tpl.id} onClick={() => insertTemplateMention(tpl)}>
<span className='flex-1 truncate'>{tpl.name}</span>
<span className='text-[10px] text-[var(--text-muted)]'>{tpl.stars}</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverFolder
id='logs'
title='Logs'
onOpen={() => mentionData.ensureLogsLoaded()}
active={isInFolderNavigationMode && mentionActiveIndex === 6}
data-idx={6}
>
{mentionData.isLoadingLogs ? (
<LoadingState />
) : mentionData.logsList.length === 0 ? (
<EmptyState message='No executions found' />
) : (
mentionData.logsList.map((log) => (
<PopoverItem key={log.id} onClick={() => insertLogMention(log)}>
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='whitespace-nowrap text-[10px]'>
{formatTimestamp(log.createdAt)}
</span>
<span className='text-[10px] text-[var(--text-tertiary)]'>·</span>
<span className='text-[10px] capitalize'>
{(log.trigger || 'manual').toLowerCase()}
</span>
</PopoverItem>
))
)}
</PopoverFolder>
<PopoverItem
rootOnly
onClick={() => insertDocsMention()}
active={isInFolderNavigationMode && mentionActiveIndex === 7}
data-idx={7}
>
<span>Docs</span>
</PopoverItem>
</>
)}
</PopoverScrollArea>
<PopoverBackButton />
<MentionMenuContent
mentionMenu={mentionMenu}
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import {
Popover,
PopoverAnchor,
@@ -9,51 +9,57 @@ import {
PopoverFolder,
PopoverItem,
PopoverScrollArea,
usePopoverContext,
} from '@/components/emcn'
import {
ALL_SLASH_COMMANDS,
MENU_STATE_TEXT_CLASSES,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import { useCaretViewport } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { useMentionMenu } from '../../hooks/use-mention-menu'
const TOP_LEVEL_COMMANDS = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
] as const
const WEB_COMMANDS = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
export interface SlashFolderNav {
isInFolder: boolean
openWebFolder: () => void
closeFolder: () => void
}
interface SlashMenuProps {
mentionMenu: ReturnType<typeof useMentionMenu>
message: string
onSelectCommand: (command: string) => void
onFolderNavChange?: (nav: SlashFolderNav) => void
}
export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuProps) {
function SlashMenuContent({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { currentFolder, openFolder, closeFolder } = usePopoverContext()
const {
mentionMenuRef,
menuListRef,
getActiveSlashQueryAtPosition,
getCaretPos,
submenuActiveIndex,
mentionActiveIndex,
openSubmenuFor,
setOpenSubmenuFor,
setSubmenuActiveIndex,
} = mentionMenu
const caretPos = getCaretPos()
const currentQuery = useMemo(() => {
const caretPos = getCaretPos()
const active = getActiveSlashQueryAtPosition(caretPos, message)
return active?.query.trim().toLowerCase() || ''
}, [message, getCaretPos, getActiveSlashQueryAtPosition])
}, [message, caretPos, getActiveSlashQueryAtPosition])
const filteredCommands = useMemo(() => {
if (!currentQuery) return null
return ALL_COMMANDS.filter(
return ALL_SLASH_COMMANDS.filter(
(cmd) =>
cmd.id.toLowerCase().includes(currentQuery) ||
cmd.label.toLowerCase().includes(currentQuery)
@@ -61,52 +67,106 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
}, [currentQuery])
const showAggregatedView = currentQuery.length > 0
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
const isInFolder = currentFolder !== null
const isInFolderNavigationMode = !isInFolder && !showAggregatedView
const textareaEl = mentionMenu.textareaRef.current
if (!textareaEl) return null
useEffect(() => {
if (onFolderNavChange) {
onFolderNavChange({
isInFolder,
openWebFolder: () => {
openFolder('web', 'Web')
setSubmenuActiveIndex(0)
},
closeFolder: () => {
closeFolder()
setSubmenuActiveIndex(0)
},
})
}
}, [onFolderNavChange, isInFolder, openFolder, closeFolder, setSubmenuActiveIndex])
return (
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{isInFolder ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className={MENU_STATE_TEXT_CLASSES}>No commands found</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setSubmenuActiveIndex(0)}
active={isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
)
}
export function SlashMenu({
mentionMenu,
message,
onSelectCommand,
onFolderNavChange,
}: SlashMenuProps) {
const { mentionMenuRef, textareaRef, getCaretPos } = mentionMenu
const caretPos = getCaretPos()
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.wordWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const { caretViewport, side } = useCaretViewport({
textareaRef,
message,
caretPos,
})
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
if (!caretViewport) return null
return (
<Popover open={true} onOpenChange={() => {}}>
@@ -129,77 +189,18 @@ export function SlashMenu({ mentionMenu, message, onSelectCommand }: SlashMenuPr
collisionPadding={6}
maxHeight={360}
className='pointer-events-auto'
style={{
width: `180px`,
}}
style={{ width: '180px' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()}
>
<PopoverBackButton onClick={() => setOpenSubmenuFor(null)} />
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
{openSubmenuFor === 'Web' ? (
<>
{WEB_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</>
) : showAggregatedView ? (
<>
{filteredCommands && filteredCommands.length === 0 ? (
<div className='px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'>
No commands found
</div>
) : (
filteredCommands?.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={index === submenuActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))
)}
</>
) : (
<>
{TOP_LEVEL_COMMANDS.map((cmd, index) => (
<PopoverItem
key={cmd.id}
onClick={() => onSelectCommand(cmd.id)}
data-idx={index}
active={isInFolderNavigationMode && index === mentionActiveIndex}
>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
<PopoverFolder
id='web'
title='Web'
onOpen={() => setOpenSubmenuFor('Web')}
active={
isInFolderNavigationMode && mentionActiveIndex === TOP_LEVEL_COMMANDS.length
}
data-idx={TOP_LEVEL_COMMANDS.length}
>
{WEB_COMMANDS.map((cmd) => (
<PopoverItem key={cmd.id} onClick={() => onSelectCommand(cmd.id)}>
<span className='truncate'>{cmd.label}</span>
</PopoverItem>
))}
</PopoverFolder>
</>
)}
</PopoverScrollArea>
<PopoverBackButton />
<SlashMenuContent
mentionMenu={mentionMenu}
message={message}
onSelectCommand={onSelectCommand}
onFolderNavChange={onFolderNavChange}
/>
</PopoverContent>
</Popover>
)

View File

@@ -1,42 +1,245 @@
/**
* Constants for user input component
*/
import type { ChatContext } from '@/stores/panel'
/**
* Mention menu options in order (matches visual render order)
* Mention folder types
*/
export const MENTION_OPTIONS = [
'Chats',
'Workflows',
'Knowledge',
'Blocks',
'Workflow Blocks',
'Templates',
'Logs',
'Docs',
export type MentionFolderId =
| 'chats'
| 'workflows'
| 'knowledge'
| 'blocks'
| 'workflow-blocks'
| 'templates'
| 'logs'
/**
* Menu item category types for mention menu (includes folders + docs item)
*/
export type MentionCategory = MentionFolderId | 'docs'
/**
* Configuration interface for folder types
*/
export interface FolderConfig<TItem = any> {
/** Display title in menu */
title: string
/** Data source key in useMentionData return */
dataKey: string
/** Loading state key in useMentionData return */
loadingKey: string
/** Ensure loaded function key in useMentionData return (optional - some folders auto-load) */
ensureLoadedKey?: string
/** Extract label from an item */
getLabel: (item: TItem) => string
/** Extract unique ID from an item */
getId: (item: TItem) => string
/** Empty state message */
emptyMessage: string
/** No match message (when filtering) */
noMatchMessage: string
/** Filter function for matching query */
filterFn: (item: TItem, query: string) => boolean
/** Build the ChatContext object from an item */
buildContext: (item: TItem, workflowId?: string | null) => ChatContext
/** Whether to use insertAtCursor fallback when replaceActiveMentionWith fails */
useInsertFallback?: boolean
}
/**
* Configuration for all folder types in the mention menu
*/
export const FOLDER_CONFIGS: Record<MentionFolderId, FolderConfig> = {
chats: {
title: 'Chats',
dataKey: 'pastChats',
loadingKey: 'isLoadingPastChats',
ensureLoadedKey: 'ensurePastChatsLoaded',
getLabel: (item) => item.title || 'New Chat',
getId: (item) => item.id,
emptyMessage: 'No past chats',
noMatchMessage: 'No matching chats',
filterFn: (item, q) => (item.title || 'New Chat').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'past_chat',
chatId: item.id,
label: item.title || 'New Chat',
}),
useInsertFallback: false,
},
workflows: {
title: 'All workflows',
dataKey: 'workflows',
loadingKey: 'isLoadingWorkflows',
// No ensureLoadedKey - workflows auto-load from registry store
getLabel: (item) => item.name || 'Untitled Workflow',
getId: (item) => item.id,
emptyMessage: 'No workflows',
noMatchMessage: 'No matching workflows',
filterFn: (item, q) => (item.name || 'Untitled Workflow').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'workflow',
workflowId: item.id,
label: item.name || 'Untitled Workflow',
}),
useInsertFallback: true,
},
knowledge: {
title: 'Knowledge Bases',
dataKey: 'knowledgeBases',
loadingKey: 'isLoadingKnowledge',
ensureLoadedKey: 'ensureKnowledgeLoaded',
getLabel: (item) => item.name || 'Untitled',
getId: (item) => item.id,
emptyMessage: 'No knowledge bases',
noMatchMessage: 'No matching knowledge bases',
filterFn: (item, q) => (item.name || 'Untitled').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'knowledge',
knowledgeId: item.id,
label: item.name || 'Untitled',
}),
useInsertFallback: false,
},
blocks: {
title: 'Blocks',
dataKey: 'blocksList',
loadingKey: 'isLoadingBlocks',
ensureLoadedKey: 'ensureBlocksLoaded',
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks found',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'blocks',
blockIds: [item.id],
label: item.name || item.id,
}),
useInsertFallback: false,
},
'workflow-blocks': {
title: 'Workflow Blocks',
dataKey: 'workflowBlocks',
loadingKey: 'isLoadingWorkflowBlocks',
// No ensureLoadedKey - workflow blocks auto-sync from store
getLabel: (item) => item.name || item.id,
getId: (item) => item.id,
emptyMessage: 'No blocks in this workflow',
noMatchMessage: 'No matching blocks',
filterFn: (item, q) => (item.name || item.id).toLowerCase().includes(q),
buildContext: (item, workflowId) => ({
kind: 'workflow_block',
workflowId: workflowId || '',
blockId: item.id,
label: item.name || item.id,
}),
useInsertFallback: true,
},
templates: {
title: 'Templates',
dataKey: 'templatesList',
loadingKey: 'isLoadingTemplates',
ensureLoadedKey: 'ensureTemplatesLoaded',
getLabel: (item) => item.name || 'Untitled Template',
getId: (item) => item.id,
emptyMessage: 'No templates found',
noMatchMessage: 'No matching templates',
filterFn: (item, q) => (item.name || 'Untitled Template').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'templates',
templateId: item.id,
label: item.name || 'Untitled Template',
}),
useInsertFallback: false,
},
logs: {
title: 'Logs',
dataKey: 'logsList',
loadingKey: 'isLoadingLogs',
ensureLoadedKey: 'ensureLogsLoaded',
getLabel: (item) => item.workflowName,
getId: (item) => item.id,
emptyMessage: 'No executions found',
noMatchMessage: 'No matching executions',
filterFn: (item, q) =>
[item.workflowName, item.trigger || ''].join(' ').toLowerCase().includes(q),
buildContext: (item) => ({
kind: 'logs',
executionId: item.executionId || item.id,
label: item.workflowName,
}),
useInsertFallback: false,
},
}
/**
* Order of folders in the mention menu
*/
export const FOLDER_ORDER: MentionFolderId[] = [
'chats',
'workflows',
'knowledge',
'blocks',
'workflow-blocks',
'templates',
'logs',
]
/**
* Docs item configuration (special case - not a folder)
*/
export const DOCS_CONFIG = {
getLabel: () => 'Docs',
buildContext: (): ChatContext => ({ kind: 'docs', label: 'Docs' }),
} as const
/**
* Total number of items in root menu (folders + docs)
*/
export const ROOT_MENU_ITEM_COUNT = FOLDER_ORDER.length + 1
/**
* Slash command configuration
*/
export interface SlashCommand {
id: string
label: string
}
export const TOP_LEVEL_COMMANDS: readonly SlashCommand[] = [
{ id: 'fast', label: 'Fast' },
{ id: 'research', label: 'Research' },
{ id: 'superagent', label: 'Actions' },
] as const
export const WEB_COMMANDS: readonly SlashCommand[] = [
{ id: 'search', label: 'Search' },
{ id: 'read', label: 'Read' },
{ id: 'scrape', label: 'Scrape' },
{ id: 'crawl', label: 'Crawl' },
] as const
export const ALL_SLASH_COMMANDS: readonly SlashCommand[] = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
export const ALL_COMMAND_IDS = ALL_SLASH_COMMANDS.map((cmd) => cmd.id)
/**
* Get display label for a command ID
*/
export function getCommandDisplayLabel(commandId: string): string {
const command = ALL_SLASH_COMMANDS.find((cmd) => cmd.id === commandId)
return command?.label || commandId.charAt(0).toUpperCase() + commandId.slice(1)
}
/**
* Model configuration options
*/
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-sonnet', label: 'Claude 4 Sonnet' },
{ value: 'claude-4.5-haiku', label: 'Claude 4.5 Haiku' },
// { value: 'claude-4.1-opus', label: 'Claude 4.1 Opus' },
{ value: 'gpt-5.1-codex', label: 'GPT 5.1 Codex' },
// { value: 'gpt-5-codex', label: 'GPT 5 Codex' },
{ value: 'gpt-5.1-medium', label: 'GPT 5.1 Medium' },
// { value: 'gpt-5-fast', label: 'GPT 5 Fast' },
// { value: 'gpt-5', label: 'GPT 5' },
// { value: 'gpt-5.1-fast', label: 'GPT 5.1 Fast' },
// { value: 'gpt-5.1', label: 'GPT 5.1' },
// { value: 'gpt-5.1-high', label: 'GPT 5.1 High' },
// { value: 'gpt-5-high', label: 'GPT 5 High' },
// { value: 'gpt-4o', label: 'GPT 4o' },
// { value: 'gpt-4.1', label: 'GPT 4.1' },
// { value: 'o3', label: 'o3' },
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
] as const
@@ -49,3 +252,18 @@ export const NEAR_TOP_THRESHOLD = 300
* Scroll tolerance for mention menu positioning (in pixels)
*/
export const SCROLL_TOLERANCE = 8
/**
* Shared CSS classes for menu state text (loading, empty states)
*/
export const MENU_STATE_TEXT_CLASSES = 'px-[8px] py-[8px] text-[12px] text-[var(--text-muted)]'
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
export function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}

View File

@@ -1,3 +1,4 @@
export { useCaretViewport } from './use-caret-viewport'
export { useContextManagement } from './use-context-management'
export { useFileAttachments } from './use-file-attachments'
export { useMentionData } from './use-mention-data'

View File

@@ -0,0 +1,77 @@
import { useMemo } from 'react'
interface CaretViewportPosition {
left: number
top: number
}
interface UseCaretViewportResult {
caretViewport: CaretViewportPosition | null
side: 'top' | 'bottom'
}
interface UseCaretViewportProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
message: string
caretPos: number
}
/**
* Calculates the viewport position of the caret in a textarea using the mirror div technique.
* This hook memoizes the calculation to prevent unnecessary DOM manipulation on every render.
*/
export function useCaretViewport({
textareaRef,
message,
caretPos,
}: UseCaretViewportProps): UseCaretViewportResult {
return useMemo(() => {
const textareaEl = textareaRef.current
if (!textareaEl) {
return { caretViewport: null, side: 'bottom' as const }
}
const textareaRect = textareaEl.getBoundingClientRect()
const style = window.getComputedStyle(textareaEl)
const mirrorDiv = document.createElement('div')
mirrorDiv.style.position = 'absolute'
mirrorDiv.style.visibility = 'hidden'
mirrorDiv.style.whiteSpace = 'pre-wrap'
mirrorDiv.style.overflowWrap = 'break-word'
mirrorDiv.style.font = style.font
mirrorDiv.style.padding = style.padding
mirrorDiv.style.border = style.border
mirrorDiv.style.width = style.width
mirrorDiv.style.lineHeight = style.lineHeight
mirrorDiv.style.boxSizing = style.boxSizing
mirrorDiv.style.letterSpacing = style.letterSpacing
mirrorDiv.style.textTransform = style.textTransform
mirrorDiv.style.textIndent = style.textIndent
mirrorDiv.style.textAlign = style.textAlign
mirrorDiv.textContent = message.substring(0, caretPos)
const caretMarker = document.createElement('span')
caretMarker.style.display = 'inline-block'
caretMarker.style.width = '0px'
caretMarker.style.padding = '0'
caretMarker.style.border = '0'
mirrorDiv.appendChild(caretMarker)
document.body.appendChild(mirrorDiv)
const markerRect = caretMarker.getBoundingClientRect()
const mirrorRect = mirrorDiv.getBoundingClientRect()
document.body.removeChild(mirrorDiv)
const caretViewport = {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textareaEl.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textareaEl.scrollTop,
}
const margin = 8
const spaceBelow = window.innerHeight - caretViewport.top - margin
const side: 'top' | 'bottom' = spaceBelow >= caretViewport.top - margin ? 'bottom' : 'top'
return { caretViewport, side }
}, [textareaRef, message, caretPos])
}

View File

@@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
filterOutContext,
isContextAlreadySelected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseContextManagementProps {
@@ -35,53 +39,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
*/
const addContext = useCallback((context: ChatContext) => {
setSelectedContexts((prev) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
const exists = prev.some((c) => {
// Primary check: label collision
// This prevents duplicate @Label tokens which would break the overlay
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields based on kind
// This prevents the same entity from being added twice even with different labels
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true // Only one docs context allowed
}
if (c.kind === 'slash_command' && 'command' in context && 'command' in c) {
return c.command === (context as any).command
}
}
return false
})
if (exists) return prev
if (isContextAlreadySelected(context, prev)) return prev
return [...prev, context]
})
}, [])
@@ -92,38 +50,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
* @param contextToRemove - Context to remove
*/
const removeContext = useCallback((contextToRemove: ChatContext) => {
setSelectedContexts((prev) =>
prev.filter((c) => {
// Match by kind and specific ID fields
if (c.kind !== contextToRemove.kind) return true
switch (c.kind) {
case 'past_chat':
return (c as any).chatId !== (contextToRemove as any).chatId
case 'workflow':
return (c as any).workflowId !== (contextToRemove as any).workflowId
case 'blocks':
return (c as any).blockId !== (contextToRemove as any).blockId
case 'workflow_block':
return (
(c as any).workflowId !== (contextToRemove as any).workflowId ||
(c as any).blockId !== (contextToRemove as any).blockId
)
case 'knowledge':
return (c as any).knowledgeId !== (contextToRemove as any).knowledgeId
case 'templates':
return (c as any).templateId !== (contextToRemove as any).templateId
case 'logs':
return (c as any).executionId !== (contextToRemove as any).executionId
case 'docs':
return false // Remove docs (only one docs context)
case 'slash_command':
return (c as any).command !== (contextToRemove as any).command
default:
return c.label !== contextToRemove.label
}
})
)
setSelectedContexts((prev) => filterOutContext(prev, contextToRemove))
}, [])
/**

View File

@@ -83,6 +83,36 @@ interface UseMentionDataProps {
workspaceId: string
}
/**
* Return type for useMentionData hook
*/
export interface MentionDataReturn {
// Data arrays
pastChats: PastChat[]
workflows: WorkflowItem[]
knowledgeBases: KnowledgeItem[]
blocksList: BlockItem[]
workflowBlocks: WorkflowBlockItem[]
templatesList: TemplateItem[]
logsList: LogItem[]
// Loading states
isLoadingPastChats: boolean
isLoadingWorkflows: boolean
isLoadingKnowledge: boolean
isLoadingBlocks: boolean
isLoadingWorkflowBlocks: boolean
isLoadingTemplates: boolean
isLoadingLogs: boolean
// Ensure loaded functions
ensurePastChatsLoaded: () => Promise<void>
ensureKnowledgeLoaded: () => Promise<void>
ensureBlocksLoaded: () => Promise<void>
ensureTemplatesLoaded: () => Promise<void>
ensureLogsLoaded: () => Promise<void>
}
/**
* Custom hook to fetch and manage data for mention suggestions
* Loads data from APIs for chats, workflows, knowledge bases, blocks, templates, and logs
@@ -90,7 +120,7 @@ interface UseMentionDataProps {
* @param props - Configuration including workflow and workspace IDs
* @returns Mention data state and loading operations
*/
export function useMentionData(props: UseMentionDataProps) {
export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
const { workflowId, workspaceId } = props
const { config, isBlockAllowed } = usePermissionConfig()
@@ -104,7 +134,6 @@ export function useMentionData(props: UseMentionDataProps) {
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
// Reset blocks list when permission config changes
useEffect(() => {
setBlocksList([])
}, [config.allowedIntegrations])
@@ -118,12 +147,10 @@ export function useMentionData(props: UseMentionDataProps) {
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
// Only subscribe to block keys to avoid re-rendering on position updates
const blockKeys = useWorkflowStore(
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
// Use workflow registry as source of truth for workflows
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
@@ -131,7 +158,6 @@ export function useMentionData(props: UseMentionDataProps) {
hydrationPhase === 'metadata-loading' ||
hydrationPhase === 'state-loading'
// Convert registry workflows to mention format, filtered by workspace and sorted
const workflows: WorkflowItem[] = Object.values(registryWorkflows)
.filter((w) => w.workspaceId === workspaceId)
.sort((a, b) => {
@@ -219,14 +245,6 @@ export function useMentionData(props: UseMentionDataProps) {
}
}, [isLoadingPastChats, pastChats.length, workflowId])
/**
* Ensures workflows are loaded (now using registry store)
*/
const ensureWorkflowsLoaded = useCallback(() => {
// Workflows are now automatically loaded from the registry store
// No manual fetching needed
}, [])
/**
* Ensures knowledge bases are loaded
*/
@@ -348,18 +366,6 @@ export function useMentionData(props: UseMentionDataProps) {
}
}, [isLoadingLogs, logsList.length, workspaceId])
/**
* Ensures workflow blocks are loaded (synced from store)
*/
const ensureWorkflowBlocksLoaded = useCallback(async () => {
if (!workflowId) return
logger.debug('ensureWorkflowBlocksLoaded called', {
workflowId,
storeBlocksCount: blockKeys.length,
workflowBlocksCount: workflowBlocks.length,
})
}, [workflowId, blockKeys.length, workflowBlocks.length])
return {
// State
pastChats,
@@ -379,11 +385,9 @@ export function useMentionData(props: UseMentionDataProps) {
// Operations
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
ensureWorkflowBlocksLoaded,
}
}

View File

@@ -1,5 +1,12 @@
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
DOCS_CONFIG,
FOLDER_CONFIGS,
type FolderConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { isContextAlreadySelected } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
import type { ChatContext } from '@/stores/panel'
interface UseMentionInsertHandlersProps {
@@ -11,12 +18,12 @@ interface UseMentionInsertHandlersProps {
selectedContexts: ChatContext[]
/** Callback to update selected contexts */
onContextAdd: (context: ChatContext) => void
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav?: MentionFolderNav | null
}
/**
* Custom hook to provide insert handlers for different mention types.
* Consolidates the logic for inserting mentions and updating selected contexts.
* Prevents duplicate mentions from being inserted.
*
* @param props - Configuration object
* @returns Insert handler functions for each mention type
@@ -26,6 +33,7 @@ export function useMentionInsertHandlers({
workflowId,
selectedContexts,
onContextAdd,
mentionFolderNav,
}: UseMentionInsertHandlersProps) {
const {
replaceActiveMentionWith,
@@ -36,342 +44,94 @@ export function useMentionInsertHandlers({
} = mentionMenu
/**
* Checks if a context already exists in selected contexts
* CRITICAL: Prioritizes label checking to prevent token system breakage
*
* @param context - Context to check
* @returns True if context already exists or label is already used
* Closes all menus and resets state
*/
const isContextAlreadySelected = useCallback(
(context: ChatContext): boolean => {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
const closeMenus = useCallback(() => {
setShowMentionMenu(false)
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
}
setOpenSubmenuFor(null)
}, [setShowMentionMenu, setOpenSubmenuFor, mentionFolderNav])
const createInsertHandler = useCallback(
<TItem>(config: FolderConfig<TItem>) => {
return (item: TItem) => {
const label = config.getLabel(item)
const context = config.buildContext(item, workflowId)
if (isContextAlreadySelected(context, selectedContexts)) {
resetActiveMentionQuery()
closeMenus()
return
}
// Secondary check: exact duplicate by ID fields
if (c.kind === context.kind) {
if (c.kind === 'past_chat' && 'chatId' in context && 'chatId' in c) {
return c.chatId === (context as any).chatId
}
if (c.kind === 'workflow' && 'workflowId' in context && 'workflowId' in c) {
return c.workflowId === (context as any).workflowId
}
if (c.kind === 'blocks' && 'blockId' in context && 'blockId' in c) {
return c.blockId === (context as any).blockId
}
if (c.kind === 'workflow_block' && 'blockId' in context && 'blockId' in c) {
return (
c.workflowId === (context as any).workflowId && c.blockId === (context as any).blockId
)
}
if (c.kind === 'knowledge' && 'knowledgeId' in context && 'knowledgeId' in c) {
return c.knowledgeId === (context as any).knowledgeId
}
if (c.kind === 'templates' && 'templateId' in context && 'templateId' in c) {
return c.templateId === (context as any).templateId
}
if (c.kind === 'logs' && 'executionId' in context && 'executionId' in c) {
return c.executionId === (context as any).executionId
}
if (c.kind === 'docs') {
return true
if (config.useInsertFallback) {
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
}
} else {
replaceActiveMentionWith(label)
}
return false
})
},
[selectedContexts]
)
/**
* Inserts a past chat mention
*
* @param chat - Chat object to mention
*/
const insertPastChatMention = useCallback(
(chat: { id: string; title: string | null }) => {
const label = chat.title || 'New Chat'
const context = { kind: 'past_chat', chatId: chat.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text (e.g., "@Unti") before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
onContextAdd(context)
closeMenus()
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a workflow mention
*
* @param wf - Workflow object to mention
*/
const insertWorkflowMention = useCallback(
(wf: { id: string; name: string }) => {
const label = wf.name || 'Untitled Workflow'
const context = { kind: 'workflow', workflowId: wf.id, label } as ChatContext
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a knowledge base mention
*
* @param kb - Knowledge base object to mention
*/
const insertKnowledgeMention = useCallback(
(kb: { id: string; name: string }) => {
const label = kb.name || 'Untitled'
const context = { kind: 'knowledge', knowledgeId: kb.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a block mention
*
* @param blk - Block object to mention
*/
const insertBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name || blk.id
const context = { kind: 'blocks', blockId: blk.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a workflow block mention
*
* @param blk - Workflow block object to mention
*/
const insertWorkflowBlockMention = useCallback(
(blk: { id: string; name: string }) => {
const label = blk.name
const context = {
kind: 'workflow_block',
workflowId: workflowId as string,
blockId: blk.id,
label,
} as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
insertAtCursor,
workflowId,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a template mention
*
* @param tpl - Template object to mention
*/
const insertTemplateMention = useCallback(
(tpl: { id: string; name: string }) => {
const label = tpl.name || 'Untitled Template'
const context = { kind: 'templates', templateId: tpl.id, label } as any
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
]
)
/**
* Inserts a log mention
*
* @param log - Log object to mention
*/
const insertLogMention = useCallback(
(log: { id: string; executionId?: string; workflowName: string }) => {
const label = log.workflowName
const context = { kind: 'logs' as const, executionId: log.executionId, label }
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
return
}
replaceActiveMentionWith(label)
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
},
[
replaceActiveMentionWith,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
]
)
/**
* Inserts a docs mention
* Special handler for Docs (no item parameter, uses DOCS_CONFIG)
*/
const insertDocsMention = useCallback(() => {
const label = 'Docs'
const context = { kind: 'docs', label } as any
const label = DOCS_CONFIG.getLabel()
const context = DOCS_CONFIG.buildContext()
// Prevent duplicate insertion
if (isContextAlreadySelected(context)) {
// Clear the partial mention text before closing
if (isContextAlreadySelected(context, selectedContexts)) {
resetActiveMentionQuery()
setShowMentionMenu(false)
setOpenSubmenuFor(null)
closeMenus()
return
}
if (!replaceActiveMentionWith(label)) insertAtCursor(` @${label} `)
// Docs uses fallback insertion
if (!replaceActiveMentionWith(label)) {
insertAtCursor(` @${label} `)
}
onContextAdd(context)
setShowMentionMenu(false)
setOpenSubmenuFor(null)
closeMenus()
}, [
selectedContexts,
replaceActiveMentionWith,
insertAtCursor,
onContextAdd,
setShowMentionMenu,
setOpenSubmenuFor,
isContextAlreadySelected,
resetActiveMentionQuery,
closeMenus,
])
return {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
}
const handlers = useMemo(
() => ({
insertPastChatMention: createInsertHandler(FOLDER_CONFIGS.chats),
insertWorkflowMention: createInsertHandler(FOLDER_CONFIGS.workflows),
insertKnowledgeMention: createInsertHandler(FOLDER_CONFIGS.knowledge),
insertBlockMention: createInsertHandler(FOLDER_CONFIGS.blocks),
insertWorkflowBlockMention: createInsertHandler(FOLDER_CONFIGS['workflow-blocks']),
insertTemplateMention: createInsertHandler(FOLDER_CONFIGS.templates),
insertLogMention: createInsertHandler(FOLDER_CONFIGS.logs),
insertDocsMention,
}),
[createInsertHandler, insertDocsMention]
)
return handlers
}

View File

@@ -1,56 +1,19 @@
import { type KeyboardEvent, useCallback } from 'react'
import type { useMentionData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import { MENTION_OPTIONS } from '../constants'
/**
* Chat item for mention insertion
*/
interface ChatItem {
id: string
title: string | null
}
/**
* Workflow item for mention insertion
*/
interface WorkflowItem {
id: string
name: string
}
/**
* Knowledge base item for mention insertion
*/
interface KnowledgeItem {
id: string
name: string
}
/**
* Block item for mention insertion
*/
interface BlockItem {
id: string
name: string
}
/**
* Template item for mention insertion
*/
interface TemplateItem {
id: string
name: string
}
/**
* Log item for mention insertion
*/
interface LogItem {
id: string
executionId?: string
workflowName: string
}
import { type KeyboardEvent, useCallback, useMemo } from 'react'
import type { MentionFolderNav } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import {
FOLDER_CONFIGS,
FOLDER_ORDER,
type MentionFolderId,
ROOT_MENU_ITEM_COUNT,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type {
useMentionData,
useMentionMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import {
getFolderData as getFolderDataUtil,
getFolderEnsureLoaded as getFolderEnsureLoadedUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
interface UseMentionKeyboardProps {
/** Mention menu hook instance */
@@ -59,37 +22,34 @@ interface UseMentionKeyboardProps {
mentionData: ReturnType<typeof useMentionData>
/** Callback to insert specific mention types */
insertHandlers: {
insertPastChatMention: (chat: ChatItem) => void
insertWorkflowMention: (wf: WorkflowItem) => void
insertKnowledgeMention: (kb: KnowledgeItem) => void
insertBlockMention: (blk: BlockItem) => void
insertWorkflowBlockMention: (blk: BlockItem) => void
insertTemplateMention: (tpl: TemplateItem) => void
insertLogMention: (log: LogItem) => void
insertPastChatMention: (chat: any) => void
insertWorkflowMention: (wf: any) => void
insertKnowledgeMention: (kb: any) => void
insertBlockMention: (blk: any) => void
insertWorkflowBlockMention: (blk: any) => void
insertTemplateMention: (tpl: any) => void
insertLogMention: (log: any) => void
insertDocsMention: () => void
}
/** Folder navigation state exposed from MentionMenu via callback */
mentionFolderNav: MentionFolderNav | null
}
/**
* Custom hook to handle keyboard navigation in the mention menu.
* Manages Arrow Up/Down/Left/Right and Enter key navigation through menus and submenus.
*
* @param props - Configuration object
* @returns Keyboard handler for mention menu
*/
export function useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
}: UseMentionKeyboardProps) {
const {
showMentionMenu,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
setMentionActiveIndex,
setSubmenuActiveIndex,
setOpenSubmenuFor,
setSubmenuQueryStart,
getCaretPos,
getActiveMentionQueryAtPosition,
@@ -98,65 +58,101 @@ export function useMentionKeyboard({
scrollActiveItemIntoView,
} = mentionMenu
const {
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
} = mentionData
const currentFolder = mentionFolderNav?.currentFolder ?? null
const isInFolder = mentionFolderNav?.isInFolder ?? false
const {
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
} = insertHandlers
/**
* Map of folder IDs to insert handlers
*/
const insertHandlerMap = useMemo(
(): Record<MentionFolderId, (item: any) => void> => ({
chats: insertHandlers.insertPastChatMention,
workflows: insertHandlers.insertWorkflowMention,
knowledge: insertHandlers.insertKnowledgeMention,
blocks: insertHandlers.insertBlockMention,
'workflow-blocks': insertHandlers.insertWorkflowBlockMention,
templates: insertHandlers.insertTemplateMention,
logs: insertHandlers.insertLogMention,
}),
[insertHandlers]
)
/**
* Get data array for a folder from mentionData
*/
const getFolderData = useCallback(
(folderId: MentionFolderId) => getFolderDataUtil(mentionData, folderId),
[mentionData]
)
/**
* Filter items for a folder based on query using config's filterFn
*/
const filterFolderItems = useCallback(
(folderId: MentionFolderId, query: string): any[] => {
const config = FOLDER_CONFIGS[folderId]
const items = getFolderData(folderId)
if (!query) return items
const q = query.toLowerCase()
return items.filter((item) => config.filterFn(item, q))
},
[getFolderData]
)
/**
* Ensure data is loaded for a folder
*/
const ensureFolderLoaded = useCallback(
(folderId: MentionFolderId): void => {
const ensureFn = getFolderEnsureLoadedUtil(mentionData, folderId)
if (ensureFn) void ensureFn()
},
[mentionData]
)
/**
* Build aggregated list matching the portal's ordering
*/
const buildAggregatedList = useCallback(
(query: string) => {
(query: string): Array<{ type: MentionFolderId | 'docs'; value: any }> => {
const q = query.toLowerCase()
return [
...pastChats
.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
.map((c) => ({ type: 'Chats' as const, value: c })),
...workflows
.filter((w) => (w.name || 'Untitled Workflow').toLowerCase().includes(q))
.map((w) => ({ type: 'Workflows' as const, value: w })),
...knowledgeBases
.filter((k) => (k.name || 'Untitled').toLowerCase().includes(q))
.map((k) => ({ type: 'Knowledge' as const, value: k })),
...blocksList
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Blocks' as const, value: b })),
...workflowBlocks
.filter((b) => (b.name || b.id).toLowerCase().includes(q))
.map((b) => ({ type: 'Workflow Blocks' as const, value: b })),
...templatesList
.filter((t) => (t.name || 'Untitled Template').toLowerCase().includes(q))
.map((t) => ({ type: 'Templates' as const, value: t })),
...logsList
.filter((l) => (l.workflowName || 'Untitled Workflow').toLowerCase().includes(q))
.map((l) => ({ type: 'Logs' as const, value: l })),
]
const result: Array<{ type: MentionFolderId | 'docs'; value: any }> = []
for (const folderId of FOLDER_ORDER) {
const filtered = filterFolderItems(folderId, q)
filtered.forEach((item) => {
result.push({ type: folderId, value: item })
})
}
if ('docs'.includes(q)) {
result.push({ type: 'docs', value: null })
}
return result
},
[pastChats, workflows, knowledgeBases, blocksList, workflowBlocks, templatesList, logsList]
[filterFolderItems]
)
/**
* Generic navigation helper for navigating through items
*/
const navigateItems = useCallback(
(
direction: 'up' | 'down',
itemCount: number,
setIndex: (fn: (prev: number) => number) => void
) => {
setIndex((prev) => {
const last = Math.max(0, itemCount - 1)
if (itemCount === 0) return 0
const next =
direction === 'down' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
},
[scrollActiveItemIntoView]
)
/**
@@ -169,143 +165,36 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (!openSubmenuFor ? active?.query || '' : '').toLowerCase()
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
// When there's a query, we show aggregated filtered view (no folders)
const showAggregatedView = mainQ.length > 0
const aggregatedList = showAggregatedView ? buildAggregatedList(mainQ) : []
// When showing aggregated filtered view, navigate through the aggregated list
if (showAggregatedView && !openSubmenuFor) {
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, aggregatedList.length - 1)
if (aggregatedList.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
if (showAggregatedView && !isInFolder) {
const aggregatedList = buildAggregatedList(mainQ)
navigateItems(direction, aggregatedList.length, setSubmenuActiveIndex)
return true
}
// Handle submenu navigation
if (openSubmenuFor === 'Chats') {
if (currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
const q = getSubmenuQuery().toLowerCase()
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
setSubmenuActiveIndex((prev) => {
const last = Math.max(0, filtered.length - 1)
if (filtered.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
} else {
// Navigate through folder options when no query
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
setMentionActiveIndex((prev) => {
const last = Math.max(0, filteredMain.length - 1)
if (filteredMain.length === 0) return 0
const next =
e.key === 'ArrowDown' ? (prev >= last ? 0 : prev + 1) : prev <= 0 ? last : prev - 1
requestAnimationFrame(() => scrollActiveItemIntoView(next))
return next
})
const filtered = filterFolderItems(currentFolder as MentionFolderId, q)
navigateItems(direction, filtered.length, setSubmenuActiveIndex)
return true
}
navigateItems(direction, ROOT_MENU_ITEM_COUNT, setMentionActiveIndex)
return true
},
[
showMentionMenu,
openSubmenuFor,
mentionActiveIndex,
submenuActiveIndex,
isInFolder,
currentFolder,
buildAggregatedList,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
filterFolderItems,
navigateItems,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
scrollActiveItemIntoView,
setMentionActiveIndex,
setSubmenuActiveIndex,
]
@@ -316,65 +205,30 @@ export function useMentionKeyboard({
*/
const handleArrowRight = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowRight') return false
if (!showMentionMenu || e.key !== 'ArrowRight' || !mentionFolderNav) return false
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (active?.query || '').toLowerCase()
const showAggregatedView = mainQ.length > 0
// Don't handle arrow right in aggregated view (user is filtering, not navigating folders)
if (showAggregatedView) return false
if (mainQ.length > 0) return false
e.preventDefault()
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
if (selected === 'Chats') {
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
resetActiveMentionQuery()
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setSubmenuQueryStart(getCaretPos())
void ensurePastChatsLoaded()
} else if (selected === 'Workflows') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
ensureFolderLoaded(selectedFolderId)
}
return true
@@ -382,21 +236,13 @@ export function useMentionKeyboard({
[
showMentionMenu,
mentionActiveIndex,
openSubmenuFor,
mentionFolderNav,
getCaretPos,
getActiveMentionQueryAtPosition,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertDocsMention,
ensureFolderLoaded,
insertHandlers,
]
)
@@ -407,16 +253,16 @@ export function useMentionKeyboard({
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showMentionMenu || e.key !== 'ArrowLeft') return false
if (openSubmenuFor) {
if (isInFolder && mentionFolderNav) {
e.preventDefault()
setOpenSubmenuFor(null)
mentionFolderNav.closeFolder()
setSubmenuQueryStart(null)
return true
}
return false
},
[showMentionMenu, openSubmenuFor, setOpenSubmenuFor, setSubmenuQueryStart]
[showMentionMenu, isInFolder, mentionFolderNav, setSubmenuQueryStart]
)
/**
@@ -429,179 +275,74 @@ export function useMentionKeyboard({
e.preventDefault()
const caretPos = getCaretPos()
const active = getActiveMentionQueryAtPosition(caretPos)
const mainQ = (active?.query || '').toLowerCase()
const mainQ = (!isInFolder ? active?.query || '' : '').toLowerCase()
const showAggregatedView = mainQ.length > 0
const filteredMain = MENTION_OPTIONS.filter((o) => o.toLowerCase().includes(mainQ))
const selected = filteredMain[mentionActiveIndex]
// Handle selection in aggregated filtered view
if (showAggregatedView && !openSubmenuFor) {
if (showAggregatedView && !isInFolder) {
const aggregated = buildAggregatedList(mainQ)
const idx = Math.max(0, Math.min(submenuActiveIndex, aggregated.length - 1))
const chosen = aggregated[idx]
if (chosen) {
if (chosen.type === 'Chats') insertPastChatMention(chosen.value as ChatItem)
else if (chosen.type === 'Workflows') insertWorkflowMention(chosen.value as WorkflowItem)
else if (chosen.type === 'Knowledge')
insertKnowledgeMention(chosen.value as KnowledgeItem)
else if (chosen.type === 'Workflow Blocks')
insertWorkflowBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Blocks') insertBlockMention(chosen.value as BlockItem)
else if (chosen.type === 'Templates') insertTemplateMention(chosen.value as TemplateItem)
else if (chosen.type === 'Logs') insertLogMention(chosen.value as LogItem)
if (chosen.type === 'docs') {
insertHandlers.insertDocsMention()
} else {
const handler = insertHandlerMap[chosen.type]
handler(chosen.value)
}
}
return true
}
// Handle folder navigation when no query
if (!openSubmenuFor && selected === 'Chats') {
resetActiveMentionQuery()
setOpenSubmenuFor('Chats')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensurePastChatsLoaded()
} else if (openSubmenuFor === 'Chats') {
if (isInFolder && currentFolder && FOLDER_CONFIGS[currentFolder as MentionFolderId]) {
const folderId = currentFolder as MentionFolderId
const q = getSubmenuQuery().toLowerCase()
const filtered = pastChats.filter((c) => (c.title || 'New Chat').toLowerCase().includes(q))
const filtered = filterFolderItems(folderId, q)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertPastChatMention(chosen)
const handler = insertHandlerMap[folderId]
handler(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflows') {
return true
}
const isDocsSelected = mentionActiveIndex === FOLDER_ORDER.length
if (isDocsSelected) {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflows')
insertHandlers.insertDocsMention()
return true
}
const selectedFolderId = FOLDER_ORDER[mentionActiveIndex]
if (selectedFolderId && mentionFolderNav) {
const config = FOLDER_CONFIGS[selectedFolderId]
resetActiveMentionQuery()
mentionFolderNav.openFolder(selectedFolderId, config.title)
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowsLoaded()
} else if (openSubmenuFor === 'Workflows') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflows.filter((w) =>
(w.name || 'Untitled Workflow').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Knowledge') {
resetActiveMentionQuery()
setOpenSubmenuFor('Knowledge')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureKnowledgeLoaded()
} else if (openSubmenuFor === 'Knowledge') {
const q = getSubmenuQuery().toLowerCase()
const filtered = knowledgeBases.filter((k) =>
(k.name || 'Untitled').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertKnowledgeMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureBlocksLoaded()
} else if (openSubmenuFor === 'Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = blocksList.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Workflow Blocks') {
resetActiveMentionQuery()
setOpenSubmenuFor('Workflow Blocks')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureWorkflowBlocksLoaded()
} else if (openSubmenuFor === 'Workflow Blocks') {
const q = getSubmenuQuery().toLowerCase()
const filtered = workflowBlocks.filter((b) => (b.name || b.id).toLowerCase().includes(q))
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertWorkflowBlockMention(chosen)
setSubmenuQueryStart(null)
}
} else if (!openSubmenuFor && selected === 'Docs') {
resetActiveMentionQuery()
insertDocsMention()
} else if (!openSubmenuFor && selected === 'Templates') {
resetActiveMentionQuery()
setOpenSubmenuFor('Templates')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureTemplatesLoaded()
} else if (!openSubmenuFor && selected === 'Logs') {
resetActiveMentionQuery()
setOpenSubmenuFor('Logs')
setSubmenuActiveIndex(0)
setSubmenuQueryStart(getCaretPos())
void ensureLogsLoaded()
} else if (openSubmenuFor === 'Templates') {
const q = getSubmenuQuery().toLowerCase()
const filtered = templatesList.filter((t) =>
(t.name || 'Untitled Template').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertTemplateMention(chosen)
setSubmenuQueryStart(null)
}
} else if (openSubmenuFor === 'Logs') {
const q = getSubmenuQuery().toLowerCase()
const filtered = logsList.filter((l) =>
[l.workflowName, l.trigger || ''].join(' ').toLowerCase().includes(q)
)
if (filtered.length > 0) {
const chosen = filtered[Math.max(0, Math.min(submenuActiveIndex, filtered.length - 1))]
insertLogMention(chosen)
setSubmenuQueryStart(null)
}
ensureFolderLoaded(selectedFolderId)
}
return true
},
[
showMentionMenu,
openSubmenuFor,
isInFolder,
currentFolder,
mentionActiveIndex,
submenuActiveIndex,
mentionFolderNav,
buildAggregatedList,
pastChats,
workflows,
knowledgeBases,
blocksList,
workflowBlocks,
templatesList,
logsList,
filterFolderItems,
insertHandlerMap,
getCaretPos,
getActiveMentionQueryAtPosition,
getSubmenuQuery,
resetActiveMentionQuery,
setOpenSubmenuFor,
setSubmenuActiveIndex,
setSubmenuQueryStart,
ensurePastChatsLoaded,
ensureWorkflowsLoaded,
ensureKnowledgeLoaded,
ensureBlocksLoaded,
ensureWorkflowBlocksLoaded,
ensureTemplatesLoaded,
ensureLogsLoaded,
insertPastChatMention,
insertWorkflowMention,
insertKnowledgeMention,
insertBlockMention,
insertWorkflowBlockMention,
insertTemplateMention,
insertLogMention,
insertDocsMention,
ensureFolderLoaded,
insertHandlers,
]
)

View File

@@ -1,9 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { SCROLL_TOLERANCE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { ChatContext } from '@/stores/panel'
import { SCROLL_TOLERANCE } from '../constants'
const logger = createLogger('useMentionMenu')
interface UseMentionMenuProps {
/** Current message text */

View File

@@ -49,7 +49,6 @@ export function useTextareaAutoResize({
const styles = window.getComputedStyle(textarea)
// Copy all text rendering properties exactly (but NOT color - overlay needs visible text)
overlay.style.font = styles.font
overlay.style.fontSize = styles.fontSize
overlay.style.fontFamily = styles.fontFamily
@@ -66,7 +65,6 @@ export function useTextareaAutoResize({
overlay.style.textTransform = styles.textTransform
overlay.style.textIndent = styles.textIndent
// Copy box model properties exactly to ensure identical text flow
overlay.style.padding = styles.padding
overlay.style.paddingTop = styles.paddingTop
overlay.style.paddingRight = styles.paddingRight
@@ -80,7 +78,6 @@ export function useTextareaAutoResize({
overlay.style.border = styles.border
overlay.style.borderWidth = styles.borderWidth
// Copy text wrapping and breaking properties
overlay.style.whiteSpace = styles.whiteSpace
overlay.style.wordBreak = styles.wordBreak
overlay.style.wordWrap = styles.wordWrap
@@ -91,20 +88,17 @@ export function useTextareaAutoResize({
overlay.style.direction = styles.direction
overlay.style.hyphens = (styles as any).hyphens ?? ''
// Critical: Match dimensions exactly
const textareaWidth = textarea.clientWidth
const textareaHeight = textarea.clientHeight
overlay.style.width = `${textareaWidth}px`
overlay.style.height = `${textareaHeight}px`
// Match max-height behavior
const computedMaxHeight = styles.maxHeight
if (computedMaxHeight && computedMaxHeight !== 'none') {
overlay.style.maxHeight = computedMaxHeight
}
// Ensure scroll positions are perfectly synced
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
})
@@ -119,25 +113,20 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay) return
// Store current cursor position to determine if user is typing at the end
const cursorPos = textarea.selectionStart ?? 0
const isAtEnd = cursorPos === message.length
const wasScrolledToBottom =
textarea.scrollHeight - textarea.scrollTop - textarea.clientHeight < 5
// Reset height to auto to get proper scrollHeight
textarea.style.height = 'auto'
overlay.style.height = 'auto'
// Force a reflow to ensure accurate scrollHeight
void textarea.offsetHeight
void overlay.offsetHeight
// Get the scroll height (this includes all content, including trailing newlines)
const scrollHeight = textarea.scrollHeight
const nextHeight = Math.min(scrollHeight, MAX_TEXTAREA_HEIGHT)
// Apply height to BOTH elements simultaneously
const heightString = `${nextHeight}px`
const overflowString = scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'
@@ -146,22 +135,18 @@ export function useTextareaAutoResize({
overlay.style.height = heightString
overlay.style.overflowY = overflowString
// Force another reflow after height change
void textarea.offsetHeight
void overlay.offsetHeight
// Maintain scroll behavior: if user was at bottom or typing at end, keep them at bottom
if ((isAtEnd || wasScrolledToBottom) && scrollHeight > nextHeight) {
const scrollValue = scrollHeight
textarea.scrollTop = scrollValue
overlay.scrollTop = scrollValue
} else {
// Otherwise, sync scroll positions
overlay.scrollTop = textarea.scrollTop
overlay.scrollLeft = textarea.scrollLeft
}
// Sync all other styles after height change
syncOverlayStyles.current()
}, [message, selectedContexts, textareaRef])
@@ -192,19 +177,15 @@ export function useTextareaAutoResize({
const overlay = overlayRef.current
if (!textarea || !overlay || !containerRef || typeof window === 'undefined') return
// Initial sync
syncOverlayStyles.current()
// Observe the CONTAINER - when pills wrap, container height changes
if (typeof ResizeObserver !== 'undefined' && !containerResizeObserverRef.current) {
containerResizeObserverRef.current = new ResizeObserver(() => {
// Container size changed (pills wrapped) - sync immediately
syncOverlayStyles.current()
})
containerResizeObserverRef.current.observe(containerRef)
}
// ALSO observe the textarea for its own size changes
if (typeof ResizeObserver !== 'undefined' && !textareaResizeObserverRef.current) {
textareaResizeObserverRef.current = new ResizeObserver(() => {
syncOverlayStyles.current()
@@ -212,7 +193,6 @@ export function useTextareaAutoResize({
textareaResizeObserverRef.current.observe(textarea)
}
// Setup MutationObserver to detect style changes
const mutationObserver = new MutationObserver(() => {
syncOverlayStyles.current()
})
@@ -221,11 +201,9 @@ export function useTextareaAutoResize({
attributeFilter: ['style', 'class'],
})
// Listen to window resize events (for browser window resizing)
const handleResize = () => syncOverlayStyles.current()
window.addEventListener('resize', handleResize)
// Cleanup
return () => {
mutationObserver.disconnect()
window.removeEventListener('resize', handleResize)

View File

@@ -18,12 +18,21 @@ import { cn } from '@/lib/core/utils/cn'
import {
AttachedFilesDisplay,
ContextPills,
type MentionFolderNav,
MentionMenu,
ModelSelector,
ModeSelector,
type SlashFolderNav,
SlashMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
import { NEAR_TOP_THRESHOLD } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
ALL_COMMAND_IDS,
getCommandDisplayLabel,
getNextIndex,
NEAR_TOP_THRESHOLD,
TOP_LEVEL_COMMANDS,
WEB_COMMANDS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import {
useContextManagement,
useFileAttachments,
@@ -40,24 +49,6 @@ import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('CopilotUserInput')
const TOP_LEVEL_COMMANDS = ['fast', 'research', 'superagent'] as const
const WEB_COMMANDS = ['search', 'read', 'scrape', 'crawl'] as const
const ALL_COMMANDS = [...TOP_LEVEL_COMMANDS, ...WEB_COMMANDS]
const COMMAND_DISPLAY_LABELS: Record<string, string> = {
superagent: 'Actions',
}
/**
* Calculates the next index for circular navigation (wraps around at bounds)
*/
function getNextIndex(current: number, direction: 'up' | 'down', maxIndex: number): number {
if (direction === 'down') {
return current >= maxIndex ? 0 : current + 1
}
return current <= 0 ? maxIndex : current - 1
}
interface UserInputProps {
onSubmit: (
message: string,
@@ -144,6 +135,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [inputContainerRef, setInputContainerRef] = useState<HTMLDivElement | null>(null)
const [showSlashMenu, setShowSlashMenu] = useState(false)
const [slashFolderNav, setSlashFolderNav] = useState<SlashFolderNav | null>(null)
const [mentionFolderNav, setMentionFolderNav] = useState<MentionFolderNav | null>(null)
const message = controlledValue !== undefined ? controlledValue : internalMessage
const setMessage =
@@ -198,12 +191,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
workflowId: workflowId || null,
selectedContexts: contextManagement.selectedContexts,
onContextAdd: contextManagement.addContext,
mentionFolderNav,
})
const mentionKeyboard = useMentionKeyboard({
mentionMenu,
mentionData,
insertHandlers,
mentionFolderNav,
})
useImperativeHandle(
@@ -222,13 +217,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[mentionMenu.textareaRef]
)
useEffect(() => {
if (workflowId) {
void mentionData.ensureWorkflowsLoaded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflowId])
useEffect(() => {
const checkPosition = () => {
if (containerRef) {
@@ -264,7 +252,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}, [mentionMenu.showMentionMenu, containerRef])
useEffect(() => {
if (!mentionMenu.showMentionMenu || mentionMenu.openSubmenuFor) {
if (!mentionMenu.showMentionMenu || mentionFolderNav?.isInFolder) {
return
}
@@ -275,8 +263,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (q && q.length > 0) {
void mentionData.ensurePastChatsLoaded()
void mentionData.ensureWorkflowsLoaded()
void mentionData.ensureWorkflowBlocksLoaded()
// workflows and workflow-blocks auto-load from stores
void mentionData.ensureKnowledgeLoaded()
void mentionData.ensureBlocksLoaded()
void mentionData.ensureTemplatesLoaded()
@@ -286,15 +273,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.showMentionMenu, mentionMenu.openSubmenuFor, message])
}, [mentionMenu.showMentionMenu, mentionFolderNav?.isInFolder, message])
useEffect(() => {
if (mentionMenu.openSubmenuFor) {
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(0))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionMenu.openSubmenuFor])
}, [mentionFolderNav?.isInFolder])
const handleSubmit = useCallback(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
@@ -372,8 +359,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const handleSlashCommandSelect = useCallback(
(command: string) => {
const displayLabel =
COMMAND_DISPLAY_LABELS[command] || command.charAt(0).toUpperCase() + command.slice(1)
const displayLabel = getCommandDisplayLabel(command)
mentionMenu.replaceActiveSlashWith(displayLabel)
contextManagement.addContext({
kind: 'slash_command',
@@ -391,9 +377,11 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && (mentionMenu.showMentionMenu || showSlashMenu)) {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
if (mentionFolderNav?.isInFolder) {
mentionFolderNav.closeFolder()
mentionMenu.setSubmenuQueryStart(null)
} else if (slashFolderNav?.isInFolder) {
slashFolderNav.closeFolder()
} else {
mentionMenu.closeMentionMenu()
setShowSlashMenu(false)
@@ -407,18 +395,19 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const direction = e.key === 'ArrowDown' ? 'down' : 'up'
const isInFolder = slashFolderNav?.isInFolder ?? false
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (mentionMenu.openSubmenuFor === 'Web') {
if (isInFolder) {
mentionMenu.setSubmenuActiveIndex((prev) => {
const next = getNextIndex(prev, direction, WEB_COMMANDS.length - 1)
requestAnimationFrame(() => mentionMenu.scrollActiveItemIntoView(next))
return next
})
} else if (showAggregatedView) {
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
mentionMenu.setSubmenuActiveIndex((prev) => {
if (filtered.length === 0) return 0
const next = getNextIndex(prev, direction, filtered.length - 1)
@@ -437,10 +426,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'ArrowRight') {
e.preventDefault()
if (!showAggregatedView && !mentionMenu.openSubmenuFor) {
if (!showAggregatedView && !isInFolder) {
if (mentionMenu.mentionActiveIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
slashFolderNav?.openWebFolder()
}
}
return
@@ -448,8 +436,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (mentionMenu.openSubmenuFor) {
mentionMenu.setOpenSubmenuFor(null)
if (isInFolder) {
slashFolderNav?.closeFolder()
}
return
}
@@ -466,13 +454,14 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const activeSlash = mentionMenu.getActiveSlashQueryAtPosition(caretPos, message)
const query = activeSlash?.query.trim().toLowerCase() || ''
const showAggregatedView = query.length > 0
const isInFolder = slashFolderNav?.isInFolder ?? false
if (mentionMenu.openSubmenuFor === 'Web') {
if (isInFolder) {
const selectedCommand =
WEB_COMMANDS[mentionMenu.submenuActiveIndex] || WEB_COMMANDS[0]
WEB_COMMANDS[mentionMenu.submenuActiveIndex]?.id || WEB_COMMANDS[0].id
handleSlashCommandSelect(selectedCommand)
} else if (showAggregatedView) {
const filtered = ALL_COMMANDS.filter((cmd) => cmd.includes(query))
const filtered = ALL_COMMAND_IDS.filter((cmd) => cmd.includes(query))
if (filtered.length > 0) {
const selectedCommand = filtered[mentionMenu.submenuActiveIndex] || filtered[0]
handleSlashCommandSelect(selectedCommand)
@@ -480,10 +469,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
} else {
const selectedIndex = mentionMenu.mentionActiveIndex
if (selectedIndex < TOP_LEVEL_COMMANDS.length) {
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex])
handleSlashCommandSelect(TOP_LEVEL_COMMANDS[selectedIndex].id)
} else if (selectedIndex === TOP_LEVEL_COMMANDS.length) {
mentionMenu.setOpenSubmenuFor('Web')
mentionMenu.setSubmenuActiveIndex(0)
slashFolderNav?.openWebFolder()
}
}
return
@@ -568,6 +556,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
message,
mentionTokensWithContext,
showSlashMenu,
slashFolderNav,
mentionFolderNav,
]
)
@@ -586,7 +576,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setShowSlashMenu(false)
mentionMenu.setShowMentionMenu(true)
mentionMenu.setInAggregated(false)
if (mentionMenu.openSubmenuFor) {
if (mentionFolderNav?.isInFolder) {
mentionMenu.setSubmenuActiveIndex(0)
} else {
mentionMenu.setMentionActiveIndex(0)
@@ -605,7 +595,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
setShowSlashMenu(false)
}
},
[setMessage, mentionMenu, disableMentions]
[setMessage, mentionMenu, disableMentions, mentionFolderNav]
)
const handleSelectAdjust = useCallback(() => {
@@ -838,6 +828,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionData={mentionData}
message={message}
insertHandlers={insertHandlers}
onFolderNavChange={setMentionFolderNav}
/>,
document.body
)}
@@ -850,6 +841,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
mentionMenu={mentionMenu}
message={message}
onSelectCommand={handleSlashCommandSelect}
onFolderNavChange={setSlashFolderNav}
/>,
document.body
)}

View File

@@ -0,0 +1,149 @@
import {
FOLDER_CONFIGS,
type MentionFolderId,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants'
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
import type { ChatContext } from '@/stores/panel'
/**
* Gets the data array for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
* Returns any[] since item types vary by folder and are used with dynamic config.filterFn
*/
export function getFolderData(mentionData: MentionDataReturn, folderId: MentionFolderId): any[] {
const config = FOLDER_CONFIGS[folderId]
return (mentionData[config.dataKey as keyof MentionDataReturn] as any[]) || []
}
/**
* Gets the loading state for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderLoading(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): boolean {
const config = FOLDER_CONFIGS[folderId]
return mentionData[config.loadingKey as keyof MentionDataReturn] as boolean
}
/**
* Gets the ensure loaded function for a folder ID from mentionData.
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
*/
export function getFolderEnsureLoaded(
mentionData: MentionDataReturn,
folderId: MentionFolderId
): (() => Promise<void>) | undefined {
const config = FOLDER_CONFIGS[folderId]
if (!config.ensureLoadedKey) return undefined
return mentionData[config.ensureLoadedKey as keyof MentionDataReturn] as
| (() => Promise<void>)
| undefined
}
/**
* Extract specific ChatContext types for type-safe narrowing
*/
type PastChatContext = Extract<ChatContext, { kind: 'past_chat' }>
type WorkflowContext = Extract<ChatContext, { kind: 'workflow' }>
type CurrentWorkflowContext = Extract<ChatContext, { kind: 'current_workflow' }>
type BlocksContext = Extract<ChatContext, { kind: 'blocks' }>
type WorkflowBlockContext = Extract<ChatContext, { kind: 'workflow_block' }>
type KnowledgeContext = Extract<ChatContext, { kind: 'knowledge' }>
type TemplatesContext = Extract<ChatContext, { kind: 'templates' }>
type LogsContext = Extract<ChatContext, { kind: 'logs' }>
type SlashCommandContext = Extract<ChatContext, { kind: 'slash_command' }>
/**
* Checks if two contexts of the same kind are equal by their ID fields.
* Assumes c.kind === context.kind (must be checked before calling).
*/
export function areContextsEqual(c: ChatContext, context: ChatContext): boolean {
switch (c.kind) {
case 'past_chat': {
const ctx = context as PastChatContext
return c.chatId === ctx.chatId
}
case 'workflow': {
const ctx = context as WorkflowContext
return c.workflowId === ctx.workflowId
}
case 'current_workflow': {
const ctx = context as CurrentWorkflowContext
return c.workflowId === ctx.workflowId
}
case 'blocks': {
const ctx = context as BlocksContext
const existingIds = c.blockIds || []
const newIds = ctx.blockIds || []
return existingIds.some((id) => newIds.includes(id))
}
case 'workflow_block': {
const ctx = context as WorkflowBlockContext
return c.workflowId === ctx.workflowId && c.blockId === ctx.blockId
}
case 'knowledge': {
const ctx = context as KnowledgeContext
return c.knowledgeId === ctx.knowledgeId
}
case 'templates': {
const ctx = context as TemplatesContext
return c.templateId === ctx.templateId
}
case 'logs': {
const ctx = context as LogsContext
return c.executionId === ctx.executionId
}
case 'docs':
return true // Only one docs context allowed
case 'slash_command': {
const ctx = context as SlashCommandContext
return c.command === ctx.command
}
default:
return false
}
}
/**
* Removes a context from a list, returning a new filtered list.
*/
export function filterOutContext(
contexts: ChatContext[],
contextToRemove: ChatContext
): ChatContext[] {
return contexts.filter((c) => {
if (c.kind !== contextToRemove.kind) return true
return !areContextsEqual(c, contextToRemove)
})
}
/**
* Checks if a context already exists in selected contexts.
*
* The token system uses @label format, so we cannot have duplicate labels
* regardless of kind or ID differences.
*
* @param context - Context to check
* @param selectedContexts - Currently selected contexts
* @returns True if context already exists or label is already used
*/
export function isContextAlreadySelected(
context: ChatContext,
selectedContexts: ChatContext[]
): boolean {
return selectedContexts.some((c) => {
// CRITICAL: Check label collision FIRST
// The token system uses @label format, so we cannot have duplicate labels
// regardless of kind or ID differences
if (c.label && context.label && c.label === context.label) {
return true
}
// Secondary check: exact duplicate by ID fields
if (c.kind !== context.kind) return false
return areContextsEqual(c, context)
})
}

View File

@@ -36,6 +36,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
@@ -82,18 +83,6 @@ const COLUMN_WIDTHS = {
OUTPUT_PANEL: 'w-[400px]',
} as const
/**
* Color palette for run IDs - matching code syntax highlighting colors
*/
const RUN_ID_COLORS = [
{ text: '#4ADE80' }, // Green
{ text: '#F472B6' }, // Pink
{ text: '#60C5FF' }, // Blue
{ text: '#FF8533' }, // Orange
{ text: '#C084FC' }, // Purple
{ text: '#FCD34D' }, // Yellow
] as const
/**
* Shared styling constants
*/
@@ -183,22 +172,6 @@ const ToggleButton = ({
</Button>
)
/**
* Formats timestamp to H:MM:SS AM/PM TZ format
*/
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp)
const fullString = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: 'short',
})
// Format: "5:54:55 PM PST" - return as is
return fullString
}
/**
* Truncates execution ID for display as run ID
*/
@@ -208,16 +181,25 @@ const formatRunId = (executionId?: string): string => {
}
/**
* Gets color for a run ID based on its index in the execution ID order map
* Run ID colors
*/
const getRunIdColor = (
executionId: string | undefined,
executionIdOrderMap: Map<string, number>
) => {
const RUN_ID_COLORS = [
'#4ADE80', // Green
'#F472B6', // Pink
'#60C5FF', // Blue
'#FF8533', // Orange
'#C084FC', // Purple
'#EAB308', // Yellow
'#2DD4BF', // Teal
'#FB7185', // Rose
] as const
/**
* Gets color for a run ID from the precomputed color map.
*/
const getRunIdColor = (executionId: string | undefined, colorMap: Map<string, string>) => {
if (!executionId) return null
const colorIndex = executionIdOrderMap.get(executionId)
if (colorIndex === undefined) return null
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
return colorMap.get(executionId) ?? null
}
/**
@@ -464,25 +446,52 @@ export function Terminal() {
}, [allWorkflowEntries])
/**
* Create stable execution ID to color index mapping based on order of first appearance.
* Once an execution ID is assigned a color index, it keeps that index.
* Uses all workflow entries to maintain consistent colors regardless of active filters.
* Track color offset - increments when old executions are trimmed
* so remaining executions keep their colors.
*/
const executionIdOrderMap = useMemo(() => {
const orderMap = new Map<string, number>()
let colorIndex = 0
const colorStateRef = useRef<{ executionIds: string[]; offset: number }>({
executionIds: [],
offset: 0,
})
// Process entries in reverse order (oldest first) since entries array is newest-first
// Use allWorkflowEntries to ensure colors remain consistent when filters change
/**
* Compute colors for each execution ID using sequential assignment.
* Colors cycle through RUN_ID_COLORS based on position + offset.
* When old executions are trimmed, offset increments to preserve colors.
*/
const executionColorMap = useMemo(() => {
const currentIds: string[] = []
const seen = new Set<string>()
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
const entry = allWorkflowEntries[i]
if (entry.executionId && !orderMap.has(entry.executionId)) {
orderMap.set(entry.executionId, colorIndex)
colorIndex++
const execId = allWorkflowEntries[i].executionId
if (execId && !seen.has(execId)) {
currentIds.push(execId)
seen.add(execId)
}
}
return orderMap
const { executionIds: prevIds, offset: prevOffset } = colorStateRef.current
let newOffset = prevOffset
if (prevIds.length > 0 && currentIds.length > 0) {
const currentOldest = currentIds[0]
if (prevIds[0] !== currentOldest) {
const trimmedCount = prevIds.indexOf(currentOldest)
if (trimmedCount > 0) {
newOffset = (prevOffset + trimmedCount) % RUN_ID_COLORS.length
}
}
}
const colorMap = new Map<string, string>()
for (let i = 0; i < currentIds.length; i++) {
const colorIndex = (newOffset + i) % RUN_ID_COLORS.length
colorMap.set(currentIds[i], RUN_ID_COLORS[colorIndex])
}
colorStateRef.current = { executionIds: currentIds, offset: newOffset }
return colorMap
}, [allWorkflowEntries])
/**
@@ -1128,7 +1137,7 @@ export function Terminal() {
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueRunIds.map((runId, index) => {
const isSelected = filters.runIds.has(runId)
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
const runIdColor = getRunIdColor(runId, executionColorMap)
return (
<PopoverItem
@@ -1139,7 +1148,7 @@ export function Terminal() {
>
<span
className='flex-1 font-mono text-[12px]'
style={{ color: runIdColor?.text || '#D2D2D2' }}
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(runId)}
</span>
@@ -1335,7 +1344,7 @@ export function Terminal() {
const statusInfo = getStatusInfo(entry.success, entry.error)
const isSelected = selectedEntry?.id === entry.id
const BlockIcon = getBlockIcon(entry.blockType)
const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap)
const runIdColor = getRunIdColor(entry.executionId, executionColorMap)
return (
<div
@@ -1385,7 +1394,7 @@ export function Terminal() {
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
style={{ color: runIdColor?.text || '#D2D2D2' }}
style={{ color: runIdColor || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
@@ -1411,7 +1420,7 @@ export function Terminal() {
ROW_TEXT_CLASS
)}
>
{formatTimestamp(entry.timestamp)}
{formatTimeWithSeconds(new Date(entry.timestamp))}
</span>
</div>
)

View File

@@ -29,6 +29,16 @@ import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
let EMPTY_DRAG_IMAGE: HTMLImageElement | null = null
function getEmptyDragImage(): HTMLImageElement {
if (!EMPTY_DRAG_IMAGE && typeof window !== 'undefined') {
EMPTY_DRAG_IMAGE = new Image(1, 1)
EMPTY_DRAG_IMAGE.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
}
return EMPTY_DRAG_IMAGE!
}
interface FolderItemProps {
folder: FolderTreeNode
level: number
@@ -36,6 +46,8 @@ interface FolderItemProps {
onDragEnter?: (e: React.DragEvent<HTMLElement>) => void
onDragLeave?: (e: React.DragEvent<HTMLElement>) => void
}
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -46,7 +58,13 @@ interface FolderItemProps {
* @param props - Component props
* @returns Folder item with drag and expand support
*/
export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
export function FolderItem({
folder,
level,
hoverHandlers,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: FolderItemProps) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
@@ -135,11 +153,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
}
}, [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) {
@@ -147,16 +160,32 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
return
}
const emptyImg = getEmptyDragImage()
if (emptyImg?.complete) {
e.dataTransfer.setDragImage(emptyImg, 0, 0)
}
e.dataTransfer.setData('folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
onDragStartProp?.()
},
[folder.id]
[folder.id, onDragStartProp]
)
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
handleDragEndBase()
onDragEndProp?.()
}, [handleDragEndBase, onDragEndProp])
const {
isOpen: isContextMenuOpen,
position,

View File

@@ -24,11 +24,23 @@ import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
let EMPTY_DRAG_IMAGE: HTMLImageElement | null = null
function getEmptyDragImage(): HTMLImageElement {
if (!EMPTY_DRAG_IMAGE && typeof window !== 'undefined') {
EMPTY_DRAG_IMAGE = new Image(1, 1)
EMPTY_DRAG_IMAGE.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='
}
return EMPTY_DRAG_IMAGE!
}
interface WorkflowItemProps {
workflow: WorkflowMetadata
active: boolean
level: number
onWorkflowClick: (workflowId: string, shiftKey: boolean, metaKey: boolean) => void
onDragStart?: () => void
onDragEnd?: () => void
}
/**
@@ -38,7 +50,14 @@ interface WorkflowItemProps {
* @param props - Component props
* @returns Workflow item with drag and selection support
*/
export function WorkflowItem({ workflow, active, level, onWorkflowClick }: WorkflowItemProps) {
export function WorkflowItem({
workflow,
active,
level,
onWorkflowClick,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
}: WorkflowItemProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { selectedWorkflows } = useFolderStore()
@@ -104,30 +123,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
[workflow.id, updateWorkflow]
)
/**
* 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 isEditingRef = useRef(false)
const {
isOpen: isContextMenuOpen,
@@ -232,6 +228,48 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
itemId: workflow.id,
})
isEditingRef.current = isEditing
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditingRef.current) {
e.preventDefault()
return
}
const emptyImg = getEmptyDragImage()
if (emptyImg?.complete) {
e.dataTransfer.setDragImage(emptyImg, 0, 0)
}
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
*/

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { memo, 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,9 +14,6 @@ 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
@@ -29,12 +26,24 @@ interface WorkflowListProps {
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
* 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
*/
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>
)
})
export function WorkflowList({
regularWorkflows,
isLoading = false,
@@ -48,20 +57,21 @@ export function WorkflowList({
const workflowId = params.workflowId as string
const { isLoading: foldersLoading } = useFolders(workspaceId)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
const {
dropTargetId,
dropIndicator,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
} = useDragDrop()
// Set scroll container when ref changes
useEffect(() => {
if (scrollContainerRef.current) {
setScrollContainer(scrollContainerRef.current)
@@ -76,23 +86,22 @@ export function WorkflowList({
return activeWorkflow?.folderId || null
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
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]
)
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])
/**
* Build a flat list of all workflow IDs in display order for range selection
*/
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []
@@ -106,12 +115,10 @@ 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)
@@ -120,30 +127,24 @@ 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(
(workflowId: string) => pathname === `/workspace/${workspaceId}/w/${workflowId}`,
(wfId: string) => pathname === `/workspace/${workspaceId}/w/${wfId}`,
[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)
@@ -151,23 +152,40 @@ export function WorkflowList({
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded])
const renderWorkflowItem = useCallback(
(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}
/>
(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} />
</div>
</div>
),
[isWorkflowActive, createItemDragHandlers, handleWorkflowClick]
)
},
[
dropIndicator,
isWorkflowActive,
createWorkflowDragHandlers,
handleWorkflowClick,
handleDragStart,
handleDragEnd,
]
)
const renderFolderSection = useCallback(
@@ -179,45 +197,75 @@ export function WorkflowList({
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isExpanded = expandedFolders.has(folder.id)
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const isDropTarget = dropTargetId === folder.id
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)
return (
<div key={folder.id} className='relative' {...createFolderDragHandlers(folder.id)}>
{/* Drop target highlight overlay - always rendered for stable DOM */}
<div key={folder.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} />
{/* Drop target highlight overlay - covers entire folder section */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
showInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
)}
/>
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createItemDragHandlers(folder.id)}
{...createFolderDragHandlers(folder.id, parentFolderId)}
>
<FolderItem
folder={folder}
level={level}
hoverHandlers={createFolderHeaderHoverHandlers(folder.id)}
onDragStart={() => handleDragStart('folder', parentFolderId)}
onDragEnd={handleDragEnd}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} />
{isExpanded && hasChildren && (
<div className='relative' {...createItemDragHandlers(folder.id)}>
{/* Vertical line - positioned to align under folder chevron */}
{isExpanded && (hasChildren || isDragging) && (
<div className='relative' {...createFolderContentDropZone(folder.id)}>
<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]'>
{workflowsInFolder.map((workflow: WorkflowMetadata) =>
renderWorkflowItem(workflow, level + 1, folder.id)
{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)} />
)}
{folder.children.map((childFolder) => (
<div key={childFolder.id} className='relative'>
{renderFolderSection(childFolder, level + 1, folder.id)}
</div>
))}
</div>
</div>
)}
@@ -227,29 +275,47 @@ export function WorkflowList({
[
workflowsByFolder,
expandedFolders,
dropTargetId,
dropIndicator,
isDragging,
createFolderDragHandlers,
createItemDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
handleDragStart,
handleDragEnd,
renderWorkflowItem,
]
)
const handleRootDragEvents = createRootDragHandlers()
const rootDropZoneHandlers = createRootDropZone()
const rootWorkflows = workflowsByFolder.root || []
const isRootDropTarget = dropTargetId === 'root'
const hasRootWorkflows = rootWorkflows.length > 0
const hasFolders = folderTree.length > 0
/**
* Handle click on empty space to revert to active workflow selection
*/
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'
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()
},
@@ -258,36 +324,23 @@ 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', !hasRootWorkflows && 'min-h-[26px]')}
{...handleRootDragEvents}
className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')}
{...rootDropZoneHandlers}
>
{/* Root drop target highlight overlay - always rendered for stable DOM */}
{/* Root drop target highlight overlay */}
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-[4px] transition-opacity duration-75',
isRootDropTarget && isDragging ? 'bg-gray-400/20 opacity-100' : 'opacity-0'
showRootInside && isDragging ? 'bg-[#33b4ff1a] opacity-100' : 'opacity-0'
)}
/>
<div className='space-y-[2px]'>
{rootWorkflows.map((workflow: WorkflowMetadata) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={isWorkflowActive(workflow.id)}
level={0}
onWorkflowClick={handleWorkflowClick}
/>
))}
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { useDragDrop } from './use-drag-drop'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useItemDrag } from './use-item-drag'

View File

@@ -1,47 +1,40 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useUpdateFolder } from '@/hooks/queries/folders'
import { useReorderFolders } from '@/hooks/queries/folders'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowList:DragDrop')
/**
* Constants for auto-scroll behavior
*/
const SCROLL_THRESHOLD = 60 // Distance from edge to trigger scroll
const SCROLL_SPEED = 8 // Pixels per frame
const SCROLL_THRESHOLD = 60
const SCROLL_SPEED = 8
const HOVER_EXPAND_DELAY = 400
/**
* Constants for folder auto-expand on hover during drag
*/
const HOVER_EXPAND_DELAY = 400 // Milliseconds to wait before expanding folder
export interface DropIndicator {
targetId: string
position: 'before' | 'after' | 'inside'
folderId: string | null
}
/**
* 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 [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [dropIndicator, setDropIndicator] = useState<DropIndicator | 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 updateFolderMutation = useUpdateFolder()
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const { setExpanded, expandedFolders } = useFolderStore()
const { updateWorkflow } = useWorkflowRegistry()
/**
* Auto-scroll handler - scrolls container when dragging near edges
*/
const handleAutoScroll = useCallback(() => {
if (!scrollContainerRef.current || !isDragging) return
@@ -49,22 +42,17 @@ 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
}
// Scroll down if near bottom and not at scroll bottom
else if (distanceFromBottom < SCROLL_THRESHOLD) {
} 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))
@@ -77,12 +65,9 @@ export function useDragDrop() {
}
}, [isDragging])
/**
* Start auto-scroll animation loop
*/
useEffect(() => {
if (isDragging) {
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10) // ~100fps for smoother response
scrollIntervalRef.current = window.setInterval(handleAutoScroll, 10)
} else {
if (scrollIntervalRef.current) {
clearInterval(scrollIntervalRef.current)
@@ -97,30 +82,17 @@ 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
}
// Don't start timer if not dragging or no folder is hovered
if (!isDragging || !hoverFolderId) {
return
}
if (!isDragging || !hoverFolderId) return
if (expandedFolders.has(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 () => {
@@ -131,249 +103,468 @@ export function useDragDrop() {
}
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
/**
* Cleanup hover state when dragging stops
*/
useEffect(() => {
if (!isDragging) {
setHoverFolderId(null)
setDropIndicator(null)
draggedTypeRef.current = null
}
}, [isDragging])
/**
* 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[], targetFolderId: string | null) => {
if (!workflowIds.length) {
logger.warn('No workflows to move')
return
}
try {
await Promise.all(
workflowIds.map((workflowId) => updateWorkflow(workflowId, { folderId: targetFolderId }))
)
logger.info(`Moved ${workflowIds.length} workflow(s)`)
} catch (error) {
logger.error('Failed to move workflows:', error)
}
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'
},
[updateWorkflow]
[]
)
/**
* 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
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,
}),
])
},
[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
// Normalize 'after' to 'before' of next sibling
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,
}
}
}
// Skip update if indicator hasn't changed
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]
)
const handleWorkflowDrop = useCallback(
async (workflowIds: string[], indicator: DropIndicator) => {
if (!workflowIds.length || !workspaceId) return
try {
const folderStore = useFolderStore.getState()
const draggedFolderPath = folderStore.getFolderPath(draggedFolderId)
const destinationFolderId = getDestinationFolderId(indicator)
const currentWorkflows = useWorkflowRegistry.getState().workflows
const firstWorkflow = currentWorkflows[workflowIds[0]]
// Prevent moving folder into its own descendant
if (
targetFolderId &&
draggedFolderPath.some((ancestor) => ancestor.id === targetFolderId)
isNoOpMove(
indicator,
workflowIds,
'workflow',
destinationFolderId,
firstWorkflow?.folderId
)
) {
logger.info('Cannot move folder into its own descendant')
return
}
// Prevent moving folder into itself
if (draggedFolderId === targetFolderId) {
const siblingItems = getSiblingItems(destinationFolderId)
const movingSet = new Set(workflowIds)
const remaining = siblingItems.filter(
(item) => !(item.type === 'workflow' && movingSet.has(item.id))
)
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)
} catch (error) {
logger.error('Failed to reorder workflows:', error)
}
},
[
getDestinationFolderId,
getSiblingItems,
calculateInsertIndex,
isNoOpMove,
buildAndSubmitUpdates,
]
)
const handleFolderDrop = useCallback(
async (draggedFolderId: string, indicator: DropIndicator) => {
if (!draggedFolderId || !workspaceId) return
try {
const folderStore = useFolderStore.getState()
const currentFolders = folderStore.folders
const targetParentId = getDestinationFolderId(indicator)
if (draggedFolderId === targetParentId) {
logger.info('Cannot move folder into itself')
return
}
if (!workspaceId) {
logger.warn('No workspaceId available for folder move')
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
)
) {
return
}
await updateFolderMutation.mutateAsync({
workspaceId,
id: draggedFolderId,
updates: { parentId: targetFolderId },
})
logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`)
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)
} catch (error) {
logger.error('Failed to move folder:', error)
logger.error('Failed to reorder folder:', error)
}
},
[updateFolderMutation, workspaceId]
[
workspaceId,
getDestinationFolderId,
getSiblingItems,
calculateInsertIndex,
isNoOpMove,
buildAndSubmitUpdates,
]
)
/**
* 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) => {
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDropTargetId(null)
const indicator = dropIndicator
setDropIndicator(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, targetFolderId)
await handleWorkflowDrop(workflowIds, indicator)
return
}
// Check if dropping a folder
const folderIdData = e.dataTransfer.getData('folder-id')
if (folderIdData && targetFolderId !== folderIdData) {
await handleFolderMove(folderIdData, targetFolderId)
if (folderIdData) {
await handleFolderDrop(folderIdData, indicator)
}
} catch (error) {
logger.error('Failed to handle drop:', error)
}
},
[handleWorkflowDrop, handleFolderMove]
[dropIndicator, handleWorkflowDrop, handleFolderDrop]
)
/**
* 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) => ({
onDragEnter: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
setIsDragging(true)
},
const createWorkflowDragHandlers = useCallback(
(workflowId: string, folderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
e.preventDefault()
lastDragYRef.current = e.clientY
setDropTargetId(folderId)
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 folder section completely
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setDropTargetId(null)
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: (e: React.DragEvent<HTMLElement>) => handleFolderDrop(e, folderId),
onDrop: handleDrop,
}),
[handleFolderDrop]
[initDragOver, calculateDropPosition, setNormalizedDropIndicator, handleDrop]
)
/**
* 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) => ({
const createFolderDragHandlers = useCallback(
(folderId: string, parentFolderId: string | null) => ({
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)
}
}
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
if (isLeavingElement(e)) setHoverFolderId(null)
},
onDrop: handleDrop,
}),
[
initDragOver,
calculateDropPosition,
calculateFolderDropPosition,
setNormalizedDropIndicator,
isLeavingElement,
handleDrop,
]
)
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) => ({
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)
},
[]
)
/**
* 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]
)
const handleDragEnd = useCallback(() => {
setIsDragging(false)
setDropIndicator(null)
draggedTypeRef.current = null
draggedSourceFolderRef.current = null
setHoverFolderId(null)
}, [])
/**
* 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 {
dropTargetId,
dropIndicator,
isDragging,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createItemDragHandlers,
createRootDragHandlers,
createFolderHeaderHoverHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
createRootDropZone,
handleDragStart,
handleDragEnd,
}
}

View File

@@ -64,6 +64,7 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortOrder: folder.sortOrder,
})
)

View File

@@ -40,7 +40,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
* Import a single workflow
*/
const importSingleWorkflow = useCallback(
async (content: string, filename: string, folderId?: string) => {
async (content: string, filename: string, folderId?: string, sortOrder?: number) => {
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
if (!workflowData || parseErrors.length > 0) {
@@ -60,6 +60,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
description: workflowData.metadata?.description || 'Imported from JSON',
workspaceId,
folderId: folderId || undefined,
sortOrder,
})
const newWorkflowId = result.id
@@ -139,6 +140,56 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
workspaceId,
})
const folderMap = new Map<string, string>()
const sanitizeName = (name: string) => name.replace(/[^a-z0-9-_]/gi, '-')
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(sanitizeName(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 {
@@ -147,15 +198,17 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
if (!folderMap.has(folderPathKey)) {
if (folderMap.has(folderPathKey)) {
targetFolderId = folderMap.get(folderPathKey)!
} else {
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: workflow.folderPath[i],
name: folderNameForSegment,
workspaceId,
parentId,
})
@@ -165,15 +218,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
targetFolderId,
workflow.sortOrder
)
if (workflowId) importedWorkflowIds.push(workflowId)
} catch (error) {

View File

@@ -59,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 }),
body: JSON.stringify({ name: workspaceName, skipDefaultWorkflow: true }),
})
if (!createResponse.ok) {
@@ -70,6 +70,56 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
logger.info('Created new workspace:', newWorkspace)
const folderMap = new Map<string, string>()
const sanitizeName = (name: string) => name.replace(/[^a-z0-9-_]/gi, '-')
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(sanitizeName(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 {
@@ -84,9 +134,10 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
if (workflow.folderPath.length > 0) {
const folderPathKey = workflow.folderPath.join('/')
if (!folderMap.has(folderPathKey)) {
let parentId: string | null = null
if (folderMap.has(folderPathKey)) {
targetFolderId = folderMap.get(folderPathKey)!
} else {
let parentId: string | undefined
for (let i = 0; i < workflow.folderPath.length; i++) {
const pathSegment = workflow.folderPath.slice(0, i + 1).join('/')
@@ -94,7 +145,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
const subFolder = await createFolderMutation.mutateAsync({
name: workflow.folderPath[i],
workspaceId: newWorkspace.id,
parentId: parentId || undefined,
parentId,
})
folderMap.set(pathSegment, subFolder.id)
parentId = subFolder.id
@@ -102,9 +153,8 @@ 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)

View File

@@ -68,6 +68,7 @@ interface CreateFolderVariables {
name: string
parentId?: string
color?: string
sortOrder?: number
}
interface UpdateFolderVariables {
@@ -160,18 +161,20 @@ export function useCreateFolder() {
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
sortOrder:
variables.sortOrder ??
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
createdAt: new Date(),
updatedAt: new Date(),
})
)
return useMutation({
mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => {
mutationFn: async ({ workspaceId, sortOrder, ...payload }: CreateFolderVariables) => {
const response = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, workspaceId }),
body: JSON.stringify({ ...payload, workspaceId, sortOrder }),
})
if (!response.ok) {
@@ -285,9 +288,66 @@ 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) })
},
})
}

View File

@@ -32,6 +32,7 @@ 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),
}
@@ -91,6 +92,7 @@ interface CreateWorkflowVariables {
description?: string
color?: string
folderId?: string | null
sortOrder?: number
}
interface CreateWorkflowResult {
@@ -100,6 +102,7 @@ interface CreateWorkflowResult {
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
}
interface DuplicateWorkflowVariables {
@@ -118,6 +121,7 @@ interface DuplicateWorkflowResult {
color: string
workspaceId: string
folderId?: string | null
sortOrder: number
blocksCount: number
edgesCount: number
subflowsCount: number
@@ -161,6 +165,7 @@ function createWorkflowMutationHandlers<TVariables extends { workspaceId: string
color: data.color,
workspaceId: data.workspaceId,
folderId: data.folderId,
sortOrder: 'sortOrder' in data ? data.sortOrder : 0,
},
},
error: null,
@@ -179,21 +184,36 @@ export function useCreateWorkflow() {
const handlers = createWorkflowMutationHandlers<CreateWorkflowVariables>(
queryClient,
'CreateWorkflow',
(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,
})
(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,
}
}
)
return useMutation({
mutationFn: async (variables: CreateWorkflowVariables): Promise<CreateWorkflowResult> => {
const { workspaceId, name, description, color, folderId } = variables
const { workspaceId, name, description, color, folderId, sortOrder } = variables
logger.info(`Creating new workflow in workspace: ${workspaceId}`)
@@ -206,6 +226,7 @@ export function useCreateWorkflow() {
color: color || getNextWorkflowColor(),
workspaceId,
folderId: folderId || null,
sortOrder,
}),
})
@@ -243,13 +264,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: {
@@ -267,16 +288,26 @@ export function useDuplicateWorkflowMutation() {
const handlers = createWorkflowMutationHandlers<DuplicateWorkflowVariables>(
queryClient,
'DuplicateWorkflow',
(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,
})
(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,
}
}
)
return useMutation({
@@ -317,6 +348,7 @@ 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,
@@ -398,3 +430,61 @@ 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) })
},
})
}

View File

@@ -7,7 +7,6 @@
export function getTimezoneAbbreviation(timezone: string, date: Date = new Date()): string {
if (timezone === 'UTC') return 'UTC'
// Common timezone mappings
const timezoneMap: Record<string, { standard: string; daylight: string }> = {
'America/Los_Angeles': { standard: 'PST', daylight: 'PDT' },
'America/Denver': { standard: 'MST', daylight: 'MDT' },
@@ -20,30 +19,22 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
'Asia/Singapore': { standard: 'SGT', daylight: 'SGT' }, // Singapore doesn't use DST
}
// If we have a mapping for this timezone
if (timezone in timezoneMap) {
// January 1 is guaranteed to be standard time in northern hemisphere
// July 1 is guaranteed to be daylight time in northern hemisphere (if observed)
const januaryDate = new Date(date.getFullYear(), 0, 1)
const julyDate = new Date(date.getFullYear(), 6, 1)
// Get offset in January (standard time)
const januaryFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// Get offset in July (likely daylight time)
const julyFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
})
// If offsets are different, timezone observes DST
const isDSTObserved = januaryFormatter.format(januaryDate) !== julyFormatter.format(julyDate)
// If DST is observed, check if current date is in DST by comparing its offset
// with January's offset (standard time)
if (isDSTObserved) {
const currentFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
@@ -54,11 +45,9 @@ export function getTimezoneAbbreviation(timezone: string, date: Date = new Date(
return isDST ? timezoneMap[timezone].daylight : timezoneMap[timezone].standard
}
// If DST is not observed, always use standard
return timezoneMap[timezone].standard
}
// For unknown timezones, use full IANA name
return timezone
}
@@ -79,7 +68,6 @@ export function formatDateTime(date: Date, timezone?: string): string {
timeZone: timezone || undefined,
})
// If timezone is provided, add a friendly timezone abbreviation
if (timezone) {
const tzAbbr = getTimezoneAbbreviation(timezone, date)
return `${formattedDate} ${tzAbbr}`
@@ -114,6 +102,40 @@ export function formatTime(date: Date): string {
})
}
/**
* Format a time with seconds and timezone
* @param date - The date to format
* @param includeTimezone - Whether to include the timezone abbreviation
* @returns A formatted time string in the format "h:mm:ss AM/PM TZ"
*/
export function formatTimeWithSeconds(date: Date, includeTimezone = true): string {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
timeZoneName: includeTimezone ? 'short' : undefined,
})
}
/**
* Format an ISO timestamp into a compact format for UI display
* @param iso - ISO timestamp string
* @returns A formatted string in "MM-DD HH:mm" format
*/
export function formatCompactTimestamp(iso: string): string {
try {
const d = new Date(iso)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${mm}-${dd} ${hh}:${min}`
} catch {
return iso
}
}
/**
* Format a duration in milliseconds to a human-readable format
* @param durationMs - The duration in milliseconds

View File

@@ -16,6 +16,7 @@ export interface WorkflowExportData {
description?: string
color?: string
folderId?: string | null
sortOrder?: number
}
state: WorkflowState
variables?: Record<string, Variable>
@@ -25,6 +26,7 @@ export interface FolderExportData {
id: string
name: string
parentId: string | null
sortOrder?: number
}
export interface WorkspaceExportStructure {
@@ -186,7 +188,12 @@ export async function exportWorkspaceToZip(
name: workspaceName,
exportedAt: new Date().toISOString(),
},
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
folders: folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
sortOrder: f.sortOrder,
})),
}
zip.file('_workspace.json', JSON.stringify(metadata, null, 2))
@@ -199,6 +206,7 @@ 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,
@@ -279,11 +287,18 @@ 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
}>
}
export async function extractWorkflowsFromZip(
@@ -303,6 +318,7 @@ 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)
@@ -317,10 +333,19 @@ export async function extractWorkflowsFromZip(
const pathParts = path.split('/').filter((p) => p.length > 0)
const filename = pathParts.pop() || path
let sortOrder: number | undefined
try {
const parsed = JSON.parse(content)
sortOrder = parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
} catch {
// ignore parse errors for sortOrder extraction
}
workflows.push({
content,
name: filename,
folderPath: pathParts,
sortOrder,
})
} catch (error) {
logger.error(`Failed to extract ${path}:`, error)
@@ -338,10 +363,20 @@ export async function extractWorkflowsFromFiles(files: File[]): Promise<Imported
try {
const content = await file.text()
let sortOrder: number | undefined
try {
const parsed = JSON.parse(content)
sortOrder = parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
} catch {
// ignore parse errors for sortOrder extraction
}
workflows.push({
content,
name: file.name,
folderPath: [],
sortOrder,
})
} catch (error) {
logger.error(`Failed to read ${file.name}:`, error)

View File

@@ -53,6 +53,8 @@ export interface ExportWorkflowState {
metadata?: {
name?: string
description?: string
color?: string
sortOrder?: number
exportedAt?: string
}
variables?: Array<{

View File

@@ -15,7 +15,7 @@ const logger = createLogger('TerminalConsoleStore')
* Maximum number of console entries to keep per workflow.
* Keeps the stored data size reasonable and improves performance.
*/
const MAX_ENTRIES_PER_WORKFLOW = 500
const MAX_ENTRIES_PER_WORKFLOW = 1000
const updateBlockOutput = (
existingOutput: NormalizedBlockOutput | undefined,
@@ -96,13 +96,57 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
}
const newEntries = [newEntry, ...state.entries]
const workflowCounts = new Map<string, number>()
const trimmedEntries = newEntries.filter((entry) => {
const count = workflowCounts.get(entry.workflowId) || 0
if (count >= MAX_ENTRIES_PER_WORKFLOW) return false
workflowCounts.set(entry.workflowId, count + 1)
return true
const executionsToRemove = new Set<string>()
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const e of newEntries) {
const group = workflowGroups.get(e.workflowId) || []
group.push(e)
workflowGroups.set(e.workflowId, group)
}
for (const [workflowId, entries] of workflowGroups) {
if (entries.length <= MAX_ENTRIES_PER_WORKFLOW) continue
const execOrder: string[] = []
const seen = new Set<string>()
for (const e of entries) {
const execId = e.executionId ?? e.id
if (!seen.has(execId)) {
execOrder.push(execId)
seen.add(execId)
}
}
const counts = new Map<string, number>()
for (const e of entries) {
const execId = e.executionId ?? e.id
counts.set(execId, (counts.get(execId) || 0) + 1)
}
let total = 0
const toKeep = new Set<string>()
for (const execId of execOrder) {
const c = counts.get(execId) || 0
if (total + c <= MAX_ENTRIES_PER_WORKFLOW) {
toKeep.add(execId)
total += c
}
}
for (const execId of execOrder) {
if (!toKeep.has(execId)) {
executionsToRemove.add(`${workflowId}:${execId}`)
}
}
}
const trimmedEntries = newEntries.filter((e) => {
const key = `${e.workflowId}:${e.executionId ?? e.id}`
return !executionsToRemove.has(key)
})
return { entries: trimmedEntries }
})

View File

@@ -476,7 +476,6 @@ 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)`,
@@ -484,8 +483,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
createdAt: new Date(),
description: sourceWorkflow.description,
color: getNextWorkflowColor(),
workspaceId, // Include the workspaceId in the new workflow
folderId: sourceWorkflow.folderId, // Include the folderId from source workflow
workspaceId,
folderId: sourceWorkflow.folderId,
sortOrder: duplicatedWorkflow.sortOrder ?? 0,
}
// Get the current workflow state to copy from

View File

@@ -26,6 +26,7 @@ export interface WorkflowMetadata {
color: string
workspaceId?: string
folderId?: string | null
sortOrder: number
}
export type HydrationPhase =

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",

View File

@@ -0,0 +1,2 @@
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

View File

@@ -981,6 +981,13 @@
"when": 1768366574848,
"tag": "0140_fuzzy_the_twelve",
"breakpoints": true
},
{
"idx": 141,
"version": "7",
"when": 1768421319400,
"tag": "0141_daffy_marten_broadcloak",
"breakpoints": true
}
]
}

View File

@@ -149,6 +149,7 @@ 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'),
@@ -165,6 +166,7 @@ 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),
})
)