Skip to content

Commit 064f254

Browse files
committed
feat(forking): resource copying UX to help with setup speed
1 parent 8925334 commit 064f254

53 files changed

Lines changed: 6359 additions & 478 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/workspaces/[id]/background-work/route.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { getSession } from '@/lib/auth'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
8-
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
8+
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
99

1010
export const GET = withRouteHandler(
1111
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -18,13 +18,9 @@ export const GET = withRouteHandler(
1818
if (!parsed.success) return parsed.response
1919
const { id } = parsed.data.params
2020

21-
const access = await checkWorkspaceAccess(id, session.user.id)
22-
if (!access.exists) {
23-
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
24-
}
25-
if (!access.canAdmin) {
26-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
27-
}
21+
// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
22+
// workspace-admin check the other fork routes use, instead of a bare access check.
23+
await assertWorkspaceAdminAccess(id, session.user.id)
2824

2925
const rows = await listSurfacedBackgroundWork(db, id)
3026
return NextResponse.json({

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { db } from '@sim/db'
2+
import { workflow } from '@sim/db/schema'
3+
import { eq } from 'drizzle-orm'
24
import { type NextRequest, NextResponse } from 'next/server'
35
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
46
import { parseRequest } from '@/lib/api/server'
@@ -16,6 +18,8 @@ import {
1618
forkDependentValueKey,
1719
loadForkDependentValues,
1820
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
21+
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
22+
import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
1923
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
2024
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
2125
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
@@ -63,10 +67,17 @@ export const GET = withRouteHandler(
6367
const replaceTargetIds = plan.items
6468
.filter((item) => item.mode === 'replace')
6569
.map((item) => item.targetWorkflowId)
66-
const [storedValues, targetDraftByWorkflow] = await Promise.all([
67-
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
68-
loadTargetDraftSubBlocks(db, replaceTargetIds),
69-
])
70+
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
71+
await Promise.all([
72+
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
73+
loadTargetDraftSubBlocks(db, replaceTargetIds),
74+
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
75+
listForkResourceCandidates(db, auth.sourceWorkspaceId),
76+
db
77+
.select({ id: workflow.id, name: workflow.name })
78+
.from(workflow)
79+
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
80+
])
7081
const storedByKey = new Map(
7182
storedValues.map((entry) => [
7283
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
@@ -108,6 +119,24 @@ export const GET = withRouteHandler(
108119
),
109120
}))
110121

122+
// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
123+
// list. Labels resolve from the source candidate lists + workflow names loaded above.
124+
const sourceLabels = new Map<string, string>()
125+
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
126+
for (const candidate of candidates)
127+
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
128+
}
129+
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
130+
const clearedRefs = collectForkClearedRefCandidates({
131+
items: plan.items,
132+
sourceStates,
133+
resolver: plan.resolver,
134+
workflowIdMap: plan.workflowIdMap,
135+
resolveBlockId,
136+
sourceLabels,
137+
sourceWorkflowNames,
138+
})
139+
111140
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
112141
kind: reference.kind,
113142
sourceId: reference.sourceId,
@@ -155,6 +184,8 @@ export const GET = withRouteHandler(
155184
inlineSecretSources: plan.inlineSecretSources,
156185
dependentReconfigs,
157186
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
187+
copyableUnmapped: plan.copyableUnmapped,
188+
clearedRefs,
158189
})
159190
}
160191
)

apps/sim/app/api/workspaces/[id]/fork/promote/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = withRouteHandler(
2525
const parsed = await parseRequest(promoteForkContract, req, context)
2626
if (!parsed.success) return parsed.response
2727
const { id } = parsed.data.params
28-
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
28+
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body
2929

3030
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
3131

@@ -36,6 +36,7 @@ export const POST = withRouteHandler(
3636
direction,
3737
userId: session.user.id,
3838
dependentValues,
39+
copyResources,
3940
requestId,
4041
})
4142

apps/sim/app/api/workspaces/[id]/fork/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const POST = withRouteHandler(
3939
knowledgeBases: copy?.knowledgeBases ?? [],
4040
customTools: copy?.customTools ?? [],
4141
skills: copy?.skills ?? [],
42-
mcpServers: copy?.mcpServers ?? [],
42+
workflowMcpServers: copy?.workflowMcpServers ?? [],
4343
},
4444
requestId,
4545
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useMarkdownMentions } from './use-markdown-mentions'
55
interface UseEditorMentionsOptions {
66
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
77
navigable?: boolean
8+
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
9+
disableTagging?: boolean
810
}
911

1012
/**
@@ -20,17 +22,18 @@ export function useEditorMentions(
2022
const [active, setActive] = useState(false)
2123
const items = useMarkdownMentions(workspaceId, { enabled: active })
2224
const navigable = options?.navigable ?? false
25+
const disableTagging = options?.disableTagging ?? false
2326

2427
useEffect(() => {
2528
if (!editor) return
26-
const hasWorkspace = Boolean(workspaceId)
27-
editor.storage.mention.enabled = hasWorkspace
29+
const taggingOn = Boolean(workspaceId) && !disableTagging
30+
editor.storage.mention.enabled = taggingOn
2831
editor.storage.mention.navigable = navigable
29-
editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null
32+
editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null
3033
return () => {
3134
editor.storage.mention.onOpen = null
3235
}
33-
}, [editor, workspaceId, navigable])
36+
}, [editor, workspaceId, navigable, disableTagging])
3437

3538
useEffect(() => {
3639
editor?.storage.mention.store.set(items)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ interface RichMarkdownEditorProps {
5555
streamIsIncremental?: boolean
5656
disableStreamingAutoScroll?: boolean
5757
previewContextKey?: string
58+
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
59+
disableTagging?: boolean
5860
}
5961

6062
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
@@ -71,6 +73,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
7173
streamIsIncremental,
7274
disableStreamingAutoScroll = false,
7375
previewContextKey,
76+
disableTagging,
7477
}: RichMarkdownEditorProps) {
7578
const {
7679
content,
@@ -112,6 +115,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
112115
autoFocus={autoFocus}
113116
streamIsIncremental={streamIsIncremental}
114117
disableStreamingAutoScroll={disableStreamingAutoScroll}
118+
disableTagging={disableTagging}
115119
onChange={setDraftContent}
116120
onSaveShortcut={saveImmediately}
117121
/>
@@ -130,6 +134,7 @@ interface LoadedRichMarkdownEditorProps {
130134
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
131135
streamIsIncremental?: boolean
132136
disableStreamingAutoScroll?: boolean
137+
disableTagging?: boolean
133138
onChange: (markdown: string) => void
134139
onSaveShortcut: () => Promise<void>
135140
}
@@ -154,6 +159,7 @@ export function LoadedRichMarkdownEditor({
154159
autoFocus,
155160
streamIsIncremental,
156161
disableStreamingAutoScroll,
162+
disableTagging,
157163
onChange,
158164
onSaveShortcut,
159165
}: LoadedRichMarkdownEditorProps) {
@@ -338,7 +344,7 @@ export function LoadedRichMarkdownEditor({
338344
}
339345
}, [editor])
340346

341-
useEditorMentions(editor, workspaceId, { navigable: true })
347+
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })
342348

343349
const wasStreamingRef = useRef(streamingAtMountRef.current)
344350

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ interface RichMarkdownFieldProps {
3838
error?: boolean
3939
/** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */
4040
workspaceId?: string
41+
/** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */
42+
disableTagging?: boolean
4143
/**
4244
* Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste
4345
* (e.g. a full document the host destructures elsewhere); `false` to fall through to normal
@@ -62,6 +64,7 @@ function LoadedRichMarkdownField({
6264
maxHeight = 360,
6365
error = false,
6466
workspaceId,
67+
disableTagging,
6568
onPasteText,
6669
}: RichMarkdownFieldProps) {
6770
const containerRef = useRef<HTMLDivElement>(null)
@@ -166,7 +169,7 @@ function LoadedRichMarkdownField({
166169
if (editor.isEditable !== !disabled) editor.setEditable(!disabled)
167170
}, [editor, value, isStreaming, disabled])
168171

169-
useEditorMentions(editor, workspaceId)
172+
useEditorMentions(editor, workspaceId, { disableTagging })
170173

171174
return (
172175
<div

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export function VersionDescriptionModal({
147147
isStreaming={isGenerating}
148148
error={description.length > MAX_DESCRIPTION_LENGTH}
149149
workspaceId={workspaceId}
150+
disableTagging
150151
/>
151152
</ChipModalField>
152153
<ChipModalError>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-activity-panel/fork-activity-panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ function jobReport(job: BackgroundWorkItem): JobReport {
122122
addGroup('Files', m.fileNames)
123123
addGroup('Custom tools', m.customToolNames)
124124
addGroup('Skills', m.skillNames)
125-
addGroup('MCP servers', m.mcpServerNames)
125+
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
126126
// Pre-names entries fall back to the per-kind counts.
127127
if (groups.length === 0) {
128128
const counts = [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { groupForkFilesIntoFolders } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree'
6+
7+
describe('groupForkFilesIntoFolders', () => {
8+
it('groups files under their folder and lifts un-foldered files to the root bucket', () => {
9+
const { folders, rootFiles } = groupForkFilesIntoFolders([
10+
{ id: 'f1', label: 'b.png', folderId: 'fld-1', folderName: 'Images' },
11+
{ id: 'f2', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
12+
{ id: 'f3', label: 'root.txt', folderId: null, folderName: null },
13+
{ id: 'f4', label: 'doc.pdf', folderId: 'fld-2', folderName: 'Docs' },
14+
])
15+
// Folders are sorted by name; each folder's files are sorted by label.
16+
expect(folders.map((folder) => folder.name)).toEqual(['Docs', 'Images'])
17+
expect(folders[1].files.map((file) => file.label)).toEqual(['a.png', 'b.png'])
18+
expect(rootFiles.map((file) => file.label)).toEqual(['root.txt'])
19+
})
20+
21+
it('treats a file whose folder was deleted (id set, name null) as a root file', () => {
22+
const { folders, rootFiles } = groupForkFilesIntoFolders([
23+
{ id: 'f1', label: 'orphan.png', folderId: 'fld-deleted', folderName: null },
24+
])
25+
expect(folders).toEqual([])
26+
expect(rootFiles.map((file) => file.id)).toEqual(['f1'])
27+
})
28+
})

0 commit comments

Comments
 (0)