Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(forking): resource copying UX to help with setup speed
  • Loading branch information
icecrasher321 committed Jun 30, 2026
commit 064f2540efaffc0a1e6b62343ee9ce36f02f3987
12 changes: 4 additions & 8 deletions apps/sim/app/api/workspaces/[id]/background-work/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'

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

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

const rows = await listSurfacedBackgroundWork(db, id)
return NextResponse.json({
Expand Down
39 changes: 35 additions & 4 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
Expand All @@ -16,6 +18,8 @@ import {
forkDependentValueKey,
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
Expand Down Expand Up @@ -63,10 +67,17 @@ export const GET = withRouteHandler(
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow] = await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
])
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
await Promise.all([
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
listForkResourceCandidates(db, auth.sourceWorkspaceId),
db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.workspaceId, auth.sourceWorkspaceId)),
])
const storedByKey = new Map(
storedValues.map((entry) => [
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
Expand Down Expand Up @@ -108,6 +119,24 @@ export const GET = withRouteHandler(
),
}))

// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
// list. Labels resolve from the source candidate lists + workflow names loaded above.
const sourceLabels = new Map<string, string>()
for (const [kind, candidates] of Object.entries(sourceCandidates)) {
for (const candidate of candidates)
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
}
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
const clearedRefs = collectForkClearedRefCandidates({
items: plan.items,
sourceStates,
resolver: plan.resolver,
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
sourceLabels,
sourceWorkflowNames,
})

const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
sourceId: reference.sourceId,
Expand Down Expand Up @@ -155,6 +184,8 @@ export const GET = withRouteHandler(
inlineSecretSources: plan.inlineSecretSources,
dependentReconfigs,
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
copyableUnmapped: plan.copyableUnmapped,
clearedRefs,
})
}
)
3 changes: 2 additions & 1 deletion apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(promoteForkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
const { otherWorkspaceId, direction, dependentValues, copyResources } = parsed.data.body

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

Expand All @@ -36,6 +36,7 @@ export const POST = withRouteHandler(
direction,
userId: session.user.id,
dependentValues,
copyResources,
requestId,
})

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/workspaces/[id]/fork/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const POST = withRouteHandler(
knowledgeBases: copy?.knowledgeBases ?? [],
customTools: copy?.customTools ?? [],
skills: copy?.skills ?? [],
mcpServers: copy?.mcpServers ?? [],
workflowMcpServers: copy?.workflowMcpServers ?? [],
},
requestId,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useMarkdownMentions } from './use-markdown-mentions'
interface UseEditorMentionsOptions {
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
navigable?: boolean
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
disableTagging?: boolean
}

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

useEffect(() => {
if (!editor) return
const hasWorkspace = Boolean(workspaceId)
editor.storage.mention.enabled = hasWorkspace
const taggingOn = Boolean(workspaceId) && !disableTagging
editor.storage.mention.enabled = taggingOn
editor.storage.mention.navigable = navigable
editor.storage.mention.onOpen = hasWorkspace ? () => setActive(true) : null
editor.storage.mention.onOpen = taggingOn ? () => setActive(true) : null
return () => {
editor.storage.mention.onOpen = null
}
}, [editor, workspaceId, navigable])
}, [editor, workspaceId, navigable, disableTagging])

useEffect(() => {
editor?.storage.mention.store.set(items)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ interface RichMarkdownEditorProps {
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
disableTagging?: boolean
}

/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
Expand All @@ -71,6 +73,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
streamIsIncremental,
disableStreamingAutoScroll = false,
previewContextKey,
disableTagging,
}: RichMarkdownEditorProps) {
const {
content,
Expand Down Expand Up @@ -112,6 +115,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
autoFocus={autoFocus}
streamIsIncremental={streamIsIncremental}
disableStreamingAutoScroll={disableStreamingAutoScroll}
disableTagging={disableTagging}
onChange={setDraftContent}
onSaveShortcut={saveImmediately}
/>
Expand All @@ -130,6 +134,7 @@ interface LoadedRichMarkdownEditorProps {
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
streamIsIncremental?: boolean
disableStreamingAutoScroll?: boolean
disableTagging?: boolean
onChange: (markdown: string) => void
onSaveShortcut: () => Promise<void>
}
Expand All @@ -154,6 +159,7 @@ export function LoadedRichMarkdownEditor({
autoFocus,
streamIsIncremental,
disableStreamingAutoScroll,
disableTagging,
onChange,
onSaveShortcut,
}: LoadedRichMarkdownEditorProps) {
Expand Down Expand Up @@ -338,7 +344,7 @@ export function LoadedRichMarkdownEditor({
}
}, [editor])

useEditorMentions(editor, workspaceId, { navigable: true })
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })

const wasStreamingRef = useRef(streamingAtMountRef.current)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ interface RichMarkdownFieldProps {
error?: boolean
/** Enables the `@` mention menu scoped to this workspace. Omit to disable mentions. */
workspaceId?: string
/** Force the `@` tag-insertion menu off even with a workspace set (existing tags still render). */
disableTagging?: boolean
/**
* Intercepts a plain-text paste before the editor handles it. Return `true` to consume the paste
* (e.g. a full document the host destructures elsewhere); `false` to fall through to normal
Expand All @@ -62,6 +64,7 @@ function LoadedRichMarkdownField({
maxHeight = 360,
error = false,
workspaceId,
disableTagging,
onPasteText,
}: RichMarkdownFieldProps) {
const containerRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -166,7 +169,7 @@ function LoadedRichMarkdownField({
if (editor.isEditable !== !disabled) editor.setEditable(!disabled)
}, [editor, value, isStreaming, disabled])

useEditorMentions(editor, workspaceId)
useEditorMentions(editor, workspaceId, { disableTagging })

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export function VersionDescriptionModal({
isStreaming={isGenerating}
error={description.length > MAX_DESCRIPTION_LENGTH}
workspaceId={workspaceId}
disableTagging
/>
</ChipModalField>
<ChipModalError>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function jobReport(job: BackgroundWorkItem): JobReport {
addGroup('Files', m.fileNames)
addGroup('Custom tools', m.customToolNames)
addGroup('Skills', m.skillNames)
addGroup('MCP servers', m.mcpServerNames)
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
// Pre-names entries fall back to the per-kind counts.
if (groups.length === 0) {
const counts = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { groupForkFilesIntoFolders } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-file-tree/fork-file-tree'

describe('groupForkFilesIntoFolders', () => {
it('groups files under their folder and lifts un-foldered files to the root bucket', () => {
const { folders, rootFiles } = groupForkFilesIntoFolders([
{ id: 'f1', label: 'b.png', folderId: 'fld-1', folderName: 'Images' },
{ id: 'f2', label: 'a.png', folderId: 'fld-1', folderName: 'Images' },
{ id: 'f3', label: 'root.txt', folderId: null, folderName: null },
{ id: 'f4', label: 'doc.pdf', folderId: 'fld-2', folderName: 'Docs' },
])
// Folders are sorted by name; each folder's files are sorted by label.
expect(folders.map((folder) => folder.name)).toEqual(['Docs', 'Images'])
expect(folders[1].files.map((file) => file.label)).toEqual(['a.png', 'b.png'])
expect(rootFiles.map((file) => file.label)).toEqual(['root.txt'])
})

it('treats a file whose folder was deleted (id set, name null) as a root file', () => {
const { folders, rootFiles } = groupForkFilesIntoFolders([
{ id: 'f1', label: 'orphan.png', folderId: 'fld-deleted', folderName: null },
])
expect(folders).toEqual([])
expect(rootFiles.map((file) => file.id)).toEqual(['f1'])
})
})
Loading