Skip to content

Commit d11ee04

Browse files
authored
fix(workspace-popover): added duplicate, import, export workspace; added export multiple workflows (#1911)
* fix(workspace-popover): added duplicate, import, export workspace; added export multiple workflows * fix copilot keyboard nav
1 parent 1d58fdf commit d11ee04

File tree

23 files changed

+1711
-303
lines changed

23 files changed

+1711
-303
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { getSession } from '@/lib/auth'
4+
import { createLogger } from '@/lib/logs/console/logger'
5+
import { generateRequestId } from '@/lib/utils'
6+
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
7+
8+
const logger = createLogger('WorkspaceDuplicateAPI')
9+
10+
const DuplicateRequestSchema = z.object({
11+
name: z.string().min(1, 'Name is required'),
12+
})
13+
14+
// POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows
15+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
16+
const { id: sourceWorkspaceId } = await params
17+
const requestId = generateRequestId()
18+
const startTime = Date.now()
19+
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
logger.warn(
23+
`[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}`
24+
)
25+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
26+
}
27+
28+
try {
29+
const body = await req.json()
30+
const { name } = DuplicateRequestSchema.parse(body)
31+
32+
logger.info(
33+
`[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}`
34+
)
35+
36+
const result = await duplicateWorkspace({
37+
sourceWorkspaceId,
38+
userId: session.user.id,
39+
name,
40+
requestId,
41+
})
42+
43+
const elapsed = Date.now() - startTime
44+
logger.info(
45+
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
46+
)
47+
48+
return NextResponse.json(result, { status: 201 })
49+
} catch (error) {
50+
if (error instanceof Error) {
51+
if (error.message === 'Source workspace not found') {
52+
logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`)
53+
return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 })
54+
}
55+
56+
if (error.message === 'Source workspace not found or access denied') {
57+
logger.warn(
58+
`[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}`
59+
)
60+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
61+
}
62+
}
63+
64+
if (error instanceof z.ZodError) {
65+
logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors })
66+
return NextResponse.json(
67+
{ error: 'Invalid request data', details: error.errors },
68+
{ status: 400 }
69+
)
70+
}
71+
72+
const elapsed = Date.now() - startTime
73+
logger.error(
74+
`[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`,
75+
error
76+
)
77+
return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 })
78+
}
79+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export function MentionMenu({
8686
getActiveMentionQueryAtPosition,
8787
getCaretPos,
8888
submenuActiveIndex,
89+
mentionActiveIndex,
90+
openSubmenuFor,
8991
} = mentionMenu
9092

9193
const {
@@ -282,6 +284,21 @@ export function MentionMenu({
282284
// Show filtered aggregated view when there's a query
283285
const showAggregatedView = currentQuery.length > 0
284286

287+
// Folder order for keyboard navigation - matches render order
288+
const FOLDER_ORDER = [
289+
'Chats', // 0
290+
'Workflows', // 1
291+
'Knowledge', // 2
292+
'Blocks', // 3
293+
'Workflow Blocks', // 4
294+
'Templates', // 5
295+
'Logs', // 6
296+
'Docs', // 7
297+
] as const
298+
299+
// Get active folder based on navigation when not in submenu and no query
300+
const isInFolderNavigationMode = !openSubmenuFor && !showAggregatedView
301+
285302
// Compute caret viewport position via mirror technique for precise anchoring
286303
const textareaEl = mentionMenu.textareaRef.current
287304
if (!textareaEl) return null
@@ -372,7 +389,184 @@ export function MentionMenu({
372389
>
373390
<PopoverBackButton />
374391
<PopoverScrollArea ref={menuListRef} className='space-y-[2px]'>
375-
{showAggregatedView ? (
392+
{openSubmenuFor ? (
393+
// Submenu view - showing contents of a specific folder
394+
<>
395+
{openSubmenuFor === 'Chats' && (
396+
<>
397+
{mentionData.isLoadingPastChats ? (
398+
<LoadingState />
399+
) : mentionData.pastChats.length === 0 ? (
400+
<EmptyState message='No past chats' />
401+
) : (
402+
mentionData.pastChats.map((chat, index) => (
403+
<PopoverItem
404+
key={chat.id}
405+
onClick={() => insertPastChatMention(chat)}
406+
data-idx={index}
407+
active={index === submenuActiveIndex}
408+
>
409+
<span className='truncate'>{chat.title || 'New Chat'}</span>
410+
</PopoverItem>
411+
))
412+
)}
413+
</>
414+
)}
415+
{openSubmenuFor === 'Workflows' && (
416+
<>
417+
{mentionData.isLoadingWorkflows ? (
418+
<LoadingState />
419+
) : mentionData.workflows.length === 0 ? (
420+
<EmptyState message='No workflows' />
421+
) : (
422+
mentionData.workflows.map((wf, index) => (
423+
<PopoverItem
424+
key={wf.id}
425+
onClick={() => insertWorkflowMention(wf)}
426+
data-idx={index}
427+
active={index === submenuActiveIndex}
428+
>
429+
<div
430+
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
431+
style={{ backgroundColor: wf.color || '#3972F6' }}
432+
/>
433+
<span className='truncate'>{wf.name || 'Untitled Workflow'}</span>
434+
</PopoverItem>
435+
))
436+
)}
437+
</>
438+
)}
439+
{openSubmenuFor === 'Knowledge' && (
440+
<>
441+
{mentionData.isLoadingKnowledge ? (
442+
<LoadingState />
443+
) : mentionData.knowledgeBases.length === 0 ? (
444+
<EmptyState message='No knowledge bases' />
445+
) : (
446+
mentionData.knowledgeBases.map((kb, index) => (
447+
<PopoverItem
448+
key={kb.id}
449+
onClick={() => insertKnowledgeMention(kb)}
450+
data-idx={index}
451+
active={index === submenuActiveIndex}
452+
>
453+
<span className='truncate'>{kb.name || 'Untitled'}</span>
454+
</PopoverItem>
455+
))
456+
)}
457+
</>
458+
)}
459+
{openSubmenuFor === 'Blocks' && (
460+
<>
461+
{mentionData.isLoadingBlocks ? (
462+
<LoadingState />
463+
) : mentionData.blocksList.length === 0 ? (
464+
<EmptyState message='No blocks found' />
465+
) : (
466+
mentionData.blocksList.map((blk, index) => {
467+
const Icon = blk.iconComponent
468+
return (
469+
<PopoverItem
470+
key={blk.id}
471+
onClick={() => insertBlockMention(blk)}
472+
data-idx={index}
473+
active={index === submenuActiveIndex}
474+
>
475+
<div
476+
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
477+
style={{ backgroundColor: blk.bgColor || '#6B7280' }}
478+
>
479+
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
480+
</div>
481+
<span className='truncate'>{blk.name || blk.id}</span>
482+
</PopoverItem>
483+
)
484+
})
485+
)}
486+
</>
487+
)}
488+
{openSubmenuFor === 'Workflow Blocks' && (
489+
<>
490+
{mentionData.isLoadingWorkflowBlocks ? (
491+
<LoadingState />
492+
) : mentionData.workflowBlocks.length === 0 ? (
493+
<EmptyState message='No blocks in this workflow' />
494+
) : (
495+
mentionData.workflowBlocks.map((blk, index) => {
496+
const Icon = blk.iconComponent
497+
return (
498+
<PopoverItem
499+
key={blk.id}
500+
onClick={() => insertWorkflowBlockMention(blk)}
501+
data-idx={index}
502+
active={index === submenuActiveIndex}
503+
>
504+
<div
505+
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
506+
style={{ backgroundColor: blk.bgColor || '#6B7280' }}
507+
>
508+
{Icon && <Icon className='!h-[10px] !w-[10px] text-white' />}
509+
</div>
510+
<span className='truncate'>{blk.name || blk.id}</span>
511+
</PopoverItem>
512+
)
513+
})
514+
)}
515+
</>
516+
)}
517+
{openSubmenuFor === 'Templates' && (
518+
<>
519+
{mentionData.isLoadingTemplates ? (
520+
<LoadingState />
521+
) : mentionData.templatesList.length === 0 ? (
522+
<EmptyState message='No templates found' />
523+
) : (
524+
mentionData.templatesList.map((tpl, index) => (
525+
<PopoverItem
526+
key={tpl.id}
527+
onClick={() => insertTemplateMention(tpl)}
528+
data-idx={index}
529+
active={index === submenuActiveIndex}
530+
>
531+
<span className='flex-1 truncate'>{tpl.name}</span>
532+
<span className='text-[#868686] text-[10px] dark:text-[#868686]'>
533+
{tpl.stars}
534+
</span>
535+
</PopoverItem>
536+
))
537+
)}
538+
</>
539+
)}
540+
{openSubmenuFor === 'Logs' && (
541+
<>
542+
{mentionData.isLoadingLogs ? (
543+
<LoadingState />
544+
) : mentionData.logsList.length === 0 ? (
545+
<EmptyState message='No executions found' />
546+
) : (
547+
mentionData.logsList.map((log, index) => (
548+
<PopoverItem
549+
key={log.id}
550+
onClick={() => insertLogMention(log)}
551+
data-idx={index}
552+
active={index === submenuActiveIndex}
553+
>
554+
<span className='min-w-0 flex-1 truncate'>{log.workflowName}</span>
555+
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
556+
<span className='whitespace-nowrap text-[10px]'>
557+
{formatTimestamp(log.createdAt)}
558+
</span>
559+
<span className='text-[#AEAEAE] text-[10px] dark:text-[#AEAEAE]'>·</span>
560+
<span className='text-[10px] capitalize'>
561+
{(log.trigger || 'manual').toLowerCase()}
562+
</span>
563+
</PopoverItem>
564+
))
565+
)}
566+
</>
567+
)}
568+
</>
569+
) : showAggregatedView ? (
376570
// Aggregated filtered view
377571
<>
378572
{filteredAggregatedItems.length === 0 ? (
@@ -406,6 +600,8 @@ export function MentionMenu({
406600
id='chats'
407601
title='Chats'
408602
onOpen={() => mentionData.ensurePastChatsLoaded()}
603+
active={isInFolderNavigationMode && mentionActiveIndex === 0}
604+
data-idx={0}
409605
>
410606
{mentionData.isLoadingPastChats ? (
411607
<LoadingState />
@@ -424,6 +620,8 @@ export function MentionMenu({
424620
id='workflows'
425621
title='All workflows'
426622
onOpen={() => mentionData.ensureWorkflowsLoaded()}
623+
active={isInFolderNavigationMode && mentionActiveIndex === 1}
624+
data-idx={1}
427625
>
428626
{mentionData.isLoadingWorkflows ? (
429627
<LoadingState />
@@ -446,6 +644,8 @@ export function MentionMenu({
446644
id='knowledge'
447645
title='Knowledge Bases'
448646
onOpen={() => mentionData.ensureKnowledgeLoaded()}
647+
active={isInFolderNavigationMode && mentionActiveIndex === 2}
648+
data-idx={2}
449649
>
450650
{mentionData.isLoadingKnowledge ? (
451651
<LoadingState />
@@ -464,6 +664,8 @@ export function MentionMenu({
464664
id='blocks'
465665
title='Blocks'
466666
onOpen={() => mentionData.ensureBlocksLoaded()}
667+
active={isInFolderNavigationMode && mentionActiveIndex === 3}
668+
data-idx={3}
467669
>
468670
{mentionData.isLoadingBlocks ? (
469671
<LoadingState />
@@ -491,6 +693,8 @@ export function MentionMenu({
491693
id='workflow-blocks'
492694
title='Workflow Blocks'
493695
onOpen={() => mentionData.ensureWorkflowBlocksLoaded()}
696+
active={isInFolderNavigationMode && mentionActiveIndex === 4}
697+
data-idx={4}
494698
>
495699
{mentionData.isLoadingWorkflowBlocks ? (
496700
<LoadingState />
@@ -518,6 +722,8 @@ export function MentionMenu({
518722
id='templates'
519723
title='Templates'
520724
onOpen={() => mentionData.ensureTemplatesLoaded()}
725+
active={isInFolderNavigationMode && mentionActiveIndex === 5}
726+
data-idx={5}
521727
>
522728
{mentionData.isLoadingTemplates ? (
523729
<LoadingState />
@@ -535,7 +741,13 @@ export function MentionMenu({
535741
)}
536742
</PopoverFolder>
537743

538-
<PopoverFolder id='logs' title='Logs' onOpen={() => mentionData.ensureLogsLoaded()}>
744+
<PopoverFolder
745+
id='logs'
746+
title='Logs'
747+
onOpen={() => mentionData.ensureLogsLoaded()}
748+
active={isInFolderNavigationMode && mentionActiveIndex === 6}
749+
data-idx={6}
750+
>
539751
{mentionData.isLoadingLogs ? (
540752
<LoadingState />
541753
) : mentionData.logsList.length === 0 ? (
@@ -557,7 +769,12 @@ export function MentionMenu({
557769
)}
558770
</PopoverFolder>
559771

560-
<PopoverItem rootOnly onClick={() => insertDocsMention()}>
772+
<PopoverItem
773+
rootOnly
774+
onClick={() => insertDocsMention()}
775+
active={isInFolderNavigationMode && mentionActiveIndex === 7}
776+
data-idx={7}
777+
>
561778
<span>Docs</span>
562779
</PopoverItem>
563780
</>

0 commit comments

Comments
 (0)