Skip to content

Commit 7662ecc

Browse files
feat(forking): resource copying UX to help with setup speed (#5294)
* feat(forking): resource copying UX to help with setup speed * update UX nits * address comments * fix canonical modes and dependson behaviour in tool input * fix skill memory bounding;
1 parent ed6e8c2 commit 7662ecc

80 files changed

Lines changed: 24972 additions & 623 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
@@ -56,6 +56,8 @@ interface RichMarkdownEditorProps {
5656
streamIsIncremental?: boolean
5757
disableStreamingAutoScroll?: boolean
5858
previewContextKey?: string
59+
/** Disable the `@` tag-insertion menu (existing tags still render). Defaults off — the file editor keeps tagging. */
60+
disableTagging?: boolean
5961
}
6062

6163
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
@@ -72,6 +74,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
7274
streamIsIncremental,
7375
disableStreamingAutoScroll = false,
7476
previewContextKey,
77+
disableTagging,
7578
}: RichMarkdownEditorProps) {
7679
const {
7780
content,
@@ -113,6 +116,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
113116
autoFocus={autoFocus}
114117
streamIsIncremental={streamIsIncremental}
115118
disableStreamingAutoScroll={disableStreamingAutoScroll}
119+
disableTagging={disableTagging}
116120
onChange={setDraftContent}
117121
onSaveShortcut={saveImmediately}
118122
/>
@@ -131,6 +135,7 @@ interface LoadedRichMarkdownEditorProps {
131135
/** See {@link RichMarkdownEditorProps.streamIsIncremental}. */
132136
streamIsIncremental?: boolean
133137
disableStreamingAutoScroll?: boolean
138+
disableTagging?: boolean
134139
onChange: (markdown: string) => void
135140
onSaveShortcut: () => Promise<void>
136141
}
@@ -155,6 +160,7 @@ export function LoadedRichMarkdownEditor({
155160
autoFocus,
156161
streamIsIncremental,
157162
disableStreamingAutoScroll,
163+
disableTagging,
158164
onChange,
159165
onSaveShortcut,
160166
}: LoadedRichMarkdownEditorProps) {
@@ -339,7 +345,7 @@ export function LoadedRichMarkdownEditor({
339345
}
340346
}, [editor])
341347

342-
useEditorMentions(editor, workspaceId, { navigable: true })
348+
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging })
343349

344350
const wasStreamingRef = useRef(streamingAtMountRef.current)
345351

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>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents'
6+
import { getBlock } from '@/blocks/registry'
7+
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
8+
9+
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
10+
({ name: 'Tool', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
11+
12+
describe('clearDependentToolParams', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks()
15+
})
16+
17+
it('clears a non-empty dependent when its parent changes', () => {
18+
vi.mocked(getBlock).mockReturnValue(
19+
blockWith([
20+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
21+
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
22+
])
23+
)
24+
const result = clearDependentToolParams(
25+
'gmail',
26+
{ credential: 'cred-2', folder: 'INBOX' },
27+
'credential'
28+
)
29+
expect(result.folder).toBe('')
30+
// The changed param itself is untouched.
31+
expect(result.credential).toBe('cred-2')
32+
})
33+
34+
it('clears transitively (a grandchild dependent is also cleared)', () => {
35+
vi.mocked(getBlock).mockReturnValue(
36+
blockWith([
37+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
38+
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
39+
{ id: 'thread', title: 'Thread', type: 'short-input', dependsOn: ['folder'] },
40+
])
41+
)
42+
const result = clearDependentToolParams(
43+
'gmail',
44+
{ credential: 'cred-2', folder: 'INBOX', thread: 't-1' },
45+
'credential'
46+
)
47+
expect(result.folder).toBe('')
48+
expect(result.thread).toBe('')
49+
})
50+
51+
it('clears a dependent when a canonical-pair member changes (advanced member, dependent on the canonical id)', () => {
52+
vi.mocked(getBlock).mockReturnValue(
53+
blockWith([
54+
{
55+
id: 'credential',
56+
title: 'Credential',
57+
type: 'oauth-input',
58+
canonicalParamId: 'credential',
59+
mode: 'basic',
60+
},
61+
{
62+
id: 'manualCredential',
63+
title: 'Credential ID',
64+
type: 'short-input',
65+
canonicalParamId: 'credential',
66+
mode: 'advanced',
67+
},
68+
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
69+
])
70+
)
71+
const result = clearDependentToolParams(
72+
'gmail',
73+
{ manualCredential: 'mc-2', folder: 'INBOX' },
74+
'manualCredential'
75+
)
76+
// The shared walk expands the canonical group, so an advanced-member change clears the dependent.
77+
expect(result.folder).toBe('')
78+
})
79+
80+
it('leaves an already-empty dependent and a non-dependent param untouched (same reference)', () => {
81+
vi.mocked(getBlock).mockReturnValue(
82+
blockWith([
83+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
84+
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
85+
{ id: 'subject', title: 'Subject', type: 'short-input' },
86+
])
87+
)
88+
const params = { credential: 'cred-2', folder: '', subject: 'keep' }
89+
const result = clearDependentToolParams('gmail', params, 'credential')
90+
// The only dependent is already empty, so nothing changes - the same reference is returned.
91+
expect(result).toBe(params)
92+
expect(result.subject).toBe('keep')
93+
})
94+
95+
it('returns equivalent params when the changed param has no dependents', () => {
96+
vi.mocked(getBlock).mockReturnValue(
97+
blockWith([
98+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
99+
{ id: 'subject', title: 'Subject', type: 'short-input' },
100+
])
101+
)
102+
const params = { credential: 'cred-2', subject: 'hello' }
103+
const result = clearDependentToolParams('gmail', params, 'subject')
104+
expect(result).toBe(params)
105+
})
106+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
2+
import { getBlock } from '@/blocks/registry'
3+
4+
/**
5+
* Clear every TRANSITIVE `dependsOn` descendant of `changedParamId` in a nested tool's params,
6+
* mirroring the top-level block clear (`use-collaborative-workflow`). Reuses the shared
7+
* {@link getWorkflowSearchDependentClears} walk - transitive BFS plus canonical-pair expansion, so a
8+
* basic OR advanced member change clears the dependent - so both surfaces clear identically. Only
9+
* descendants that currently hold a non-empty value are reset to `''`; the changed param itself and
10+
* non-descendants are untouched. Returns the same reference when nothing changed.
11+
*/
12+
export function clearDependentToolParams(
13+
toolType: string,
14+
params: Record<string, string>,
15+
changedParamId: string
16+
): Record<string, string> {
17+
const subBlocks = getBlock(toolType)?.subBlocks ?? []
18+
let next: Record<string, string> | null = null
19+
for (const { subBlockId } of getWorkflowSearchDependentClears(subBlocks, changedParamId)) {
20+
if (!params[subBlockId]) continue
21+
next ??= { ...params }
22+
next[subBlockId] = ''
23+
}
24+
return next ?? params
25+
}

0 commit comments

Comments
 (0)