- {controller.dependentClears.map((ref, index) => (
-
- {ref.blockLabel} will lose{' '}
- {ref.fieldLabel} in{' '}
- {ref.workflowName}
-
- ))}
+ {controller.dependentClears.map((ref, index) => {
+ const droppedKey = `${ref.kind}:${ref.sourceId}`
+ const dropped = controller.droppedRefs.has(droppedKey)
+ return (
+
+
+ {ref.blockLabel} will lose{' '}
+ {ref.fieldLabel} in{' '}
+ {ref.workflowName}
+ {dropped ? ' — dropped' : ''}
+
+ {dropped ? (
+ controller.toggleDroppedRef(ref.kind, ref.sourceId, false)}
+ >
+ Undo
+
+ ) : null}
+
+ )
+ })}
Re-pick these in the target after the sync.
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.test.ts
new file mode 100644
index 00000000000..1fdd3717ede
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.test.ts
@@ -0,0 +1,114 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import type { ForkTriggerMapping } from '@/lib/api/contracts/workspace-fork'
+import {
+ forkDyingTriggerUrls,
+ forkTriggerChoices,
+ forkTriggerPathOwners,
+} from '@/ee/workspace-forking/components/fork-sync/trigger-choices'
+
+function mapping(overrides: Partial = {}): ForkTriggerMapping {
+ return {
+ sourceBlockId: 'blk',
+ blockName: 'Slack messages',
+ workflowName: 'ITSM intake',
+ ownPath: null,
+ adoptablePaths: ['p1'],
+ defaultAdoptPath: 'p1',
+ ...overrides,
+ }
+}
+
+describe('forkTriggerChoices', () => {
+ it('takes the default when the user has not chosen', () => {
+ expect(forkTriggerChoices([mapping()], {}).get('blk')).toBe('p1')
+ })
+
+ it('honours an explicit pick over the default', () => {
+ const mappings = [mapping({ adoptablePaths: ['p1', 'p2'], defaultAdoptPath: null })]
+ expect(forkTriggerChoices(mappings, { blk: 'p2' }).get('blk')).toBe('p2')
+ })
+
+ it("treats an explicit '' as minting a new URL, overriding the default", () => {
+ expect(forkTriggerChoices([mapping()], { blk: '' }).get('blk')).toBe('')
+ })
+
+ it('ignores a pick the slot never offered', () => {
+ expect(forkTriggerChoices([mapping()], { blk: 'not-offered' }).get('blk')).toBe('')
+ })
+
+ /**
+ * Two blocks cannot serve one path (`path_deployment_unique`) and the server awards it to the
+ * first slot, so the second row's real outcome is a NEW URL - not the path it asked for.
+ */
+ it('awards a contested path to the first row only', () => {
+ const mappings = [
+ mapping({ sourceBlockId: 'a', blockName: 'Slack A', defaultAdoptPath: null }),
+ mapping({ sourceBlockId: 'b', blockName: 'Slack B', defaultAdoptPath: null }),
+ ]
+ const chosen = forkTriggerChoices(mappings, { a: 'p1', b: 'p1' })
+ expect(chosen.get('a')).toBe('p1')
+ expect(chosen.get('b')).toBe('')
+ })
+})
+
+describe('forkDyingTriggerUrls', () => {
+ const retiring = [
+ { workflowName: 'ITSM intake', path: 'p1' },
+ { workflowName: 'ITSM intake', path: 'p2' },
+ ]
+
+ it('excludes a URL some row adopts', () => {
+ const chosen = forkTriggerChoices([mapping()], {})
+ expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p2'])
+ })
+
+ /**
+ * The bug this exists for: the server computes its warning from the DEFAULT resolution, so
+ * choosing "Generate new URL" used to kill a URL the confirm never mentioned.
+ */
+ it('re-lists a URL once the user opts into a new one instead', () => {
+ const chosen = forkTriggerChoices([mapping()], { blk: '' })
+ expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p1', 'p2'])
+ })
+
+ it('drops a URL the user adopts where the default adopted nothing', () => {
+ const mappings = [mapping({ adoptablePaths: ['p1', 'p2'], defaultAdoptPath: null })]
+ const chosen = forkTriggerChoices(mappings, { blk: 'p2' })
+ expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p1'])
+ })
+
+ /** A contested path is still served by its winner, so it is not dying. */
+ it('counts a contested path as adopted exactly once', () => {
+ const mappings = [
+ mapping({ sourceBlockId: 'a', defaultAdoptPath: null }),
+ mapping({ sourceBlockId: 'b', defaultAdoptPath: null }),
+ ]
+ const chosen = forkTriggerChoices(mappings, { a: 'p1', b: 'p1' })
+ expect(forkDyingTriggerUrls(retiring, chosen).map((r) => r.path)).toEqual(['p2'])
+ })
+})
+
+describe('forkTriggerPathOwners', () => {
+ const mappings = [
+ mapping({ sourceBlockId: 'a', blockName: 'Slack A', defaultAdoptPath: null }),
+ mapping({ sourceBlockId: 'b', blockName: 'Slack B', defaultAdoptPath: null }),
+ ]
+
+ it('names the row that claimed a path, from another row’s perspective', () => {
+ const chosen = forkTriggerChoices(mappings, { a: 'p1' })
+ expect(forkTriggerPathOwners(mappings, chosen, 'b').get('p1')).toBe('Slack A')
+ })
+
+ it('never reports a row as the owner of its own claim', () => {
+ const chosen = forkTriggerChoices(mappings, { a: 'p1' })
+ expect(forkTriggerPathOwners(mappings, chosen, 'a').has('p1')).toBe(false)
+ })
+
+ it('reports nothing while no row has claimed anything', () => {
+ const chosen = forkTriggerChoices(mappings, {})
+ expect(forkTriggerPathOwners(mappings, chosen, 'b').size).toBe(0)
+ })
+})
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.ts b/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.ts
new file mode 100644
index 00000000000..914f12a533a
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/trigger-choices.ts
@@ -0,0 +1,63 @@
+import type { ForkTriggerMapping, ForkTriggerUrlChange } from '@/lib/api/contracts/workspace-fork'
+
+/**
+ * Which retiring URL each arriving trigger currently takes, keyed by source block id. `''` means
+ * "mint a new URL".
+ *
+ * Mirrors `resolveForkTriggerPaths` on the server, which is what makes the preview trustworthy:
+ * an override counts only for a path the slot actually offered, and a path is awarded to the
+ * FIRST row that claims it - two blocks cannot serve one path (`path_deployment_unique`), so a
+ * later row claiming the same URL silently receives a new one instead.
+ */
+export function forkTriggerChoices(
+ mappings: readonly ForkTriggerMapping[],
+ adoptions: Readonly>
+): Map {
+ const chosen = new Map()
+ const claimed = new Set()
+ for (const mapping of mappings) {
+ const picked =
+ mapping.sourceBlockId in adoptions
+ ? adoptions[mapping.sourceBlockId]
+ : (mapping.defaultAdoptPath ?? '')
+ const honoured =
+ picked !== '' && mapping.adoptablePaths.includes(picked) && !claimed.has(picked) ? picked : ''
+ if (honoured !== '') claimed.add(honoured)
+ chosen.set(mapping.sourceBlockId, honoured)
+ }
+ return chosen
+}
+
+/**
+ * The retiring URLs the CURRENT choices leave unserved.
+ *
+ * Derived from the raw retiring set rather than read off the diff: the server computes its own
+ * default before the user picks anything, so a preview built from it would omit a URL the user
+ * has just chosen to abandon - in the one modal that exists to state irreversible consequences.
+ */
+export function forkDyingTriggerUrls(
+ retiring: readonly ForkTriggerUrlChange[],
+ chosen: ReadonlyMap
+): ForkTriggerUrlChange[] {
+ const adopted = new Set(Array.from(chosen.values()).filter((path) => path !== ''))
+ return retiring.filter((row) => !adopted.has(row.path))
+}
+
+/**
+ * The block name already claiming each path, from the perspective of one row - so its picker can
+ * disable a URL another trigger took rather than letting the user select a choice the sync will
+ * silently overrule.
+ */
+export function forkTriggerPathOwners(
+ mappings: readonly ForkTriggerMapping[],
+ chosen: ReadonlyMap,
+ forSourceBlockId: string
+): Map {
+ const owners = new Map()
+ for (const mapping of mappings) {
+ if (mapping.sourceBlockId === forSourceBlockId) continue
+ const pick = chosen.get(mapping.sourceBlockId)
+ if (pick) owners.set(pick, mapping.blockName)
+ }
+ return owners
+}
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
index 15f70c53e4d..c28d29c371b 100644
--- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts
@@ -9,6 +9,8 @@ import type {
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
+ ForkTriggerMapping,
+ ForkTriggerUrlChange,
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import {
@@ -33,6 +35,11 @@ import {
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
+import {
+ forkDyingTriggerUrls,
+ forkTriggerChoices,
+ forkTriggerPathOwners,
+} from '@/ee/workspace-forking/components/fork-sync/trigger-choices'
import {
type ForkDirection,
useForkDiff,
@@ -40,6 +47,7 @@ import {
usePromoteFork,
useUpdateForkMapping,
} from '@/ee/workspace-forking/hooks/workspace-fork'
+import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
/**
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
@@ -92,6 +100,12 @@ export interface ForkKindSummary {
export interface ForkSyncController {
direction: ForkDirection
otherWorkspaceName: string
+ /**
+ * The workspace this sync WRITES, named for user-facing copy: the other workspace on push,
+ * "this workspace" on pull. Derived once here so every surface that names the target - the
+ * overwrite confirm, the Trigger URLs heading - says the same thing.
+ */
+ targetWorkspaceName: string
isLoading: boolean
isError: boolean
errorMessage: string | null
@@ -140,6 +154,25 @@ export interface ForkSyncController {
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
copySelected: ReadonlySet
toggleCopyKeys: (keys: string[], checked: boolean) => void
+ /**
+ * Source-deleted references the user accepted losing in the target, keyed `${kind}:${sourceId}`.
+ * In-session only - an acknowledgment is a decision about this sync, never a stored mapping.
+ */
+ droppedRefs: ReadonlySet
+ /** Toggle one acknowledgment; the row leaves "Blocking sync" for "Will be cleared". */
+ toggleDroppedRef: (kind: string, sourceId: string, dropped: boolean) => void
+ /** Accept losing every source-deleted blocker at once - the volume is the point. */
+ dropAllDeletedRefs: () => void
+ /** Source-deleted blockers still awaiting a decision, for the bulk affordance. */
+ droppableBlockerCount: number
+ /**
+ * How many blocking rows name each resource, keyed `${kind}:${sourceId}`. A drop is inherently
+ * resource-scoped - the remapper clears by reference, not by field - so the row that offers the
+ * control states how many fields it covers rather than implying a per-field choice.
+ */
+ blockingUsesByResource: ReadonlyMap
+ /** Index of the row that owns each resource's Drop control, so it renders exactly once. */
+ firstBlockingRowForResource: ReadonlyMap
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
referencedByKind: ReadonlyMap
unreferencedByKind: ReadonlyMap
@@ -152,6 +185,26 @@ export interface ForkSyncController {
workflowChanges: ForkWorkflowChange[]
/** Names of target workflows this sync archives, for the confirm modal. */
archivedWorkflowNames: string[]
+ /**
+ * Public trigger URLs the CURRENT picks leave unserved. Derived from the retiring set and the
+ * live adoption choices, so the heads-up, the overwrite confirm and the rows always agree.
+ */
+ triggerUrlChanges: ForkTriggerUrlChange[]
+ /** Arriving triggers whose URL is a choice: keep a retiring one, or mint a new one. */
+ triggerMappings: ForkTriggerMapping[]
+ /**
+ * The chosen adoption per source trigger block. A key present with a path adopts it; present
+ * with `''` mints a new URL; absent takes the server's `defaultAdoptPath`.
+ */
+ triggerAdoptions: Readonly>
+ setTriggerAdoption: (sourceBlockId: string, path: string) => void
+ /** Paths another trigger row has already claimed, so this row can disable them. */
+ triggerPathOwnersFor: (sourceBlockId: string) => ReadonlyMap
+ /**
+ * The path a row will actually serve, resolved the same way the server resolves it. Never
+ * reports a path another row claimed first, so the row's displayed URL is its real outcome.
+ */
+ triggerChoiceFor: (sourceBlockId: string) => string
/** Names of deployed SOURCE workflows marked "Exclude from sync" - never sent. */
excludedSourceWorkflows: string[]
/** Names of mapped TARGET workflows marked "Exclude from sync" - never replaced or archived. */
@@ -239,6 +292,16 @@ export function useForkSync(params: {
// sync so their references resolve to the copy instead of being cleared.
const [copySelected, setCopySelected] = useState>(new Set())
const [copyDefaulted, setCopyDefaulted] = useState(false)
+ // Source-deleted references the user explicitly accepted losing in the target (keyed by
+ // `${kind}:${sourceId}`). In-session only, like `copySelected` - an acknowledgment is a decision
+ // about THIS sync, never a stored mapping. The server re-checks that each source really is gone
+ // before honouring one.
+ const [droppedRefs, setDroppedRefs] = useState>(new Set())
+ // Which retiring public URL each arriving trigger takes over, keyed by SOURCE block id. Session
+ // state like the two above: the choice is about THIS sync, and once it lands the adopted path is
+ // stored in the target block's `triggerPath`, so later syncs preserve it with no input at all.
+ // `''` is the explicit "mint a new URL" choice, distinct from an absent key (take the default).
+ const [triggerAdoptions, setTriggerAdoptions] = useState>({})
const [submitting, setSubmitting] = useState(false)
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
@@ -248,6 +311,8 @@ export function useForkSync(params: {
setReconfig({})
setCopySelected(new Set())
setCopyDefaulted(false)
+ setDroppedRefs(new Set())
+ setTriggerAdoptions({})
}, [direction, otherWorkspaceId])
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
@@ -266,6 +331,14 @@ export function useForkSync(params: {
[diff.data?.copyableUnmapped]
)
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
+ const triggerMappings = useMemo(
+ () => diff.data?.triggerMappings ?? [],
+ [diff.data?.triggerMappings]
+ )
+ const retiringTriggerUrls = useMemo(
+ () => diff.data?.retiringTriggerUrls ?? [],
+ [diff.data?.retiringTriggerUrls]
+ )
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
@@ -298,6 +371,16 @@ export function useForkSync(params: {
[visibleCopyables, copySelected]
)
+ /**
+ * Keys that no longer need a mapping target: selected for copy, or an acknowledged drop. Kept
+ * separate from `copyingKeys` so a dropped reference is never counted as "copied" in the
+ * per-kind badge.
+ */
+ const satisfiedKeys = useMemo(() => {
+ if (droppedRefs.size === 0) return copyingKeys
+ return new Set([...copyingKeys, ...droppedRefs])
+ }, [copyingKeys, droppedRefs])
+
// Group the visible copy candidates by kind so each renders as its own expandable section
// (chevron + tri-state select-all + count), matching the fork picker. Referenced and
// unreferenced candidates group separately: unreferenced ones (used by no synced workflow)
@@ -448,7 +531,7 @@ export function useForkSync(params: {
// A required reference is satisfied when it has a mapping target OR the user selected it for
// copy (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`.
- const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys)
+ const requiredComplete = isForkRequiredComplete(entries, targets, satisfiedKeys)
// Every required dependent whose parent is RESOLVED must have a value before sync. Under a
// mapped parent the user re-picks against the target; under a copy-resolved parent the field
@@ -494,8 +577,20 @@ export function useForkSync(params: {
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
return mapped || copyingKeys.has(key)
}
- return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
- }, [clearedRefs, entriesByParent, targets, copyingKeys])
+ const { blockers, informational } = splitForkClearedRefs(
+ selectVisibleClearedRefs(clearedRefs, isResolved)
+ )
+ if (droppedRefs.size === 0) return { blockers, informational }
+ // An acknowledged drop stops blocking and moves into the informational "Will be cleared"
+ // list, mirroring the server: it filters the same entries out of its own gate, but only
+ // after re-checking that each source really is gone.
+ const dropped = blockers.filter((ref) => droppedRefs.has(`${ref.kind}:${ref.sourceId}`))
+ if (dropped.length === 0) return { blockers, informational }
+ return {
+ blockers: blockers.filter((ref) => !droppedRefs.has(`${ref.kind}:${ref.sourceId}`)),
+ informational: [...informational, ...dropped],
+ }
+ }, [clearedRefs, entriesByParent, targets, copyingKeys, droppedRefs])
// Per-kind status for the Mappings summary: "Fully mapped" or "n/total mapped", flagged when
// a REQUIRED target is still missing (which blocks Sync). Reads the effective
@@ -510,7 +605,7 @@ export function useForkSync(params: {
const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length
// Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not
// "pending".
- const requiredPending = forkRequiredPending(group.items, targets, copyingKeys)
+ const requiredPending = forkRequiredPending(group.items, targets, satisfiedKeys)
const reconfigPending = reconfigPendingByKind.has(group.kind)
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
})
@@ -665,9 +760,69 @@ export function useForkSync(params: {
)
}
+ const toggleDroppedRef = (kind: string, sourceId: string, dropped: boolean) => {
+ const key = `${kind}:${sourceId}`
+ setDroppedRefs((prev) => {
+ const next = new Set(prev)
+ if (dropped) next.add(key)
+ else next.delete(key)
+ return next
+ })
+ }
+
+ // Only `source-deleted` blockers are droppable: an unmapped-copyable can be copied and a
+ // missing workflow can be deployed, so neither is a dead end the user should be able to accept.
+ const droppableBlockerKeys = useMemo(
+ () =>
+ blockingRefs
+ .filter((ref) => forkSyncBlockerReasonFor(ref) === 'source-deleted')
+ .map((ref) => `${ref.kind}:${ref.sourceId}`),
+ [blockingRefs]
+ )
+
+ const dropAllDeletedRefs = () => {
+ setDroppedRefs((prev) => new Set([...prev, ...droppableBlockerKeys]))
+ }
+
+ // Blocking rows indexed by the resource they name, so the Drop control renders once per resource
+ // and can state how many fields it covers - matching what the sync actually does.
+ const { blockingUsesByResource, firstBlockingRowForResource } = useMemo(() => {
+ const uses = new Map()
+ const firstRow = new Map()
+ blockingRefs.forEach((ref, index) => {
+ const key = `${ref.kind}:${ref.sourceId}`
+ uses.set(key, (uses.get(key) ?? 0) + 1)
+ if (!firstRow.has(key)) firstRow.set(key, index)
+ })
+ return { blockingUsesByResource: uses, firstBlockingRowForResource: firstRow }
+ }, [blockingRefs])
+
+ const setTriggerAdoption = (sourceBlockId: string, path: string) => {
+ setTriggerAdoptions((prev) => ({ ...prev, [sourceBlockId]: path }))
+ }
+
+ /** Live choices, resolved exactly as the server will resolve them (first claim wins a path). */
+ const chosenTriggerPaths = useMemo(
+ () => forkTriggerChoices(triggerMappings, triggerAdoptions),
+ [triggerMappings, triggerAdoptions]
+ )
+
+ const triggerUrlChanges = useMemo(
+ () => forkDyingTriggerUrls(retiringTriggerUrls, chosenTriggerPaths),
+ [retiringTriggerUrls, chosenTriggerPaths]
+ )
+
+ const triggerPathOwnersFor = (sourceBlockId: string): ReadonlyMap =>
+ forkTriggerPathOwners(triggerMappings, chosenTriggerPaths, sourceBlockId)
+
+ /** The path a row will actually serve, or '' for a new URL - never a claim another row won. */
+ const triggerChoiceFor = (sourceBlockId: string): string =>
+ chosenTriggerPaths.get(sourceBlockId) ?? ''
+
const discard = () => {
setTargets({})
setReconfig({})
+ setTriggerAdoptions({})
}
const sync = async () => {
@@ -685,6 +840,27 @@ export function useForkSync(params: {
const selectedCopyables = visibleCopyables.filter((candidate) =>
copySelected.has(forkRefKey(candidate))
)
+ // Acknowledged drops, captured at confirm time like every other payload. The server honours
+ // one only after re-checking that the source resource is genuinely gone.
+ const dropReferences = Array.from(droppedRefs).map((key) => {
+ const separator = key.indexOf(':')
+ return {
+ kind: key.slice(0, separator) as ForkMappingEntry['kind'],
+ sourceId: key.slice(separator + 1),
+ }
+ })
+ // Only the choices that DIFFER from the server's default need sending - an untouched row is
+ // already what the server would pick, so an empty list means "the preview, as shown".
+ const triggerMappingOverrides = triggerMappings
+ .filter(
+ (mapping) =>
+ mapping.sourceBlockId in triggerAdoptions &&
+ (triggerAdoptions[mapping.sourceBlockId] || null) !== mapping.defaultAdoptPath
+ )
+ .map((mapping) => ({
+ sourceBlockId: mapping.sourceBlockId,
+ adoptPath: triggerAdoptions[mapping.sourceBlockId] || null,
+ }))
try {
await updateMapping.mutateAsync({
workspaceId,
@@ -716,6 +892,10 @@ export function useForkSync(params: {
// existing store is left untouched.
...(dependentValues !== null ? { dependentValues } : {}),
...(selectedCopyables.length > 0 ? { copyResources } : {}),
+ ...(dropReferences.length > 0 ? { dropReferences } : {}),
+ ...(triggerMappingOverrides.length > 0
+ ? { triggerMappings: triggerMappingOverrides }
+ : {}),
},
})
@@ -751,11 +931,26 @@ export function useForkSync(params: {
// Activity entry (needsConfiguration/clearedOptional are recorded there) and a
// needs-config workflow visibly stays undeployed. Deploy FAILURES remain a real,
// actionable outcome, so they keep a warning.
+ const dropped = result.droppedReferences.length
+ // Naming the dropped count is the point of making the drop explicit: the fields really are
+ // blank in the target now, and the server reports only the acknowledgments it honoured.
+ const droppedSuffix =
+ dropped > 0 ? ` ${dropped} deleted reference${dropped === 1 ? '' : 's'} dropped.` : ''
+ // A dead webhook URL fails silently and externally - nothing in the app breaks - so the one
+ // moment the user can act on it is right after the sync that killed it.
+ const deadUrls = result.triggerUrlChanges.length
+ const urlSuffix =
+ deadUrls > 0
+ ? ` ${deadUrls} webhook URL${deadUrls === 1 ? '' : 's'} stopped being served — re-register ${deadUrls === 1 ? 'it' : 'them'}.`
+ : ''
+ const suffix = `${droppedSuffix}${urlSuffix}`
if (result.deployFailed > 0) {
const n = result.deployFailed
toast.warning(
- `${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.`
+ `${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.${suffix}`
)
+ } else if (suffix !== '') {
+ toast.warning(`${label}.${suffix}`)
} else {
toast.success(label)
}
@@ -769,6 +964,7 @@ export function useForkSync(params: {
return {
direction,
otherWorkspaceName,
+ targetWorkspaceName: direction === 'push' ? otherWorkspaceName : 'this workspace',
isLoading: enabled && mapping.isLoading,
isError: mapping.isError,
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
@@ -793,6 +989,12 @@ export function useForkSync(params: {
copyingKeys,
copySelected,
toggleCopyKeys,
+ droppedRefs,
+ toggleDroppedRef,
+ dropAllDeletedRefs,
+ droppableBlockerCount: droppableBlockerKeys.length,
+ blockingUsesByResource,
+ firstBlockingRowForResource,
referencedByKind,
unreferencedByKind,
hasVisibleCopyables: visibleCopyables.length > 0,
@@ -800,6 +1002,12 @@ export function useForkSync(params: {
dependentClears,
workflowChanges,
archivedWorkflowNames,
+ triggerUrlChanges,
+ triggerMappings,
+ triggerAdoptions,
+ setTriggerAdoption,
+ triggerPathOwnersFor,
+ triggerChoiceFor,
excludedSourceWorkflows: diff.data?.excludedSourceWorkflows ?? [],
excludedTargetWorkflows: diff.data?.excludedTargetWorkflows ?? [],
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx
index 6cc9789acd1..c8582141d6b 100644
--- a/apps/sim/ee/workspace-forking/components/forks.tsx
+++ b/apps/sim/ee/workspace-forking/components/forks.tsx
@@ -46,6 +46,7 @@ import {
} from '@/ee/workspace-forking/hooks/workspace-fork'
import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
+import { buildWebhookTriggerUrl } from '@/triggers/webhook-url'
/** Explains a disabled lineage action whose target workspace the viewer cannot open. */
const NO_ACCESS_TOOLTIP = "You don't have access to this workspace"
@@ -152,7 +153,7 @@ function ForkSyncDetailView({
},
]
- const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace'
+ const targetWorkspaceName = controller.targetWorkspaceName
return (
<>
@@ -224,6 +225,36 @@ function ForkSyncDetailView({
) : null}