chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,314 @@
'use client'
import { useCallback, useMemo } from 'react'
import { Badge, Button } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
import type { BackgroundWorkItem } from '@/lib/api/contracts/workspace-fork'
import {
ActivityLog,
type ActivityLogEntry,
} from '@/app/workspace/[workspaceId]/settings/components/activity-log'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { useWorkspaceBackgroundWork } from '@/ee/workspace-forking/hooks/background-work'
const logger = createLogger('ForkActivityPanel')
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
/** Join "N verb" segments (verbs like "updated" aren't pluralized), dropping zero counts. */
function countList(pairs: Array<[number | undefined, string]>): string {
return pairs
.filter(([n]) => (n ?? 0) > 0)
.map(([n, verb]) => `${n} ${verb}`)
.join(' · ')
}
/** A named group (one resource kind or change action) of a job's report. */
interface ReportGroup {
label: string
names: string[]
}
/** A job's expanded report: named groups plus plain notes (counts / warnings). */
interface JobReport {
groups: ReportGroup[]
notes: Array<{ value: string; warning?: boolean }>
}
/** The workspace whose activity is being viewed, for phrasing rows recorded on either side of an edge. */
interface ActivityView {
workspaceId: string
/** Lineage partner names by id (the parent + this workspace's forks). */
workspaceNames: ReadonlyMap<string, string>
}
/** Display name of the workspace a partner-recorded row was keyed to (the edge's other side). */
function partnerName(job: BackgroundWorkItem, view: ActivityView): string {
return view.workspaceNames.get(job.workspaceId) ?? 'another workspace'
}
/**
* The activity-row title, derived per kind from the job's metadata. Every event is
* recorded once, keyed to the workspace it was initiated from, so a row keyed to an
* edge partner is phrased from THIS workspace's side (e.g. the parent's "Pushed to X"
* row reads "Received push from <fork>" when X views it).
*/
function jobTitle(job: BackgroundWorkItem, view: ActivityView): string {
const m = job.metadata
const recordedHere = job.workspaceId === view.workspaceId
switch (job.kind) {
case 'fork_content_copy':
// A partner-recorded copy row is either this workspace's own creation (recorded
// on the parent, carrying our id as the child) or a sync's resource fill.
if (!recordedHere && m?.childWorkspaceId === view.workspaceId) {
return `Forked from "${partnerName(job, view)}"`
}
return m?.childWorkspaceName
? `Forked into "${m.childWorkspaceName}"`
: (job.message ?? 'Fork')
case 'fork_sync':
if (!recordedHere) {
return m?.direction === 'pull'
? `Pulled by "${partnerName(job, view)}"`
: `Received push from "${partnerName(job, view)}"`
}
if (!m?.otherWorkspaceName) return job.message ?? 'Sync'
return m.direction === 'pull'
? `Pulled from "${m.otherWorkspaceName}"`
: `Pushed to "${m.otherWorkspaceName}"`
case 'fork_rollback':
if (!recordedHere) return `Sync undone in "${partnerName(job, view)}"`
return m?.otherWorkspaceName
? `Undid sync from "${m.otherWorkspaceName}"`
: (job.message ?? 'Rollback')
default:
return job.message ?? 'Activity'
}
}
/** Short action label for the Event badge, per job kind. */
function jobEventLabel(job: BackgroundWorkItem): string {
switch (job.kind) {
case 'fork_content_copy':
return 'Fork'
case 'fork_sync':
return job.metadata?.direction === 'pull' ? 'Pull' : 'Push'
case 'fork_rollback':
return 'Rollback'
default:
return 'Activity'
}
}
/**
* Badge variant: bad outcomes keep the status colors (red/amber), while successful
* rows are colored by operation so Fork / Push / Pull / Rollback are distinguishable
* at a glance.
*/
function jobBadgeVariant(job: BackgroundWorkItem) {
if (job.status === 'failed') return 'red' as const
if (job.status === 'completed_with_warnings') return 'amber' as const
if (job.status !== 'completed') return 'gray-secondary' as const
switch (job.kind) {
case 'fork_content_copy':
return 'blue' as const
case 'fork_sync':
return job.metadata?.direction === 'pull' ? ('cyan' as const) : ('green' as const)
case 'fork_rollback':
return 'purple' as const
default:
return 'gray-secondary' as const
}
}
/** Build a job's report (named groups + plain notes) from its metadata. */
function jobReport(job: BackgroundWorkItem): JobReport {
const m = job.metadata
const groups: ReportGroup[] = []
const notes: JobReport['notes'] = []
if (!m) return { groups, notes }
const addGroup = (label: string, names: string[] | undefined) => {
if (names && names.length > 0) groups.push({ label, names })
}
if (job.kind === 'fork_sync') {
addGroup('Updated', m.updatedNames)
addGroup('Created', m.createdNames)
addGroup('Archived', m.archivedNames)
// Pre-names entries fall back to the count summary (redeployed mirrors updated).
if (groups.length === 0) {
const counts = countList([
[m.updated, 'updated'],
[m.created, 'created'],
[m.archived, 'archived'],
])
if (counts) notes.push({ value: counts })
}
if (m.needsConfiguration && m.needsConfiguration.length > 0) {
for (const item of m.needsConfiguration) {
notes.push({
value: `${item.workflowName} — re-check ${item.blocks.join(', ')}`,
warning: true,
})
}
}
if (m.clearedOptional && m.clearedOptional.length > 0) {
for (const item of m.clearedOptional) {
notes.push({
value: `${item.workflowName} — optional cleared in ${item.blocks.join(', ')}`,
})
}
}
if (m.deployFailed && m.deployFailed > 0) {
notes.push({ value: `${plural(m.deployFailed, 'workflow')} failed to deploy`, warning: true })
}
return { groups, notes }
}
if (job.kind === 'fork_rollback') {
const counts = countList([
[m.restored, 'restored'],
[m.unarchived, 'unarchived'],
[m.removed, 'removed'],
[m.skipped, 'skipped'],
])
if (counts) notes.push({ value: counts })
return { groups, notes }
}
// fork_content_copy: a named breakdown of everything copied, by kind.
addGroup('Workflows', m.workflowNames)
addGroup('Knowledge bases', m.knowledgeBaseNames)
addGroup('Tables', m.tableNames)
addGroup('Files', m.fileNames)
addGroup('Custom tools', m.customToolNames)
addGroup('Skills', m.skillNames)
addGroup('MCP servers', m.mcpServerNames)
addGroup('Workflow MCP servers', m.workflowMcpServerNames)
// Sync content-copy rows record per-kind COUNTS only (fork rows carry names), so fall back
// to the counts when no named group rendered.
if (groups.length === 0) {
const counts = [
[m.workflowsCopied, 'workflow'],
[m.knowledgeBases, 'knowledge base'],
[m.tables, 'table'],
[m.files, 'file'],
]
.filter(([n]) => ((n as number | undefined) ?? 0) > 0)
.map(([n, noun]) => plural(n as number, noun as string))
.join(' · ')
if (counts) notes.push({ value: counts })
}
if (m.failed && m.failed > 0) {
notes.push({ value: `${plural(m.failed, 'resource')} failed to copy`, warning: true })
}
if (m.clearingFailed) {
notes.push({ value: 'Reference cleanup incomplete', warning: true })
}
return { groups, notes }
}
/** The expanded detail box content for one job: named groups, notes, and any error. */
function jobDetails(job: BackgroundWorkItem, report: JobReport) {
return (
<>
{report.groups.map((group) => (
<div key={group.label} className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>{group.label}</span>
<span className='min-w-0 flex-1 text-[var(--text-primary)]'>
{group.names.join(', ')}
</span>
</div>
))}
{report.notes.map((note, index) => (
<span
key={`${index}:${note.value}`}
className={note.warning ? 'text-[var(--text-error)]' : 'text-[var(--text-secondary)]'}
>
{note.value}
</span>
))}
{job.error ? <span className='text-[var(--text-error)]'>{job.error}</span> : null}
</>
)
}
/** Maps a background job to the shared {@link ActivityLog} row shape. */
function toActivityEntry(job: BackgroundWorkItem, view: ActivityView): ActivityLogEntry {
const report = jobReport(job)
const hasDetails = report.groups.length > 0 || report.notes.length > 0 || Boolean(job.error)
return {
id: job.id,
timestamp: formatDateTime(new Date(job.startedAt)),
event: (
<Badge variant={jobBadgeVariant(job)} size='sm' className='shrink-0'>
{jobEventLabel(job)}
</Badge>
),
description: jobTitle(job, view),
actor: job.metadata?.actorName || 'System',
details: hasDetails ? jobDetails(job, report) : undefined,
}
}
interface ForkActivityPanelProps {
/** Poll the durable fork-job audit trail involving this workspace. */
workspaceId: string
/** Lineage partner names by id (the parent + forks), for phrasing partner-recorded rows. */
workspaceNames: ReadonlyMap<string, string>
}
/**
* A durable audit log of every fork, sync, and rollback involving the workspace
* (both sides of each fork edge), rendered through the shared {@link ActivityLog}
* so it reads identically to the enterprise audit log: each row (timestamp, action
* badge, description, actor) expands to a per-kind breakdown of what changed.
*/
export function ForkActivityPanel({ workspaceId, workspaceNames }: ForkActivityPanelProps) {
const { data, isPending, isError, hasNextPage, fetchNextPage, isFetchingNextPage } =
useWorkspaceBackgroundWork(workspaceId)
const view: ActivityView = { workspaceId, workspaceNames }
const jobs = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.items)
}, [data])
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage().catch((error: unknown) => {
logger.error('Failed to load more fork activity', { error })
})
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<ActivityLog
entries={jobs.map((job) => toActivityEntry(job, view))}
eventColumn='compact'
// A failed or still-loading feed must never claim "nothing here yet".
emptyState={
isError ? (
<SettingsEmptyState variant='inline'>
Couldn't load activity. Try again shortly.
</SettingsEmptyState>
) : isPending ? undefined : (
<SettingsEmptyState variant='inline'>
Nothing here yet. Forks, syncs, and rollbacks will appear here.
</SettingsEmptyState>
)
}
footer={
hasNextPage ? (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
) : undefined
}
/>
)
}
@@ -0,0 +1,28 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { groupForkFilesIntoFolders } from '@/ee/workspace-forking/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'])
})
})
@@ -0,0 +1,197 @@
'use client'
import { useId, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
export interface ForkFileTreeItem {
id: string
label: string
}
export interface ForkFileTreeFolder {
id: string
name: string
files: ForkFileTreeItem[]
}
/** A flat copyable file with its folder grouping, before {@link groupForkFilesIntoFolders}. */
export interface ForkFlatFile {
id: string
label: string
folderId: string | null
folderName: string | null
}
/**
* Group flat files into folders (sorted by name, each file sorted by label) plus a root bucket
* for files with no folder. A file whose folder id is set but whose name is null (its folder was
* deleted) falls into the root bucket, so it stays selectable rather than hiding under a phantom.
*/
export function groupForkFilesIntoFolders(files: ForkFlatFile[]): {
folders: ForkFileTreeFolder[]
rootFiles: ForkFileTreeItem[]
} {
const folderById = new Map<string, ForkFileTreeFolder>()
const rootFiles: ForkFileTreeItem[] = []
for (const file of files) {
const item: ForkFileTreeItem = { id: file.id, label: file.label }
if (file.folderId && file.folderName) {
let folder = folderById.get(file.folderId)
if (!folder) {
folder = { id: file.folderId, name: file.folderName, files: [] }
folderById.set(file.folderId, folder)
}
folder.files.push(item)
} else {
rootFiles.push(item)
}
}
const folders = Array.from(folderById.values()).sort((a, b) => a.name.localeCompare(b.name))
for (const folder of folders) folder.files.sort((a, b) => a.label.localeCompare(b.label))
rootFiles.sort((a, b) => a.label.localeCompare(b.label))
return { folders, rootFiles }
}
interface ForkFileTreeProps {
folders: ForkFileTreeFolder[]
rootFiles: ForkFileTreeItem[]
isSelected: (fileId: string) => boolean
onToggleFile: (fileId: string, checked: boolean) => void
/** Toggle every file in a folder at once (the folder-level select-all). */
onToggleMany: (fileIds: string[], checked: boolean) => void
disabled?: boolean
}
/**
* Folder ▸ file collapsible tree shared by the fork picker and the sync copy selector, so both
* surfaces group files identically. Each folder is a tri-state select-all header that expands to
* its files; files with no folder render at the top level. Files are the only copyable kind that
* nests - tables, knowledge bases, custom tools, and skills stay flat at the top level.
*/
export function ForkFileTree({
folders,
rootFiles,
isSelected,
onToggleFile,
onToggleMany,
disabled = false,
}: ForkFileTreeProps) {
return (
<div className='flex flex-col gap-1'>
{folders.map((folder) => (
<ForkFileFolderRow
key={folder.id}
folder={folder}
isSelected={isSelected}
onToggleFile={onToggleFile}
onToggleMany={onToggleMany}
disabled={disabled}
/>
))}
{rootFiles.map((file) => (
<ForkFileRow
key={file.id}
file={file}
checked={isSelected(file.id)}
onToggle={onToggleFile}
disabled={disabled}
/>
))}
</div>
)
}
interface ForkFileFolderRowProps {
folder: ForkFileTreeFolder
isSelected: (fileId: string) => boolean
onToggleFile: (fileId: string, checked: boolean) => void
onToggleMany: (fileIds: string[], checked: boolean) => void
disabled: boolean
}
function ForkFileFolderRow({
folder,
isSelected,
onToggleFile,
onToggleMany,
disabled,
}: ForkFileFolderRowProps) {
const [expanded, setExpanded] = useState(false)
const fileIds = folder.files.map((file) => file.id)
const total = fileIds.length
const selectedCount = fileIds.filter(isSelected).length
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all in ${folder.name}`}
checked={headerState}
onCheckedChange={() => onToggleMany(fileIds, headerState !== true)}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='ml-6 flex flex-col gap-0.5'>
{folder.files.map((file) => (
<ForkFileRow
key={file.id}
file={file}
checked={isSelected(file.id)}
onToggle={onToggleFile}
disabled={disabled}
/>
))}
</div>
) : null}
</div>
)
}
interface ForkFileRowProps {
file: ForkFileTreeItem
checked: boolean
onToggle: (fileId: string, checked: boolean) => void
disabled: boolean
}
function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) {
const itemId = useId()
return (
<label
htmlFor={itemId}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:text-[var(--text-primary)]'
)}
>
<Checkbox
id={itemId}
size='sm'
checked={checked}
onCheckedChange={(value) => onToggle(file.id, value === true)}
disabled={disabled}
/>
<span className='min-w-0 flex-1 truncate'>{file.label}</span>
</label>
)
}
@@ -0,0 +1,193 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
import {
ForkFileTree,
type ForkFlatFile,
groupForkFilesIntoFolders,
} from '@/ee/workspace-forking/components/fork-file-tree/fork-file-tree'
/** A flat copyable resource (table / KB / custom tool / skill / MCP server) in the picker. */
export interface ForkResourcePickerItem {
id: string
label: string
}
interface ResourceKindRowProps {
label: string
items: ForkResourcePickerItem[]
selected: Set<string>
/** Toggle the given ids on/off. Used by the select-all header checkbox. */
onToggleMany: (ids: string[], checked: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
disabled?: boolean
}
/**
* One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header
* (count of selected / total) plus, when expanded, a scrollable list of individual resources so
* the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync
* modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree
* instead - use {@link FileKindRow}.
*
* Expanded body uses the settings MCP expand pattern (`border-t` + `--surface-2`) so items
* read as contained in the kind rather than indented siblings of other kinds.
*/
export function ResourceKindRow({
label,
items,
selected,
onToggleMany,
onToggleItem,
disabled = false,
}: ResourceKindRowProps) {
const [expanded, setExpanded] = useState(false)
const fieldId = useId()
const total = items.length
const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0)
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
return (
<div className='flex flex-col'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all ${label}`}
checked={headerState}
onCheckedChange={() =>
onToggleMany(
items.map((item) => item.id),
headerState !== true
)
}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='mt-1 max-h-44 overflow-y-auto border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
<div className='flex flex-col gap-0.5'>
{items.map((item) => {
const isChecked = selected.has(item.id)
const itemId = `${fieldId}-${item.id}`
return (
<label
key={item.id}
htmlFor={itemId}
className={cn(
'flex min-w-0 items-center gap-2 rounded-md py-0.5 text-[var(--text-body)] text-sm',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:text-[var(--text-primary)]'
)}
>
<Checkbox
id={itemId}
size='sm'
checked={isChecked}
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
disabled={disabled}
/>
<span className='truncate'>{item.label}</span>
</label>
)
})}
</div>
</div>
) : null}
</div>
)
}
interface FileKindRowProps {
label: string
files: ForkFlatFile[]
selected: Set<string>
onToggleAll: (selectAll: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
onToggleMany: (ids: string[], checked: boolean) => void
disabled?: boolean
}
/**
* The Files kind: a tri-state "select all" header (count selected / total) that expands to a
* folder ▸ file tree, so the user can copy a whole folder or a specific file. Files are the only
* copyable kind that nests; every other kind uses the flat {@link ResourceKindRow}. Shared by the
* fork and sync copy pickers so both group files identically.
*/
export function FileKindRow({
label,
files,
selected,
onToggleAll,
onToggleItem,
onToggleMany,
disabled = false,
}: FileKindRowProps) {
const [expanded, setExpanded] = useState(false)
const total = files.length
const selectedCount = files.filter((file) => selected.has(file.id)).length
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
const { folders, rootFiles } = useMemo(() => groupForkFilesIntoFolders(files), [files])
return (
<div className='flex flex-col'>
<div className='flex items-center gap-2 text-[var(--text-body)] text-sm'>
<Checkbox
size='sm'
aria-label={`Copy all ${label}`}
checked={headerState}
onCheckedChange={() => onToggleAll(headerState !== true)}
disabled={disabled}
/>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
onClick={() => setExpanded((value) => !value)}
>
<span className='min-w-0 flex-1 truncate'>
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
</span>
<ChevronDown
className={cn(
'h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
</button>
</div>
{expanded ? (
<div className='mt-1 border-[var(--border-1)] border-t bg-[var(--surface-2)] px-2.5 py-2'>
<ForkFileTree
folders={folders}
rootFiles={rootFiles}
isSelected={(id) => selected.has(id)}
onToggleFile={onToggleItem}
onToggleMany={onToggleMany}
disabled={disabled}
/>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,169 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import {
forkBlockerResolution,
selectVisibleClearedRefs,
splitForkClearedRefs,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
type WorkflowRef = Extract<ForkClearedRef, { cause: 'workflow' }>
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
const base = {
targetWorkflowId: 'wf-tgt',
workflowName: 'Workflow',
blockId: 'block-1',
blockLabel: 'Block',
sourceLabel: 'Source',
}
const referenceRef = (
kind: ReferenceRef['kind'],
sourceId: string,
fieldLabel = 'Field',
sourceDeleted = false
): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted })
const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({
...base,
fieldLabel,
cause: 'workflow',
kind: 'workflow',
sourceId,
})
const dependentRef = (
parentKind: DependentRef['parentKind'],
parentSourceId: string,
fieldLabel = 'Field'
): DependentRef => ({
...base,
fieldLabel,
cause: 'dependent',
kind: parentKind,
sourceId: parentSourceId,
parentKind,
parentSourceId,
})
// The page's predicate is `mapped || copied`; here we model each disposition as a resolved key so
// the document-under-KB case is exercised for both a copied parent and a mapped parent.
const resolvedKeys = (...keys: string[]) => {
const set = new Set(keys)
return (kind: string, sourceId: string) => set.has(`${kind}:${sourceId}`)
}
const documentDependent = dependentRef('knowledge-base', 'kb-1', 'Document')
describe('selectVisibleClearedRefs', () => {
it('drops a document dependent when its parent KB is selected for copy', () => {
expect(
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
).toEqual([])
})
it('drops a document dependent when its parent KB is mapped', () => {
// Map vs copy both resolve the parent through the same predicate; modeled identically here.
expect(
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
).toEqual([])
})
it('keeps a document dependent while its parent KB is neither mapped nor copied', () => {
expect(selectVisibleClearedRefs([documentDependent], resolvedKeys())).toEqual([
documentDependent,
])
})
it('keeps a credential-anchored dependent even when the credential is mapped (label still clears)', () => {
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
// A mapped credential remaps to a different account, so the account-scoped label is cleared
// regardless - the entry must stay even though the parent is "resolved".
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys('credential:cred-1'))).toEqual([
labelDependent,
])
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys())).toEqual([labelDependent])
})
it('keeps a table-anchored dependent even when the table is copied/mapped (column still clears)', () => {
const columnDependent = dependentRef('table', 'tbl-1', 'Column')
expect(selectVisibleClearedRefs([columnDependent], resolvedKeys('table:tbl-1'))).toEqual([
columnDependent,
])
})
it('drops the parent KB reference AND its child document together when the KB is resolved', () => {
const kbReference = referenceRef('knowledge-base', 'kb-1', 'Knowledge Base')
expect(
selectVisibleClearedRefs(
[kbReference, documentDependent],
resolvedKeys('knowledge-base:kb-1')
)
).toEqual([])
})
it('applies the same predicate to a reference entry (drops resolved, keeps unresolved)', () => {
const credentialReference = referenceRef('credential', 'cred-1')
expect(
selectVisibleClearedRefs([credentialReference], resolvedKeys('credential:cred-1'))
).toEqual([])
expect(selectVisibleClearedRefs([credentialReference], resolvedKeys())).toEqual([
credentialReference,
])
})
it('always keeps a workflow reference (it cannot be resolved on the page)', () => {
const workflowReference = workflowRef('wf-other')
expect(
selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other'))
).toEqual([workflowReference])
})
})
describe('splitForkClearedRefs', () => {
it('splits reference/workflow causes into blockers and dependents into informational', () => {
const tableReference = referenceRef('table', 'tbl-1')
const workflowReference = workflowRef('wf-other')
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
const { blockers, informational } = splitForkClearedRefs([
tableReference,
workflowReference,
labelDependent,
])
expect(blockers).toEqual([tableReference, workflowReference])
expect(informational).toEqual([labelDependent])
})
it('treats an unmapped MCP server and a source-deleted reference as blockers', () => {
const mcpReference = referenceRef('mcp-server', 'srv-1')
const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true)
const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference])
expect(blockers).toEqual([mcpReference, deletedReference])
expect(informational).toEqual([])
})
})
describe('forkBlockerResolution', () => {
it('phrases each blocker reason with its actionable resolution', () => {
expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe(
'map it to a target or select it for copy'
)
expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe(
'map it to a target or select it for copy'
)
expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe(
'deleted in the source — map it to an existing knowledge base in the target'
)
expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe(
'deploy "Source" in the source or remove the reference'
)
})
it('returns null for non-blocking dependent entries', () => {
expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull()
})
})
@@ -0,0 +1,86 @@
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */
export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean
/**
* Parent kinds whose dependent child is itself a resource carried alongside the parent, so
* resolving the parent (mapping or copying it) PRESERVES the child rather than clearing it -
* currently just knowledge bases: a referenced document is copied with its KB, or auto-copied into
* a mapped KB, and remapped in place. Any other parent's child (a credential's label, a table's
* column) is account/table-scoped and cleared whenever the parent is remapped (the engine's
* `clearDependentsOnRemap`), so those entries stay regardless of the parent's disposition.
*/
const PARENT_KINDS_THAT_PRESERVE_CHILD: ReadonlySet<string> = new Set(['knowledge-base'])
/**
* Narrow the diff's cleared-ref candidates to those still cleared under the live selection:
* - `reference`: drops off once its own resource is resolved (mapped or copied).
* - `dependent`: drops off once its PARENT resource (`parentKind`/`parentSourceId`) is resolved -
* using the SAME predicate as `reference` - but ONLY when the child follows that parent (a
* document under a KB). A credential- or table-anchored dependent is cleared on any parent remap,
* so it stays even after the parent is mapped.
* - `workflow`: always stays - a cross-workflow reference cannot be resolved here.
*
* Pure so the reactive list is unit-testable independent of the page's selection state.
*/
export function selectVisibleClearedRefs(
clearedRefs: ForkClearedRef[],
isResolved: ClearedRefResolvedPredicate
): ForkClearedRef[] {
return clearedRefs.filter((ref) => {
if (ref.cause === 'reference') return !isResolved(ref.kind, ref.sourceId)
// The discriminated union guarantees `parentKind`/`parentSourceId` on a `dependent` variant.
if (ref.cause === 'dependent' && PARENT_KINDS_THAT_PRESERVE_CHILD.has(ref.parentKind)) {
return !isResolved(ref.parentKind, ref.parentSourceId)
}
return true
})
}
/**
* Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the
* sync is disabled while any remain) and the informational remainder (`dependent` entries, owned
* by the reconfigure flow - they clear but never block). Pure, so the page's gate and the two
* sections stay one testable rule.
*/
export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
blockers: ForkClearedRef[]
informational: ForkClearedRef[]
} {
const blockers: ForkClearedRef[] = []
const informational: ForkClearedRef[] = []
for (const ref of visibleRefs) {
if (forkSyncBlockerReasonFor(ref)) blockers.push(ref)
else informational.push(ref)
}
return { blockers, informational }
}
/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
const BLOCKER_KIND_LABEL: Record<string, string> = {
table: 'table',
'knowledge-base': 'knowledge base',
file: 'file',
'custom-tool': 'custom tool',
skill: 'skill',
'mcp-server': 'MCP server',
}
/**
* The actionable resolution line for a blocking entry, phrased for "{block} would lose {field}
* in {workflow} - {resolution}". Null for non-blocking (dependent) entries.
*/
export function forkBlockerResolution(ref: ForkClearedRef): string | null {
const reason = forkSyncBlockerReasonFor(ref)
if (!reason) return null
switch (reason) {
case 'unmapped-copyable':
return 'map it to a target or select it for copy'
case 'source-deleted':
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
case 'workflow-missing':
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
}
}
@@ -0,0 +1,225 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import {
effectiveForkTarget,
forkCopyingKeys,
forkDefaultCopySelection,
forkMappedCopyableKeys,
forkParentResolution,
forkRefKey,
forkRequiredKindsLabel,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
const entry = (overrides: Partial<ForkMappingEntry>): ForkMappingEntry => ({
kind: 'credential',
resourceType: 'oauth_credential',
sourceId: 'src',
sourceLabel: 'Src',
targetId: null,
suggested: false,
required: false,
candidates: [],
candidatesTruncated: false,
...overrides,
})
const copyable = (overrides: Partial<ForkCopyableUnmapped>): ForkCopyableUnmapped => ({
kind: 'knowledge-base',
sourceId: 'kb',
label: 'KB',
parentId: null,
parentLabel: null,
referenced: true,
...overrides,
})
describe('forkRefKey / effectiveForkTarget', () => {
it('shares the kind:sourceId keyspace for entries and copyables', () => {
expect(forkRefKey({ kind: 'knowledge-base', sourceId: 'kb-1' })).toBe('knowledge-base:kb-1')
expect(forkRefKey(entry({ kind: 'table', sourceId: 't-1' }))).toBe('table:t-1')
})
it('prefers an in-session override, else the persisted target, else empty', () => {
const e = entry({ kind: 'table', sourceId: 't-1', targetId: 'persisted' })
expect(effectiveForkTarget(e, { 'table:t-1': 'in-session' })).toBe('in-session')
expect(effectiveForkTarget(e, {})).toBe('persisted')
expect(effectiveForkTarget(entry({ kind: 'table', sourceId: 't-1' }), {})).toBe('')
})
})
describe('copy-vs-map reconciliation', () => {
it('drops a mapped copyable from the visible copy list (maps win over copy)', () => {
const entries = [
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
entry({ kind: 'table', sourceId: 'tbl-1' }),
]
const candidates = [
copyable({ kind: 'knowledge-base', sourceId: 'kb-1' }),
copyable({ kind: 'table', sourceId: 'tbl-1' }),
]
const mapped = forkMappedCopyableKeys(entries, {})
expect(mapped.has('knowledge-base:kb-1')).toBe(true)
expect(mapped.has('table:tbl-1')).toBe(false)
expect(forkVisibleCopyables(candidates, mapped).map(forkRefKey)).toEqual(['table:tbl-1'])
})
it('an in-session mapping target also drops the copyable', () => {
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1' })]
const mapped = forkMappedCopyableKeys(entries, { 'knowledge-base:kb-1': 'kb-tgt' })
expect(
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mapped)
).toEqual([])
})
it('copyingKeys = the visible candidates that are selected for copy', () => {
const visible = [
copyable({ kind: 'table', sourceId: 'tbl-1' }),
copyable({ kind: 'skill', sourceId: 'sk-1' }),
]
expect([...forkCopyingKeys(visible, new Set(['table:tbl-1']))]).toEqual(['table:tbl-1'])
})
})
describe('forkDefaultCopySelection', () => {
it('seeds every referenced candidate and leaves unreferenced ones unselected', () => {
const selection = forkDefaultCopySelection([
copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }),
copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }),
copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }),
])
expect(selection).toEqual(new Set(['knowledge-base:kb-1']))
})
it('seeds nothing when every candidate is unreferenced', () => {
expect(
forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })])
).toEqual(new Set())
})
})
describe('isForkRequiredComplete', () => {
it('a required ref is satisfied by a mapping target', () => {
const entries = [
entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' }),
]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
it('a required ref is satisfied by a copy selection (server accepts copy as resolving it)', () => {
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
expect(isForkRequiredComplete(entries, {}, new Set(['knowledge-base:kb-1']))).toBe(true)
})
it('a required ref neither mapped nor copied blocks', () => {
const entries = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
})
it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => {
const entries = [
entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }),
]
// MCP servers are never copy candidates, so the copy set can't contain them; only a
// mapping target resolves the entry.
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true)
})
it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => {
// A deleted source is dropped from the copy candidates (its label lookup fails), so the
// only resolution is mapping the dead id to a live target resource.
const entries = [
entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }),
]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true)
})
it('optional refs never block', () => {
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
})
describe('forkRequiredKindsLabel', () => {
it('names credentials and secrets by kind, together or alone', () => {
expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe(
'credentials and secrets'
)
expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials')
expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets')
})
it('falls back to "references" for any other (or empty) kind set', () => {
expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references')
expect(forkRequiredKindsLabel(new Set())).toBe('references')
})
})
describe('forkParentResolution', () => {
const kb = entry({ kind: 'knowledge-base', sourceId: 'kb-1' })
it('is copied when the entry is selected for copy', () => {
expect(forkParentResolution(kb, {}, new Set(['knowledge-base:kb-1']))).toBe('copied')
})
it('is mapped with a persisted or in-session target, unresolved with neither', () => {
expect(
forkParentResolution(
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
{},
new Set()
)
).toBe('mapped')
expect(forkParentResolution(kb, { 'knowledge-base:kb-1': 'kb-tgt' }, new Set())).toBe('mapped')
expect(forkParentResolution(kb, {}, new Set())).toBe('unresolved')
})
it('toggling copy⇄map flips the resolution (the selector scope + seed follow it)', () => {
// Copy-selected: the dependent selectors browse the SOURCE parent.
const copying = new Set(['knowledge-base:kb-1'])
expect(forkParentResolution(kb, {}, copying)).toBe('copied')
// The user maps a target instead: the mapped entry drops out of the visible copyables
// (copy-vs-map reconciliation), so its copying key disappears and the resolution flips.
const targets = { 'knowledge-base:kb-1': 'kb-tgt' }
const mappedKeys = forkMappedCopyableKeys([kb], targets)
const copyingAfterMap = forkCopyingKeys(
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mappedKeys),
copying
)
expect(forkParentResolution(kb, targets, copyingAfterMap)).toBe('mapped')
// Back to copy ('' target override): the copyable is visible + still selected again.
const cleared = { 'knowledge-base:kb-1': '' }
const copyingAfterClear = forkCopyingKeys(
forkVisibleCopyables(
[copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })],
forkMappedCopyableKeys([kb], cleared)
),
copying
)
expect(forkParentResolution(kb, cleared, copyingAfterClear)).toBe('copied')
})
})
describe('forkRequiredPending', () => {
it('is true when a required ref is neither mapped nor selected for copy', () => {
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
expect(forkRequiredPending(items, {}, new Set())).toBe(true)
})
it('is false when the required ref is selected for copy', () => {
const items = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
expect(forkRequiredPending(items, {}, new Set(['knowledge-base:kb-1']))).toBe(false)
})
it('is false when the required ref is mapped', () => {
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' })]
expect(forkRequiredPending(items, {}, new Set())).toBe(false)
})
})
@@ -0,0 +1,136 @@
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
/** `${kind}:${sourceId}` - the shared key for a mapping entry and its copy candidate. */
export const forkRefKey = (ref: { kind: string; sourceId: string }): string =>
`${ref.kind}:${ref.sourceId}`
/** Effective mapping target: the in-session override, else the persisted target, else ''. */
export function effectiveForkTarget(
entry: ForkMappingEntry,
targets: Record<string, string>
): string {
return targets[forkRefKey(entry)] ?? entry.targetId ?? ''
}
/**
* Keys of copyable resources that already have a mapping target (in-session or persisted). Maps win
* over copy, so these drop out of the copy list - the copy-vs-map reconciliation.
*/
export function forkMappedCopyableKeys(
entries: ForkMappingEntry[],
targets: Record<string, string>
): Set<string> {
const keys = new Set<string>()
for (const entry of entries) {
if (effectiveForkTarget(entry, targets) !== '') keys.add(forkRefKey(entry))
}
return keys
}
/** Copy candidates the user has not mapped (a mapped copyable is excluded - copy-vs-map reconcile). */
export function forkVisibleCopyables(
copyableUnmapped: ForkCopyableUnmapped[],
mappedKeys: ReadonlySet<string>
): ForkCopyableUnmapped[] {
return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate)))
}
/**
* The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one
* clears its references, so the common case needs no clicks). Unreferenced candidates - used by
* no synced workflow - start unselected: copying them is opt-in, so scratch data created in the
* source is never pushed by surprise.
*/
export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set<string> {
const keys = new Set<string>()
for (const candidate of copyableUnmapped) {
if (candidate.referenced) keys.add(forkRefKey(candidate))
}
return keys
}
/** Keys of the visible copy candidates actually selected for copy. */
export function forkCopyingKeys(
visibleCopyables: ForkCopyableUnmapped[],
copySelected: ReadonlySet<string>
): Set<string> {
const keys = new Set<string>()
for (const candidate of visibleCopyables) {
const key = forkRefKey(candidate)
if (copySelected.has(key)) keys.add(key)
}
return keys
}
/**
* How a mapping entry is resolved under the live selection, for its dependents' behavior:
* - `copied`: selected for copy - dependents stay editable against the SOURCE parent (the copy
* will contain the source's children), seeded from the source reference.
* - `mapped`: has an effective target - dependents re-pick against the TARGET parent.
* - `unresolved`: neither - dependents are disabled; the parent's own gate owns the block.
* Mapped and copied are mutually exclusive by construction (a mapped copyable is excluded from
* the copy candidates), so the branch order is not load-bearing.
*/
export type ForkParentResolution = 'mapped' | 'copied' | 'unresolved'
export function forkParentResolution(
entry: ForkMappingEntry,
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): ForkParentResolution {
if (copyingKeys.has(forkRefKey(entry))) return 'copied'
return effectiveForkTarget(entry, targets) !== '' ? 'mapped' : 'unresolved'
}
/**
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
* branches are mutually exclusive.
*/
export function isForkRequiredComplete(
entries: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): boolean {
return entries.every(
(entry) =>
!entry.required ||
effectiveForkTarget(entry, targets) !== '' ||
copyingKeys.has(forkRefKey(entry))
)
}
/**
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
*/
export function forkRequiredPending(
items: ForkMappingEntry[],
targets: Record<string, string>,
copyingKeys: ReadonlySet<string>
): boolean {
return items.some(
(entry) =>
entry.required &&
effectiveForkTarget(entry, targets) === '' &&
!copyingKeys.has(forkRefKey(entry))
)
}
/**
* Human label for the kinds still failing the required gate, for "Map all required {label} first"
* messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's
* failure toast so both name the obstacle identically. Credentials and secrets are named
* explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in
* the cleared-ref blockers (the collector excludes them), so they need their own wording. Any
* other kind falls back to "references".
*/
export function forkRequiredKindsLabel(kinds: ReadonlySet<string>): string {
const credentials = kinds.has('credential')
const secrets = kinds.has('env-var')
if (credentials && secrets) return 'credentials and secrets'
if (credentials) return 'credentials'
if (secrets) return 'secrets'
return 'references'
}
@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
interface DependentFieldSelectorProps {
selectorKey: SelectorKey
/** Full selector context, including the newly-chosen parent value. */
context: Record<string, string>
/** False until the parent (credential/KB) target is chosen. */
enabled: boolean
value: string
onChange: (value: string) => void
title: string
}
/**
* A controlled, standalone selector for the sync page's pre-sync reconfigure: fetches
* options via the shared selector data layer (the same `useSelectorOptions` registry the
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
* {@link ConnectorSelectorField}.
*/
export function DependentFieldSelector({
selectorKey,
context,
enabled,
value,
onChange,
title,
}: DependentFieldSelectorProps) {
const selectorContext = useMemo<SelectorContext>(() => {
const ctx: SelectorContext = {}
Object.assign(ctx, context)
return ctx
}, [context])
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
context: selectorContext,
enabled,
})
const comboboxOptions = useMemo<ComboboxOption[]>(
() => options.map((option) => ({ label: option.label, value: option.id })),
[options]
)
if (isLoading && enabled) {
return (
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
<Loader className='size-3.5' animate />
Loading
</div>
)
}
return (
<ChipCombobox
className='w-full'
options={comboboxOptions}
value={value || undefined}
onChange={(next) => onChange(next)}
searchable
searchPlaceholder={`Search ${title.toLowerCase()}...`}
placeholder={`Select ${title.toLowerCase()}`}
disabled={!enabled}
emptyMessage={`No ${title.toLowerCase()} found`}
/>
)
}
@@ -0,0 +1,99 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
const field = (overrides: Partial<ForkDependentReconfig> = {}): ForkDependentReconfig => ({
parentKind: 'credential',
parentSourceId: 'cred-1',
parentContextKey: 'oauthCredential',
targetWorkflowId: 'wf-1',
targetBlockId: 'blk-1',
blockName: 'Gmail',
subBlockKey: 'folder',
selectorKey: 'gmail.labels',
title: 'Label',
currentValue: 'INBOX',
required: false,
consumesContextKeys: [],
context: {},
sourceValue: '',
...overrides,
})
describe('dependentKey', () => {
it('keys by target workflow + block + subblock', () => {
expect(
dependentKey(field({ targetWorkflowId: 'w', targetBlockId: 'b', subBlockKey: 's' }))
).toBe('w:b:s')
})
})
describe('effectiveDependentValue', () => {
it('returns the in-session re-pick when present', () => {
const f = field()
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_42' }, false)).toBe('Label_42')
})
it('returns the stored currentValue when no re-pick and the parent is unchanged', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX')
})
it('returns blank when the parent changed (the stored value no longer resolves)', () => {
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('')
})
it('an in-session re-pick wins even when the parent changed', () => {
const f = field({ currentValue: 'INBOX' })
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_99' }, true)).toBe('Label_99')
})
it('an explicit empty re-pick is respected (not treated as absent)', () => {
const f = field({ currentValue: 'INBOX' })
expect(effectiveDependentValue(f, { [dependentKey(f)]: '' }, false)).toBe('')
})
})
describe('effectiveCopyDependentValue', () => {
const copyField = (overrides: Partial<ForkDependentReconfig> = {}) =>
field({
parentKind: 'knowledge-base',
parentSourceId: 'kb-src',
parentContextKey: 'knowledgeBaseId',
subBlockKey: 'documentSelector',
selectorKey: 'knowledge.documents',
title: 'Document',
...overrides,
})
it('seeds from the raw source reference when nothing is stored (the copy will contain it)', () => {
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, {})).toBe('doc-src')
})
it('prefers the stored value over the source reference (a saved re-pick survives reload)', () => {
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, {})).toBe('doc-saved')
})
it('an in-session re-pick wins over both', () => {
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: 'doc-picked' })).toBe('doc-picked')
})
it('an explicit empty re-pick is respected (a required field then gates as usual)', () => {
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: '' })).toBe('')
})
it('is blank when the source never referenced anything and nothing was stored', () => {
const f = copyField({ currentValue: '', sourceValue: '' })
expect(effectiveCopyDependentValue(f, {})).toBe('')
})
})
@@ -0,0 +1,39 @@
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
export function dependentKey(dependent: ForkDependentReconfig): string {
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
}
/**
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
* stored value was for the previous parent and won't resolve against the new one. Shared by the
* sync gate + payload build and the per-block selector so the rule can't drift between them.
*/
export function effectiveDependentValue(
field: ForkDependentReconfig,
reconfig: Record<string, string>,
parentChanged: boolean
): string {
const repicked = reconfig[dependentKey(field)]
if (repicked !== undefined) return repicked
return parentChanged ? '' : field.currentValue
}
/**
* The value sent + displayed for a dependent whose parent is resolved by COPY: the user's
* in-session re-pick, else the stored value, else the field's raw SOURCE reference. The copy
* brings the source parent's children along (a copied KB carries its referenced documents), so
* the source reference is exactly what the copied parent will contain - the selector browses the
* SOURCE parent and this seed resolves there. An explicit empty re-pick is respected (it gates a
* required field as usual).
*/
export function effectiveCopyDependentValue(
field: ForkDependentReconfig,
reconfig: Record<string, string>
): string {
const repicked = reconfig[dependentKey(field)]
if (repicked !== undefined) return repicked
return field.currentValue || field.sourceValue
}
@@ -0,0 +1,747 @@
'use client'
import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react'
import {
Badge,
ChevronDown,
ChipCombobox,
ChipSwitch,
CollapsibleCard,
cn,
FieldDivider,
Label,
} from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import type {
ForkCopyableUnmapped,
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
} from '@/lib/api/contracts/workspace-fork'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
FileKindRow,
ResourceKindRow,
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
import { forkBlockerResolution } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
import type {
ForkKindSummary,
ForkMappingGroup,
ForkSyncController,
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
import type { SelectorKey } from '@/hooks/selectors/types'
/**
* Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match
* the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind
* is a flat list.
*/
const COPYABLE_KIND_SECTIONS: ReadonlyArray<{
kind: ForkCopyableUnmapped['kind']
label: string
}> = [
{ kind: 'file', label: 'Files' },
{ kind: 'table', label: 'Tables' },
{ kind: 'knowledge-base', label: 'Knowledge bases' },
{ kind: 'custom-tool', label: 'Custom tools' },
{ kind: 'skill', label: 'Skills' },
{ kind: 'mcp-server', label: 'MCP servers' },
]
/**
* Sentinel option value for the "New copy" entry - the displayed resolution while a copyable
* is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect,
* never sent.
*/
const NEW_COPY_VALUE = '__new_copy__'
/** Fixed target-picker width so every mapping row's control lines up as one column (mirrors General). */
const MAPPING_TARGET_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
interface DependentBlock {
targetBlockId: string
blockName: string
fields: ForkDependentReconfig[]
}
interface WorkflowDependents {
workflowId: string
workflowName: string
blocks: DependentBlock[]
}
/**
* Bucket an entry's dependents per workflow, then per block within it - the
* workflow → block hierarchy the workflow cards render from.
*/
function groupDependentsByWorkflow(
workflows: ForkResourceUsage['workflows'],
dependents: ForkDependentReconfig[]
): WorkflowDependents[] {
const byWorkflow = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependents) {
const list = byWorkflow.get(dependent.targetWorkflowId)
if (list) list.push(dependent)
else byWorkflow.set(dependent.targetWorkflowId, [dependent])
}
return workflows.map((workflow) => {
const byBlock = new Map<string, DependentBlock>()
for (const field of byWorkflow.get(workflow.workflowId) ?? []) {
let block = byBlock.get(field.targetBlockId)
if (!block) {
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
byBlock.set(field.targetBlockId, block)
}
block.fields.push(field)
}
return {
workflowId: workflow.workflowId,
workflowName: workflow.workflowName,
blocks: Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)),
}
})
}
/** Chain state for one block: the SelectorContext values its parent fields provide. */
function blockChainState(
block: DependentBlock,
effectiveValue: (field: ForkDependentReconfig) => string
) {
const providedValues: Record<string, string> = {}
const providedContextKeys = new Set<string>()
for (const field of block.fields) {
if (field.providesContextKey) {
providedContextKeys.add(field.providesContextKey)
const value = effectiveValue(field)
if (value) providedValues[field.providesContextKey] = value
}
}
return { providedValues, providedContextKeys }
}
/** Store a re-pick and invalidate in-block children chained off the changed field. */
function applyDependentRepick(
setReconfig: Dispatch<SetStateAction<Record<string, string>>>,
field: ForkDependentReconfig,
blockFields: ForkDependentReconfig[],
value: string
) {
setReconfig((prev) => {
const nextState = { ...prev, [dependentKey(field)]: value }
// A changed parent invalidates its children's stale re-picks.
const providedKey = field.providesContextKey
if (providedKey) {
for (const sibling of blockFields) {
if (sibling.consumesContextKeys.includes(providedKey)) {
delete nextState[dependentKey(sibling)]
}
}
}
return nextState
})
}
interface DependentSelectorProps {
field: ForkDependentReconfig
block: DependentBlock
target: string
parentChanged: boolean
/** True when the parent is resolved by COPY: browse the SOURCE parent, seeded from the source. */
copying: boolean
workspaceId: string
sourceWorkspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/**
* One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent
* (pre-filled from the stored value, blank after a parent change) and is disabled until the
* parent target is set. Under a COPY-resolved parent it browses the SOURCE parent (the copy
* will contain exactly those children), pre-filled with the source reference. Either way it
* stays disabled until every chained in-block parent has a value, and a re-pick invalidates
* chained children.
*/
function DependentSelector({
field,
block,
target,
parentChanged,
copying,
workspaceId,
sourceWorkspaceId,
reconfig,
setReconfig,
}: DependentSelectorProps) {
const effectiveValue = (f: ForkDependentReconfig) =>
copying
? effectiveCopyDependentValue(f, reconfig)
: effectiveDependentValue(f, reconfig, parentChanged)
const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue)
// Disabled until every in-block parent it depends on has a value, so a child never queries
// a stale upstream value.
const ready = field.consumesContextKeys.every(
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
)
// A copy-resolved parent has no target id until the sync runs - scope to the SOURCE parent
// instead (its children are what the copy brings), keeping the selector fully editable.
const parentValue = copying ? field.parentSourceId : target
return (
<DependentFieldSelector
selectorKey={field.selectorKey as SelectorKey}
context={{
...field.context,
...providedValues,
// Owning workspace, for workspace-scoped selectors like table.columns.
workspaceId: copying ? sourceWorkspaceId : workspaceId,
[field.parentContextKey]: parentValue,
}}
enabled={parentValue !== '' && ready}
value={effectiveValue(field)}
onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)}
title={field.title}
/>
)
}
interface DependentWorkflowCardProps {
workflow: WorkflowDependents
target: string
parentChanged: boolean
/** True when the parent is resolved by COPY - the selectors browse the SOURCE parent. */
copying: boolean
workspaceId: string
sourceWorkspaceId: string
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
}
/**
* One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table
* workflow sidebar's input mapping and the enrichment config use): the header names the
* workflow; the body groups fields under block → optional tool → plain field label.
* Cards holding a required field start expanded - a required field is what gates Sync.
*/
function DependentWorkflowCard({
workflow,
target,
parentChanged,
copying,
workspaceId,
sourceWorkspaceId,
reconfig,
setReconfig,
}: DependentWorkflowCardProps) {
const [collapsed, setCollapsed] = useState(
() => !workflow.blocks.some((block) => block.fields.some((field) => field.required))
)
return (
<CollapsibleCard
title={workflow.workflowName}
collapsed={collapsed}
onToggleCollapse={() => setCollapsed((value) => !value)}
>
<div className='flex flex-col gap-3'>
{workflow.blocks.map((block) => {
const topLevel = block.fields.filter((field) => !field.toolName)
const byTool = new Map<string, ForkDependentReconfig[]>()
for (const field of block.fields) {
if (!field.toolName) continue
const list = byTool.get(field.toolName)
if (list) list.push(field)
else byTool.set(field.toolName, [field])
}
const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b))
return (
<div key={block.targetBlockId} className='flex flex-col gap-2'>
<Label className='text-small'>{block.blockName}</Label>
{topLevel.map((field) => (
<div key={dependentKey(field)} className='flex flex-col gap-1'>
<Label className='text-[var(--text-muted)] text-caption'>
{field.title}
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
</Label>
<DependentSelector
field={field}
block={block}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={workspaceId}
sourceWorkspaceId={sourceWorkspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
</div>
))}
{toolGroups.map(([toolName, fields]) => (
<div key={toolName} className='flex flex-col gap-1.5 pl-2'>
<span className='text-[var(--text-muted)] text-small'>{toolName}</span>
{fields.map((field) => (
<div key={dependentKey(field)} className='flex flex-col gap-1'>
<Label className='text-[var(--text-muted)] text-caption'>
{field.title}
{field.required ? (
<span className='text-[var(--text-error)]'> *</span>
) : null}
</Label>
<DependentSelector
field={field}
block={block}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={workspaceId}
sourceWorkspaceId={sourceWorkspaceId}
reconfig={reconfig}
setReconfig={setReconfig}
/>
</div>
))}
</div>
))}
</div>
)
})}
</div>
</CollapsibleCard>
)
}
interface MappingEntryProps {
controller: ForkSyncController
group: ForkMappingGroup
entry: ForkMappingEntry
}
/**
* One mapping entry: the source ↔ target picker row (with a "Copy instead" entry for copy
* candidates and per-source taken-target disabling on push), then one collapsible card per
* workflow the resource is used in, holding that workflow's dependent field selectors.
* Workflows with nothing to configure are named in a muted note so the usage stays visible.
*/
function MappingEntry({ controller, group, entry }: MappingEntryProps) {
const target = controller.targetFor(entry)
const takenOwners = controller.takenOwnersFor(entry, group.items)
const parentChanged = controller.parentChangedFor(entry)
const entryRefKey = forkRefKey(entry)
const copying = controller.copyingKeys.has(entryRefKey)
const usages = controller.usagesForEntry(entry)
const dependents = controller.dependentsForEntry(entry)
// Group once per (usages, dependents) change - both keep stable references from the
// controller's memoized maps, so this skips recompute across the page's frequent re-renders.
const workflows = useMemo(
() => groupDependentsByWorkflow(usages, dependents),
[usages, dependents]
)
const configurable = workflows.filter((workflow) => workflow.blocks.length > 0)
const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0)
return (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<div className='flex items-center justify-between gap-4'>
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
<ChipCombobox
className='w-full'
align='start'
options={[
// While copy-resolved, the closed control shows the copy by NAME (the copy
// keeps the source's name) via a hidden display-only option; the list itself
// stays unambiguous.
...(controller.copyableKeys.has(entryRefKey) && copying
? [{ label: entry.sourceLabel, value: NEW_COPY_VALUE, hidden: true }]
: []),
// The way back to the copy flow after mapping - clears the target via onSelect.
...(controller.copyableKeys.has(entryRefKey) && target !== ''
? [
{
label: 'New copy',
value: NEW_COPY_VALUE,
onSelect: () => controller.setTarget(entry, ''),
},
]
: []),
...entry.candidates.map((candidate) => {
const owner = takenOwners.get(candidate.id)
return {
label: owner ? `${candidate.label} · mapped to ${owner}` : candidate.label,
value: candidate.id,
disabled: owner !== undefined,
}
}),
]}
value={copying ? NEW_COPY_VALUE : target || undefined}
onChange={(value) => controller.setTarget(entry, value)}
placeholder='Select target'
/>
</div>
</div>
{entry.candidatesTruncated ? (
<p className='text-[var(--text-muted)] text-small'>
More options than shown search by name.
</p>
) : null}
</div>
{configurable.map((workflow) => (
<DependentWorkflowCard
key={workflow.workflowId}
workflow={workflow}
target={target}
parentChanged={parentChanged}
copying={copying}
workspaceId={controller.targetWorkspaceId}
sourceWorkspaceId={controller.sourceWorkspaceId}
reconfig={controller.reconfig}
setReconfig={controller.setReconfig}
/>
))}
{usedOnly.length > 0 ? (
<p className='text-[var(--text-tertiary)] text-caption'>
Also used in {usedOnly.map((workflow) => workflow.workflowName).join(', ')} nothing to
configure there.
</p>
) : null}
</div>
)
}
/** Badge copy + color for one kind's mapping status (shared badge rules with the old summary). */
function kindStatusBadge(summary: ForkKindSummary): {
label: string
variant: 'green' | 'amber' | 'gray-secondary'
} {
const { total, mapped, copied, requiredPending, reconfigPending } = summary
const resolved = mapped + copied
const complete = resolved === total && !reconfigPending
const label = complete
? mapped === total
? 'Fully mapped'
: copied === total
? 'Copied'
: 'Mapped & copied'
: reconfigPending && resolved === total
? 'Needs setup'
: copied > 0
? `${resolved}/${total} ready`
: `${mapped}/${total} mapped`
const variant = complete
? 'green'
: requiredPending || reconfigPending
? 'amber'
: 'gray-secondary'
return { label, variant }
}
interface MappingKindRowProps {
controller: ForkSyncController
group: ForkMappingGroup
summary: ForkKindSummary
}
/**
* One resource kind in the Mappings section: a chevron header row with the kind's status badge
* (the summary IS the entry), expanding to that kind's mapping entries. Mirrors the expandable
* kind rows of the Copy resources section so the two sections share one interaction rhythm.
*/
function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
const [open, setOpen] = useState(false)
const badge = kindStatusBadge(summary)
return (
<div className='flex flex-col'>
<button
type='button'
onClick={() => setOpen((value) => !value)}
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
>
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
<Badge variant={badge.variant} size='sm' dot>
{badge.label}
</Badge>
<ChevronDown
className={cn(
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
open && 'rotate-180'
)}
/>
</button>
{open ? (
<div className='flex flex-col pt-3 pb-1'>
{group.items.map((entry, index) => (
<Fragment key={forkRefKey(entry)}>
{index > 0 ? <FieldDivider /> : null}
<MappingEntry controller={controller} group={group} entry={entry} />
</Fragment>
))}
</div>
) : null}
</div>
)
}
interface CopyKindSectionsProps {
controller: ForkSyncController
byKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
}
/**
* One expandable row per copyable kind present in `byKind` - shared by the referenced group
* and the unreferenced "Not used by any workflow" group so both render exactly like the fork
* picker (files as a folder tree, every other kind flat).
*/
function CopyKindSections({ controller, byKind }: CopyKindSectionsProps) {
return (
<>
{COPYABLE_KIND_SECTIONS.map((section) => {
const candidates = byKind.get(section.kind)
if (!candidates || candidates.length === 0) return null
// The picker rows track item ids; copy selection is keyed `${kind}:${id}`
// (matching `forkRefKey`), so derive the per-kind selected-id subset and
// re-prefix on toggle.
const selectedIds = new Set(
candidates
.filter((candidate) => controller.copySelected.has(forkRefKey(candidate)))
.map((candidate) => candidate.sourceId)
)
const toggleMany = (ids: string[], checked: boolean) =>
controller.toggleCopyKeys(
ids.map((id) => `${section.kind}:${id}`),
checked
)
const toggleAll = (selectAll: boolean) =>
toggleMany(
candidates.map((candidate) => candidate.sourceId),
selectAll
)
return section.kind === 'file' ? (
<FileKindRow
key={section.kind}
label={section.label}
files={candidates.map((candidate) => ({
id: candidate.sourceId,
label: candidate.label,
folderId: candidate.parentId,
folderName: candidate.parentLabel,
}))}
selected={selectedIds}
onToggleAll={toggleAll}
onToggleItem={(id, checked) => toggleMany([id], checked)}
onToggleMany={toggleMany}
disabled={controller.submitting}
/>
) : (
<ResourceKindRow
key={section.kind}
label={section.label}
items={candidates.map((candidate) => ({
id: candidate.sourceId,
label: candidate.label,
}))}
selected={selectedIds}
onToggleMany={toggleMany}
onToggleItem={(id, checked) => toggleMany([id], checked)}
disabled={controller.submitting}
/>
)
})}
</>
)
}
interface ForkSyncViewProps {
controller: ForkSyncController
onDirectionChange: (direction: ForkDirection) => void
}
/**
* The parent fork edge's sync experience as page sections: pick a direction, review the
* deployed-workflow changes, resolve the per-kind mappings (each kind an expandable row whose
* status badge doubles as the summary), choose which unmapped resources to copy, and clear any
* blocking references. The page header's Sync action commits it (after the overwrite confirm).
*/
export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) {
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
return (
<div className='flex flex-col gap-7'>
<SettingsSection label='Sync direction'>
<div className='flex flex-col gap-2'>
<ChipSwitch
value={controller.direction}
onChange={onDirectionChange}
aria-label='Sync direction'
options={[
{ value: 'push', label: 'Push' },
{ value: 'pull', label: 'Pull' },
]}
/>
<p className='text-[var(--text-muted)] text-caption'>
{controller.direction === 'push'
? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.`
: `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`}
</p>
</div>
</SettingsSection>
{/* Surface a failed/pending fetch so the page never renders blank below the direction. */}
{detailsError ? (
<SettingsSection label='Sync details'>
<div className='text-[var(--text-error)] text-small'>{detailsError}</div>
</SettingsSection>
) : !controller.hasDiff ? (
<div className='text-[var(--text-muted)] text-small'>Loading sync details</div>
) : null}
{/* Always shown once the diff loads so the user sees the section even with nothing
deployed - an empty change list means the source has no deployed workflows (every
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
{controller.hasDiff ? (
<SettingsSection label='Deployed workflows'>
{controller.workflowChanges.length > 0 ? (
<div className='flex flex-col gap-1'>
{controller.workflowChanges.map((change, index) => {
const renamed = change.currentName !== change.otherName
return (
<div
key={`${change.action}:${change.currentName}:${index}`}
className='flex min-w-0 items-center gap-1.5'
>
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
{change.currentName}
</span>
{renamed ? (
<>
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
{change.otherName}
</span>
</>
) : null}
</div>
)
})}
</div>
) : (
<div className='text-[var(--text-muted)] text-small'>
{controller.direction === 'push'
? `No deployed workflows. Deploy workflows to push changes to ${controller.otherWorkspaceName}.`
: `No deployed workflows in ${controller.otherWorkspaceName} to pull.`}
</div>
)}
</SettingsSection>
) : null}
{headsUp ? (
<SettingsSection label='Heads up'>
{controller.mcpReauthCount > 0 ? (
<div className='text-[var(--text-muted)] text-small'>
{controller.mcpReauthCount} MCP server(s) use OAuth and must be re-authorized in the
target workspace.
</div>
) : null}
{controller.inlineSecretCount > 0 ? (
<div className='mt-1 text-[var(--text-muted)] text-small'>
{controller.inlineSecretCount} inline secret(s) can't be auto-mapped — set them in the
target workspace.
</div>
) : null}
</SettingsSection>
) : null}
{controller.hasMapping ? (
<SettingsSection label='Mappings'>
{controller.groups.length > 0 ? (
<div className='flex flex-col gap-2'>
{controller.groups.map((group) => {
const summary = controller.kindSummaries.find((item) => item.kind === group.kind)
if (!summary) return null
return (
<MappingKindRow
key={group.kind}
controller={controller}
group={group}
summary={summary}
/>
)
})}
</div>
) : (
<SettingsEmptyState variant='inline'>
This workspace's deployed workflows have no mappable references.
</SettingsEmptyState>
)}
</SettingsSection>
) : null}
{controller.hasVisibleCopyables ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{controller.referencedByKind.size > 0 ? (
<CopyKindSections controller={controller} byKind={controller.referencedByKind} />
) : null}
{controller.unreferencedByKind.size > 0 ? (
<>
{controller.referencedByKind.size > 0 ? (
<div className='mt-2 text-[var(--text-muted)] text-caption'>
Not used by any workflow
</div>
) : null}
<CopyKindSections controller={controller} byKind={controller.unreferencedByKind} />
</>
) : null}
</div>
</SettingsSection>
) : null}
{controller.blockingRefs.length > 0 ? (
<SettingsSection label='Blocking sync'>
<div className='flex flex-col gap-1'>
{controller.blockingRefs.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName} {forkBlockerResolution(ref)}
</div>
))}
</div>
</SettingsSection>
) : null}
{controller.dependentClears.length > 0 ? (
<SettingsSection label='Will be cleared'>
<div className='flex flex-col gap-1'>
{controller.dependentClears.map((ref, index) => (
<div
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
className='min-w-0 text-[var(--text-secondary)] text-small'
>
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
{ref.workflowName}
</div>
))}
</div>
<p className='text-[var(--text-muted)] text-caption'>
Re-pick these in the target after the sync.
</p>
</SettingsSection>
) : null}
</div>
)
}
@@ -0,0 +1,810 @@
'use client'
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react'
import { toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import type {
ForkClearedRef,
ForkCopyableUnmapped,
ForkDependentReconfig,
ForkMappingEntry,
ForkResourceUsage,
ForkWorkflowChange,
} from '@/lib/api/contracts/workspace-fork'
import {
selectVisibleClearedRefs,
splitForkClearedRefs,
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
import {
effectiveForkTarget,
type ForkParentResolution,
forkCopyingKeys,
forkDefaultCopySelection,
forkMappedCopyableKeys,
forkParentResolution,
forkRefKey,
forkRequiredKindsLabel,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
import {
dependentKey,
effectiveCopyDependentValue,
effectiveDependentValue,
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
import {
type ForkDirection,
useForkDiff,
useForkMapping,
usePromoteFork,
useUpdateForkMapping,
} from '@/ee/workspace-forking/hooks/workspace-fork'
/**
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
* the mapping view (`getForkMappingView`) skips documents — they ride their parent KB via the
* reconfigure flow — so a `knowledge-document` mapping section is never reachable.
*/
export type MappableMappingKind = Exclude<ForkMappingEntry['kind'], 'knowledge-document'>
/** Section label + display order per mapping kind (one mapping group per kind). */
const MAPPING_SECTION: Record<MappableMappingKind, { label: string; order: number }> = {
credential: { label: 'Credentials', order: 0 },
'env-var': { label: 'Secrets', order: 1 },
table: { label: 'Tables', order: 2 },
'knowledge-base': { label: 'Knowledge bases', order: 3 },
file: { label: 'Files', order: 4 },
'mcp-server': { label: 'MCP servers', order: 5 },
'custom-tool': { label: 'Custom tools', order: 6 },
skill: { label: 'Skills', order: 7 },
}
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
/**
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
* letting the workflow-card grouping memos skip recompute across the page's frequent re-renders.
*/
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
/** Target workflows this sync archives, previewed in the confirm before "and X more". */
export const ARCHIVED_PREVIEW_LIMIT = 5
export interface ForkMappingGroup {
kind: MappableMappingKind
label: string
items: ForkMappingEntry[]
}
/** Per-kind mapping status for the Mappings section's summary badges. */
export interface ForkKindSummary {
kind: MappableMappingKind
total: number
mapped: number
copied: number
requiredPending: boolean
reconfigPending: boolean
}
export interface ForkSyncController {
direction: ForkDirection
otherWorkspaceName: string
isLoading: boolean
isError: boolean
errorMessage: string | null
/** Diff fetch failure, surfaced inline (the mapping may still have loaded). */
diffErrorMessage: string | null
/** True once the diff payload for ANY direction is present (placeholder included). */
hasDiff: boolean
/** True once the mapping payload is present (placeholder included), gating the Mappings section. */
hasMapping: boolean
groups: ForkMappingGroup[]
/** Per-kind mapping status, aligned with `groups` (same kinds, same order). */
kindSummaries: ForkKindSummary[]
/** Effective (in-session override, else persisted/suggested) target for an entry. */
targetFor: (entry: ForkMappingEntry) => string
/** Set an entry's target ('' clears it) and drop its dependents' stale re-picks. */
setTarget: (entry: ForkMappingEntry, value: string) => void
/** Targets already claimed by another source in the same kind (push targets are unique; pull never disables). */
takenOwnersFor: (
entry: ForkMappingEntry,
items: ForkMappingEntry[]
) => ReadonlyMap<string, string>
/** Every workflow an entry's resource is used in (diff-fed; empty until the diff loads). */
usagesForEntry: (entry: ForkMappingEntry) => ForkResourceUsage['workflows']
/** An entry's dependent fields (its credential/KB/table's selectors), from the diff. */
dependentsForEntry: (entry: ForkMappingEntry) => ForkDependentReconfig[]
/**
* Whether an entry needs an in-place reconfigure: its effective target changed in-session,
* or it's an unconfirmed suggestion (accepting it as-is still remaps + clears the dependents).
*/
parentChangedFor: (entry: ForkMappingEntry) => boolean
/** The workspace a MAPPED parent's dependent selectors query against (direction-aware from the diff). */
targetWorkspaceId: string
/**
* The sync's source workspace (push: this one; pull: the other), which a COPY-resolved
* parent's dependent selectors browse - the copy will contain the source parent's children,
* so the source is the truthful catalog to pick from.
*/
sourceWorkspaceId: string
/** In-session dependent re-picks, keyed by `dependentKey`. */
reconfig: Record<string, string>
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
/** Keys the backend offers as copy candidates, for the entry rows' "Copy instead" affordance. */
copyableKeys: ReadonlySet<string>
/** Copyables actually selected for copy (visible + checked), keyed `${kind}:${sourceId}`. */
copyingKeys: ReadonlySet<string>
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
copySelected: ReadonlySet<string>
toggleCopyKeys: (keys: string[], checked: boolean) => void
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
referencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
unreferencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
hasVisibleCopyables: boolean
/** Would-clear references that BLOCK sync (mirrors the server's zero-cleared-refs gate). */
blockingRefs: ForkClearedRef[]
/** Informational would-clear dependents (owned by the reconfigure flow; never block). */
dependentClears: ForkClearedRef[]
/** Deployed-workflow change list (update → create → archive, then by name). */
workflowChanges: ForkWorkflowChange[]
/** Names of target workflows this sync archives, for the confirm modal. */
archivedWorkflowNames: string[]
mcpReauthCount: number
inlineSecretCount: number
dirty: boolean
saving: boolean
save: () => void
discard: () => void
submitting: boolean
syncDisabled: boolean
/** Names the failing gate for the disabled Sync chip's tooltip; undefined when enabled. */
syncDisabledReason: string | undefined
/** Persist the effective mapping + dependents + copy selection, then promote. */
sync: () => Promise<void>
}
const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry)
/**
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
* the dependents). Pure over (entry, in-session targets) so the inline render, the Sync
* gate, and the payload build share one predicate instead of drifting copies.
*/
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
if (next === '') return false
return entry.suggested || next !== (entry.targetId ?? '')
}
/**
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
* on the parent (target) side, so a parent target can back only one source - a second source
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
* share one fork target, which resolves correctly), so pull passes the empty map and never
* disables. Excludes `exclude` so a source never disables its own current selection.
*/
function takenTargetOwners(
items: ForkMappingEntry[],
targets: Record<string, string>,
exclude: ForkMappingEntry
): Map<string, string> {
const owners = new Map<string, string>()
for (const item of items) {
if (entryKey(item) === entryKey(exclude)) continue
const target = targets[entryKey(item)] ?? item.targetId ?? ''
if (target !== '') owners.set(target, item.sourceLabel)
}
return owners
}
/**
* The full sync surface's state for one fork edge, in the chosen direction: the editable
* resource mapping (in-session target overrides + dependent re-picks, persisted via Save or
* as part of Sync), the copy-resources selection (seeded once the diff settles), the reactive
* would-clear blockers, the per-kind status summaries, the Sync gate, and the promote run
* itself. A direction switch drops every in-session choice — the mapping set, copy candidates,
* and blockers all depend on the direction — and the copy selection re-seeds only from a
* settled (non-placeholder) diff so a stale payload can't latch wrong keys.
*/
export function useForkSync(params: {
workspaceId: string
otherWorkspaceId?: string
otherWorkspaceName: string
direction: ForkDirection
enabled: boolean
}): ForkSyncController {
const { workspaceId, otherWorkspaceId, otherWorkspaceName, direction, enabled } = params
// User's IN-SESSION mapping overrides only - NOT the source of truth. The displayed/persisted
// target falls back to each entry's stored `targetId` (see `targetFor`), so a reopened edge
// shows its remembered mappings even though React Query's structural sharing keeps `entries`
// referentially stable (a target-seeding effect gated on `entries` would never re-run there).
const [targets, setTargets] = useState<Record<string, string>>({})
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
// `dependentKey`. Folded into the full effective set sent on save/sync, which the server
// persists as the stored mapping - so the selection survives every future sync without
// re-picking.
const [reconfig, setReconfig] = useState<Record<string, string>>({})
// Referenced-but-unmapped resources the user chose to copy into the target (keyed by
// `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on
// sync so their references resolve to the copy instead of being cleared.
const [copySelected, setCopySelected] = useState<Set<string>>(new Set())
const [copyDefaulted, setCopyDefaulted] = useState(false)
const [submitting, setSubmitting] = useState(false)
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
// copy candidates, and blockers all depend on it.
useEffect(() => {
setTargets({})
setReconfig({})
setCopySelected(new Set())
setCopyDefaulted(false)
}, [direction, otherWorkspaceId])
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled })
const updateMapping = useUpdateForkMapping()
const promote = usePromoteFork()
const entries = useMemo<ForkMappingEntry[]>(() => mapping.data?.entries ?? [], [mapping.data])
const dependentReconfigs = useMemo(
() => diff.data?.dependentReconfigs ?? [],
[diff.data?.dependentReconfigs]
)
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
const copyableUnmapped = useMemo(
() => diff.data?.copyableUnmapped ?? [],
[diff.data?.copyableUnmapped]
)
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
// 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
// list (it re-enters `visibleCopyables` once its effective target is '').
const copyableKeys = useMemo(
() => new Set(copyableUnmapped.map((candidate) => forkRefKey(candidate))),
[copyableUnmapped]
)
// Copy-vs-map reconciliation: a copyable resource the user has given an effective (in-session
// or persisted) mapping target must NOT also appear in the copy list - the user picked map, not
// copy. `forkRefKey` shares the `${kind}:${sourceId}` keyspace across entries and candidates,
// so a mapped entry's key directly excludes its copy candidate. The server enforces the same
// precedence: a mapped resource resolves != null, so it never reaches the plan's
// `copyableUnmapped`, and a copy request for it is dropped by `buildPromoteCopySelection`.
const mappedCopyableKeys = useMemo(
() => forkMappedCopyableKeys(entries, targets),
[entries, targets]
)
const visibleCopyables = useMemo(
() => forkVisibleCopyables(copyableUnmapped, mappedCopyableKeys),
[copyableUnmapped, mappedCopyableKeys]
)
// Copyables actually selected for copy (visible + checked), keyed for an O(1) lookup so a
// copyable mapping entry can show a "will be copied" note.
const copyingKeys = useMemo(
() => forkCopyingKeys(visibleCopyables, copySelected),
[visibleCopyables, copySelected]
)
// 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)
// render under a muted "Not used by any workflow" grouping and default to unselected.
const { referencedByKind, unreferencedByKind } = useMemo(() => {
const referenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
const unreferenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
for (const candidate of visibleCopyables) {
const groups = candidate.referenced ? referenced : unreferenced
const list = groups.get(candidate.kind)
if (list) list.push(candidate)
else groups.set(candidate.kind, [candidate])
}
return { referencedByKind: referenced, unreferencedByKind: unreferenced }
}, [visibleCopyables])
// Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
// (bring the referenced resources along) needs no clicks; the user can deselect to clear
// instead. Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying
// them is opt-in since nothing references them. Seed ONLY from a settled diff for the current
// direction: on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps
// the previous direction's payload (placeholderData) until the new fetch resolves - seeding
// from it would latch against stale keys and leave the real copyables unchecked, clearing
// their references on Sync.
useEffect(() => {
if (!enabled || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
setCopyDefaulted(true)
setCopySelected(forkDefaultCopySelection(copyableUnmapped))
}, [enabled, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
// Group dependents by their parent (kind:sourceId) once, so each mapping entry gets a
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat the
// workflow-card grouping memos.
const dependentsByParent = useMemo(() => {
const map = new Map<string, ForkDependentReconfig[]>()
for (const dependent of dependentReconfigs) {
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
const list = map.get(key)
if (list) list.push(dependent)
else map.set(key, [dependent])
}
return map
}, [dependentReconfigs])
// Effective target for an entry: the user's in-session override if present, else the
// persisted mapping from the server. Read directly from `entries` so a reopened edge
// reflects stored mappings without a seeding effect.
const targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets)
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
resourceUsages.find(
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
)?.workflows ?? EMPTY_USAGES
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
const parentChangedFor = (entry: ForkMappingEntry): boolean =>
shouldReconfigureEntry(entry, targets)
// Set an entry's in-session mapping target. A `value` of '' explicitly clears it, overriding
// any name-match suggestion (effectiveForkTarget's `??` treats '' as present, so the suggestion
// no longer wins) - so the resource re-enters `visibleCopyables` and is copy-selectable again.
// Changing the parent invalidates its dependents' in-session re-picks (chosen against the old
// account), so drop them.
const setTarget = (entry: ForkMappingEntry, value: string) => {
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
setReconfig((prev) => {
let changed = false
const next = { ...prev }
for (const dependent of dependentsForEntry(entry)) {
const key = dependentKey(dependent)
if (key in next) {
delete next[key]
changed = true
}
}
return changed ? next : prev
})
}
const takenOwnersFor = (
entry: ForkMappingEntry,
items: ForkMappingEntry[]
): ReadonlyMap<string, string> =>
direction === 'push' ? takenTargetOwners(items, targets, entry) : EMPTY_TARGET_OWNERS
const toggleCopyKeys = (keys: string[], checked: boolean) =>
setCopySelected((prev) => {
const next = new Set(prev)
for (const key of keys) {
if (checked) next.add(key)
else next.delete(key)
}
return next
})
// Group mappings by resource type - one accordion row per kind, required types first.
const groups = useMemo<ForkMappingGroup[]>(() => {
const byKind = new Map<MappableMappingKind, ForkMappingEntry[]>()
for (const entry of entries) {
// The mapping view never emits a document entry (it rides its KB), so the group is
// unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound.
if (entry.kind === 'knowledge-document') continue
const list = byKind.get(entry.kind)
if (list) list.push(entry)
else byKind.set(entry.kind, [entry])
}
return Array.from(byKind, ([kind, items]) => ({
kind,
label: MAPPING_SECTION[kind].label,
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
}, [entries])
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
// and several times per field across the Sync gate, the value helper, and the payload build.
const entriesByParent = useMemo(() => {
const map = new Map<string, ForkMappingEntry>()
for (const entry of entries) map.set(entryKey(entry), entry)
return map
}, [entries])
const entryForDependent = (field: ForkDependentReconfig) =>
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
const resolutionFor = (entry: ForkMappingEntry): ForkParentResolution =>
forkParentResolution(entry, targets, copyingKeys)
// The value sent + displayed for a dependent (delegates to the shared per-resolution rule):
// the user's re-pick, else - under a MAPPED parent - the stored value (blank when the parent
// target changed in-session), or - under a COPY-resolved parent - the stored value falling
// back to the raw source reference (which the copied parent will contain). Callers that
// already resolved the parent pass both in to skip repeat lookups.
const dependentValueFor = (
field: ForkDependentReconfig,
parent = entryForDependent(field),
resolution: ForkParentResolution = parent ? resolutionFor(parent) : 'unresolved'
): string => {
if (resolution === 'copied') return effectiveCopyDependentValue(field, reconfig)
return effectiveDependentValue(
field,
reconfig,
parent ? shouldReconfigureEntry(parent, targets) : false
)
}
// 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)
// 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
// pre-fills with the source reference (the copy carries it), so it's satisfied out of the box
// and gates only when explicitly emptied. A dependent whose parent is unresolved can't be
// picked yet (its selector is disabled) and is gated by `requiredComplete` on the parent
// instead, so it's skipped here.
const reconfigComplete = dependentReconfigs.every((field) => {
if (!field.required) return true
const parent = entryForDependent(field)
if (!parent) return true
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') return true
return dependentValueFor(field, parent, resolution) !== ''
})
// Kinds with a required DEPENDENT that still has no value (its parent is resolved): these
// block Sync via `reconfigComplete`, so the summary badge for that kind must not read
// "Fully mapped".
const reconfigPendingByKind = new Set<MappableMappingKind>()
for (const field of dependentReconfigs) {
if (!field.required) continue
const parent = entryForDependent(field)
if (!parent) continue
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') continue
if (dependentValueFor(field, parent, resolution) === '') {
reconfigPendingByKind.add(parent.kind as MappableMappingKind)
}
}
// The references this sync would blank, reactively narrowed to the current selection. A
// resource is "resolved" once it has a mapping target OR is selected for copy - the same
// predicate drives a `reference` (its own resource) and a `dependent` (its PARENT resource),
// so mapping or copying a parent KB makes its child document drop off. Then split:
// `reference`/`workflow` entries are BLOCKERS (Sync stays disabled while any remain -
// mirroring the server's zero-cleared-refs gate); `dependent` entries stay informational
// (the reconfigure flow owns them).
const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => {
const isResolved = (kind: string, sourceId: string) => {
const key = `${kind}:${sourceId}`
const entry = entriesByParent.get(key)
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
return mapped || copyingKeys.has(key)
}
return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
}, [clearedRefs, entriesByParent, targets, copyingKeys])
// 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
// (override-or-persisted) target so it reflects both remembered mappings and in-session edits.
const kindSummaries: ForkKindSummary[] = groups.map((group) => {
const total = group.items.length
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
// Copy-selected items are resolved too (their refs are kept), so they count toward
// completion and render as "copied" rather than unconfigured. mapped/copied are disjoint:
// a mapped copyable is excluded from the copy candidates, so copyingKeys never overlaps a
// mapped entry.
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 reconfigPending = reconfigPendingByKind.has(group.kind)
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
})
// Kinds whose required gate is still failing, so the Sync tooltip can name the actual
// obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector
// excludes required kinds), so the required gate must not borrow the blocker message -
// it would point at a "Blocking sync" section that isn't rendered.
const pendingRequiredKinds = new Set<string>(
kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind)
)
// Sync details still settling for the current direction: loading, a failed/empty mapping
// (`!mapping.data` must not read as "nothing required"), or the PREVIOUS direction's
// placeholder after a switch (syncing on it would send stale mappings/copies and clear
// references). Until `diff.data` arrives `dependentReconfigs` is empty, so `reconfigComplete`
// is vacuously true.
const dataPending =
mapping.isLoading ||
!mapping.data ||
mapping.isPlaceholderData ||
!diff.data ||
diff.isPlaceholderData
// A failed fetch also gates Sync: a failed REFETCH keeps the last successful payload in
// `data` (so `dataPending` stays false), and every gate below would be judging that stale
// snapshot while the page shows the load error.
const dataError = mapping.isError || diff.isError
// Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference
// would clear in a synced target workflow. `requiredComplete` covers the mapping entries
// (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers
// workflow-to-workflow references, which have no mapping entry to resolve.
const syncBlocked = blockingRefs.length > 0
const syncDisabled =
submitting ||
!otherWorkspaceId ||
!requiredComplete ||
!reconfigComplete ||
syncBlocked ||
dataPending ||
dataError
// A load failure outranks the gates - they're computed from the stale (or absent) payload,
// so naming one would mislead. Then priority mirrors the resolution flow: clear the
// blockers, map the required resources, reconfigure their dependents - each failing gate
// names ITS obstacle (an unmapped credential/secret is a required-mapping failure, not a
// cleared-ref blocker; see `pendingRequiredKinds`).
const syncDisabledReason = dataError
? "Couldn't load sync details — reload the page to retry"
: syncBlocked
? 'Resolve every blocking reference first — map it, copy it, or fix it in the source'
: !requiredComplete
? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first`
: !reconfigComplete
? 'Reconfigure all required fields first'
: dataPending
? 'Loading sync details…'
: undefined
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
return [...(diff.data?.workflows ?? [])].sort(
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
)
}, [diff.data?.workflows])
// Target workflows this sync archives (their source was deleted), named in the confirm modal
// so the overwrite warning is concrete.
const archivedWorkflowNames = useMemo(
() =>
workflowChanges
.filter((change) => change.action === 'archive')
.map((change) => change.currentName),
[workflowChanges]
)
// Send the full stored mapping for every dependent whose parent is RESOLVED - mapped (its
// effective value: re-pick, stored, or blank-after-change) or copy-selected (re-pick, stored,
// or the source reference; promote translates a source document id to its copied counterpart
// at write time). The server persists this verbatim as the stored mapping; fields whose
// parent is unresolved are omitted (they can't be configured). This is the whole "what's in
// the mapping goes in" contract, shared by Save and Sync so the two persist identically.
const buildDependentValues = () =>
dependentReconfigs.flatMap((field) => {
const parent = entryForDependent(field)
if (!parent) return []
const resolution = resolutionFor(parent)
if (resolution === 'unresolved') return []
return [
{
workflowId: field.targetWorkflowId,
blockId: field.targetBlockId,
subBlockKey: field.subBlockKey,
value: dependentValueFor(field, parent, resolution),
},
]
})
const buildMappingEntries = () =>
entries.map((entry) => ({
resourceType: entry.resourceType,
sourceId: entry.sourceId,
targetId: targetFor(entry) || null,
}))
// Dirty only on a real change from the stored/suggested target - so a freshly loaded
// mapping (even with name-match suggestions shown) isn't dirty until the user edits.
const targetsDirty = useMemo(
() =>
entries.some((entry) => {
const key = entryKey(entry)
return key in targets && targets[key] !== (entry.targetId ?? '')
}),
[entries, targets]
)
// A dependent re-pick that differs from its stored value also dirties the editor. A re-pick
// under a changed parent is covered by `targetsDirty` (the parent override is the change).
const reconfigDirty = useMemo(
() =>
dependentReconfigs.some((field) => {
const repicked = reconfig[dependentKey(field)]
return repicked !== undefined && repicked !== field.currentValue
}),
[dependentReconfigs, reconfig]
)
const dirty = targetsDirty || reconfigDirty
const save = () => {
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
updateMapping.mutate(
{
workspaceId,
body: {
otherWorkspaceId,
direction,
// Persist the full effective set (WYSIWYG). Only include dependentValues once the
// diff has loaded; omitted before that so an early save can't wipe the store from
// an unknown set.
entries: buildMappingEntries(),
...(diff.data ? { dependentValues: buildDependentValues() } : {}),
},
},
{
onSuccess: () => {
setTargets({})
setReconfig({})
toast.success('Mapping saved')
},
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')),
}
)
}
const discard = () => {
setTargets({})
setReconfig({})
}
const sync = async () => {
if (!otherWorkspaceId) return
setSubmitting(true)
// Capture every payload from the state at confirm time, before any await - the page's
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
// mid-flight edit must not leak into the promote body.
const mappingEntries = buildMappingEntries()
const dependentValues = diff.data ? buildDependentValues() : null
// Copy the referenced-but-unmapped resources the user kept selected, excluding any the
// user mapped in-session (reconciliation: maps win). The backend validates each id
// against the plan's copy candidates too, so a mapped/stale id is dropped server-side
// regardless.
const selectedCopyables = visibleCopyables.filter((candidate) =>
copySelected.has(forkRefKey(candidate))
)
try {
await updateMapping.mutateAsync({
workspaceId,
body: { otherWorkspaceId, direction, entries: mappingEntries },
})
const copyResources = {
knowledgeBases: selectedCopyables
.filter((c) => c.kind === 'knowledge-base')
.map((c) => c.sourceId),
tables: selectedCopyables.filter((c) => c.kind === 'table').map((c) => c.sourceId),
customTools: selectedCopyables
.filter((c) => c.kind === 'custom-tool')
.map((c) => c.sourceId),
skills: selectedCopyables.filter((c) => c.kind === 'skill').map((c) => c.sourceId),
// Files are identified by storage key (the copyable candidate's sourceId is the key).
files: selectedCopyables.filter((c) => c.kind === 'file').map((c) => c.sourceId),
mcpServers: selectedCopyables.filter((c) => c.kind === 'mcp-server').map((c) => c.sourceId),
}
const result = await promote.mutateAsync({
workspaceId,
body: {
otherWorkspaceId,
direction,
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`,
// which means "every dependent went away" and must reconcile/clear the live replace
// targets' stored rows. Collapsing `[]` into omission would make the backend
// PRESERVE stale rows. Only omit before the diff loads (set unknown), so the
// existing store is left untouched.
...(dependentValues !== null ? { dependentValues } : {}),
...(selectedCopyables.length > 0 ? { copyResources } : {}),
},
})
if (!result.promoteRunId) {
if (result.blockers.length > 0) {
// The server's authoritative gate re-found would-clear references (something changed
// between the preview and Sync). The mutation's settled invalidation refetches the
// diff, so the refreshed blocker list is already on its way in.
const count = result.blockers.length
toast.error(
`Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.`
)
return
}
if (result.unmappedRequired.length > 0) {
// Name the actual blocking kinds rather than always blaming credentials: the server
// blocks on required REFERENCES (credentials and/or secrets); required dependents are
// gated client-side before this runs (see the Sync chip's disabled tooltip).
const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind))
toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`)
return
}
toast.error('Sync did not complete')
return
}
const target = otherWorkspaceName || 'the workspace'
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
// A sync only commits once every reference is mapped/copied and every required dependent
// has a value (the zero-cleared-refs invariant + `reconfigComplete`), so the old
// "re-check X block - something may have been cleared" warnings only fired on
// preview-vs-commit races and read as false alarms. Those rare cases still land in the
// 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.
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'}.`
)
} else {
toast.success(label)
}
} catch (error) {
toast.error(getErrorMessage(error, 'Sync failed'))
} finally {
setSubmitting(false)
}
}
return {
direction,
otherWorkspaceName,
isLoading: enabled && mapping.isLoading,
isError: mapping.isError,
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
diffErrorMessage: diff.isError
? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.")
: null,
hasDiff: Boolean(diff.data),
hasMapping: Boolean(mapping.data),
groups,
kindSummaries,
targetFor,
setTarget,
takenOwnersFor,
usagesForEntry,
dependentsForEntry,
parentChangedFor,
targetWorkspaceId: diff.data?.targetWorkspaceId ?? '',
sourceWorkspaceId: diff.data?.sourceWorkspaceId ?? '',
reconfig,
setReconfig,
copyableKeys,
copyingKeys,
copySelected,
toggleCopyKeys,
referencedByKind,
unreferencedByKind,
hasVisibleCopyables: visibleCopyables.length > 0,
blockingRefs,
dependentClears,
workflowChanges,
archivedWorkflowNames,
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
dirty,
saving: updateMapping.isPending,
save,
discard,
submitting,
syncDisabled,
syncDisabledReason,
sync,
}
}
@@ -0,0 +1,299 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
ChipCopyInput,
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@sim/emcn'
import { AlertTriangle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import type { GetForkResourcesResponse } from '@/lib/api/contracts/workspace-fork'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import {
FileKindRow,
ResourceKindRow,
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
import { useForkResources, useForkWorkspace } from '@/ee/workspace-forking/hooks/workspace-fork'
interface ForkWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
sourceWorkspaceId: string
sourceWorkspaceName: string
/** Whether the user is under their workspace cap; creating a fork is gated on this. */
canFork: boolean
/** Sends the user to upgrade (billing) when they try to fork at the cap. */
onUpgrade: () => void
}
type ResourceKey = Exclude<keyof GetForkResourcesResponse, 'deployedWorkflowCount'>
type ResourceSelection = Record<ResourceKey, Set<string>>
const RESOURCE_KINDS: ReadonlyArray<{ key: ResourceKey; label: string }> = [
{ key: 'files', label: 'Files' },
{ key: 'tables', label: 'Tables' },
{ key: 'knowledgeBases', label: 'Knowledge bases' },
{ key: 'customTools', label: 'Custom tools' },
{ key: 'skills', label: 'Skills' },
{ key: 'mcpServers', label: 'MCP servers' },
{ key: 'workflowMcpServers', label: 'Workflow MCP servers' },
]
const emptySelection = (): ResourceSelection => ({
files: new Set(),
tables: new Set(),
knowledgeBases: new Set(),
customTools: new Set(),
skills: new Set(),
mcpServers: new Set(),
workflowMcpServers: new Set(),
})
const fullSelection = (data: GetForkResourcesResponse): ResourceSelection => {
const selection = emptySelection()
for (const kind of RESOURCE_KINDS) {
selection[kind.key] = new Set((data[kind.key] ?? []).map((item) => item.id))
}
return selection
}
/**
* Names and creates a fork of the current workspace, letting the user pick which
* resources to copy (whole kinds or a specific subset). Unselected resources leave
* the corresponding workflow subblocks empty. On success the modal closes - the
* Forks settings page's Activity log tracks the copy job, and the toast offers a
* one-click jump into the new fork.
*/
export function ForkWorkspaceModal({
open,
onOpenChange,
sourceWorkspaceId,
sourceWorkspaceName,
canFork,
onUpgrade,
}: ForkWorkspaceModalProps) {
const router = useRouter()
const forkWorkspace = useForkWorkspace()
const resources = useForkResources(sourceWorkspaceId, open)
const [name, setName] = useState('')
const [selected, setSelected] = useState<ResourceSelection>(emptySelection)
const [defaulted, setDefaulted] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setName(`${sourceWorkspaceName} (fork)`)
setSelected(emptySelection())
setDefaulted(false)
setError(null)
}
}, [open, sourceWorkspaceName])
useEffect(() => {
if (!open || !resources.data || defaulted) return
setDefaulted(true)
setSelected(fullSelection(resources.data))
}, [open, resources.data, defaulted])
const isForking = forkWorkspace.isPending
const availableKinds = useMemo(
() => RESOURCE_KINDS.filter((kind) => (resources.data?.[kind.key].length ?? 0) > 0),
[resources.data]
)
const hasDeselection = useMemo(
() =>
defaulted &&
availableKinds.some(
(kind) => selected[kind.key].size < (resources.data?.[kind.key]?.length ?? 0)
),
[defaulted, availableKinds, selected, resources.data]
)
// A fork always produces a usable workspace: deployed workflows are copied, and
// when the source has none, create-fork seeds a blank starter workflow (plus any
// selected resources). So forking is never blocked - we just set expectations when
// there are no deployed workflows to carry over.
const noDeployedWorkflows =
Boolean(resources.data) && (resources.data?.deployedWorkflowCount ?? 0) === 0
const handleSubmit = () => {
// At a workspace cap, creating a fork is the only gated action - send the user to
// upgrade rather than blocking the whole modal.
if (!canFork) {
onUpgrade()
return
}
const trimmed = name.trim()
// Block until the resources query resolves: building `copy` from an unloaded `resources.data`
// would send an empty selection and silently clear every reference in the fork. The Fork
// action is disabled in this state too; this is the defense-in-depth guard.
if (!trimmed || isForking || !resources.data) return
setError(null)
const copy = Object.fromEntries(
RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])])
)
forkWorkspace.mutate(
{ workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } },
{
onSuccess: (result) => {
// The copy job's progress lands in the page's Activity log; the toast action
// preserves the old modal's one-click "Open fork".
toast.success(`Forked into "${result.workspace.name}"`, {
action: {
label: 'Open fork',
onClick: () => router.push(`/workspace/${result.workspace.id}/w`),
},
})
onOpenChange(false)
},
onError: (err) => setError(err.message || 'Failed to fork workspace'),
}
)
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Fork workspace'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Fork workspace</ChipModalHeader>
<ChipModalBody>
<div className='flex flex-col gap-7 px-2'>
<SettingsSection label='Forking from'>
<ChipCopyInput value={sourceWorkspaceName} aria-label='Forking from' />
</SettingsSection>
<SettingsSection
label='Name'
headerAccessory={
<span className='text-[var(--text-error)]' title='Required'>
*
</span>
}
>
<ChipInput
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
handleSubmit()
}
}}
placeholder='Workspace name'
maxLength={100}
autoComplete='off'
disabled={isForking}
aria-label='Workspace name'
/>
</SettingsSection>
{availableKinds.length > 0 ? (
<SettingsSection label='Copy resources'>
<div className='flex flex-col gap-2'>
{availableKinds.map((kind) =>
kind.key === 'files' ? (
<FileKindRow
key={kind.key}
label={kind.label}
files={resources.data?.files ?? []}
selected={selected.files}
onToggleAll={(selectAll) =>
setSelected((prev) => ({
...prev,
files: selectAll
? new Set((resources.data?.files ?? []).map((item) => item.id))
: new Set<string>(),
}))
}
onToggleItem={(id, checked) =>
setSelected((prev) => {
const next = new Set(prev.files)
if (checked) next.add(id)
else next.delete(id)
return { ...prev, files: next }
})
}
onToggleMany={(ids, checked) =>
setSelected((prev) => {
const next = new Set(prev.files)
for (const id of ids) {
if (checked) next.add(id)
else next.delete(id)
}
return { ...prev, files: next }
})
}
disabled={isForking}
/>
) : (
<ResourceKindRow
key={kind.key}
label={kind.label}
items={resources.data?.[kind.key] ?? []}
selected={selected[kind.key]}
onToggleMany={(ids, checked) =>
setSelected((prev) => {
const next = new Set(prev[kind.key])
for (const id of ids) {
if (checked) next.add(id)
else next.delete(id)
}
return { ...prev, [kind.key]: next }
})
}
onToggleItem={(id, checked) =>
setSelected((prev) => {
const next = new Set(prev[kind.key])
if (checked) next.add(id)
else next.delete(id)
return { ...prev, [kind.key]: next }
})
}
disabled={isForking}
/>
)
)}
{hasDeselection ? (
<div className='flex items-start gap-1.5 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
Some resources are not selected references to them in your workflows will be
cleared in the fork.
</span>
</div>
) : null}
</div>
</SettingsSection>
) : null}
{noDeployedWorkflows ? (
<p className='text-[var(--text-muted)] text-caption'>
No deployed workflows to copy your fork will start with a blank workflow.
</p>
) : null}
</div>
<ChipModalError>{error ?? undefined}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isForking}
primaryAction={{
label: isForking ? 'Forking...' : 'Fork',
onClick: handleSubmit,
// At the cap the button stays clickable (no name needed) so it can route to
// upgrade. Otherwise it needs a name AND the resources query loaded - forking
// before `resources.data` arrives would clear every reference (P1-C).
disabled: isForking || (canFork && (!name.trim() || !resources.data)),
disabledTooltip:
canFork && name.trim() && !resources.data ? 'Loading workspace resources…' : undefined,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,580 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal, toast } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { AlertTriangle, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
forkIdParam,
forkIdUrlKeys,
forkSyncDirectionParam,
forkSyncDirectionUrlKeys,
forkViewParam,
forkViewUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import {
type RowAction,
RowActionsMenu,
} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { ForkActivityPanel } from '@/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel'
import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view'
import {
ARCHIVED_PREVIEW_LIMIT,
useForkSync,
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal'
import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available'
import {
useForkLineage,
useRollbackFork,
useUnlinkFork,
} from '@/ee/workspace-forking/hooks/workspace-fork'
import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
/** Explains a disabled lineage action whose target workspace the viewer cannot open. */
const NO_ACCESS_TOOLTIP = "You don't have access to this workspace"
/** Lineage partner names by id (the parent + this workspace's forks), for the Activity view. */
function lineagePartnerNames(
parent: ForkLineageNodeApi | null,
forks: ForkLineageChildApi[]
): ReadonlyMap<string, string> {
const names = new Map<string, string>()
if (parent) names.set(parent.id, parent.name)
for (const fork of forks) names.set(fork.id, fork.name)
return names
}
interface ForkListRowProps {
name: string
/** Entries for the row's `...` menu (Edit mappings / Open workspace / Disconnect). */
actions: RowAction[]
}
function ForkListRow({ name, actions }: ForkListRowProps) {
return (
<div className='flex items-center justify-between gap-3'>
<FloatingOverflowText
label={name}
className='block min-w-0 truncate text-[var(--text-body)] text-sm'
/>
<div className='flex flex-shrink-0 items-center gap-1'>
<RowActionsMenu label='Fork actions' actions={actions} />
</div>
</div>
)
}
interface ForkSyncDetailViewProps {
title: string
workspaceId: string
/** The other side of the edge being synced (this workspace's parent). */
otherWorkspaceId: string
otherWorkspaceName: string
onBack: () => void
/** Header chips rendered left of Sync (e.g. Open workspace) — the caller owns those. */
actions: SettingsAction[]
}
/**
* The parent edge's sync page (reached from the parent row): direction, deployed-workflow
* changes, per-kind mappings (each an expandable row whose status badge is the summary),
* copy resources, and blocking references, all as page sections.
* The header's Sync chip is gated until zero blockers + required mappings + reconfigure are
* complete, and always confirms the overwrite first — that confirm is the flow's one modal.
* While the mapping has unsaved edits the header swaps to Discard/Save and leaving is guarded;
* Sync itself persists the effective mapping as part of the run.
*/
function ForkSyncDetailView({
title,
workspaceId,
otherWorkspaceId,
otherWorkspaceName,
onBack,
actions,
}: ForkSyncDetailViewProps) {
// Sync direction is shareable view state: a copied link opens the same side of the sync.
const [direction, setDirection] = useQueryState(forkSyncDirectionParam.key, {
...forkSyncDirectionParam.parser,
...forkSyncDirectionUrlKeys,
})
const controller = useForkSync({
workspaceId,
otherWorkspaceId,
otherWorkspaceName,
direction,
enabled: true,
})
// Guard leaving the detail view (Back) while the mapping has unsaved edits, and feed
// the shared settings dirty store so a sidebar section switch confirms too.
const guard = useSettingsUnsavedGuard({ isDirty: controller.dirty })
const [confirmSyncOpen, setConfirmSyncOpen] = useState(false)
// Sync is the edge's primary action, so it's the rightmost/black chip; the caller's
// Open workspace chip sits left of it. Dirty mapping edits swap the whole cluster
// for Discard/Save until they're saved or discarded.
const panelActions: SettingsAction[] = controller.dirty
? saveDiscardActions({
dirty: controller.dirty,
saving: controller.saving,
onSave: controller.save,
onDiscard: controller.discard,
})
: [
...actions,
{
text: controller.submitting ? 'Working...' : 'Sync',
variant: 'primary' as const,
onSelect: () => setConfirmSyncOpen(true),
disabled: controller.syncDisabled,
tooltip: controller.syncDisabled
? controller.syncDisabledReason
: `Push to or pull from ${otherWorkspaceName}`,
},
]
const targetWorkspaceName = direction === 'push' ? otherWorkspaceName : 'this workspace'
return (
<>
<SettingsPanel
back={{
text: 'Workspace Forks',
icon: ArrowLeft,
onSelect: () =>
guard.guardBack(() => {
void setDirection(null)
onBack()
}),
}}
title={title}
actions={panelActions}
>
<ForkSyncView
controller={controller}
onDirectionChange={(next) => void setDirection(next)}
/>
</SettingsPanel>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
<ChipConfirmModal
open={confirmSyncOpen}
onOpenChange={setConfirmSyncOpen}
srTitle='Sync workspace'
title='Overwrite target workspace'
text={[
'The target may have been modified since the last sync. Syncing will ',
{ text: 'overwrite any changes', bold: true },
' there. Continue?',
]}
confirm={{
label: 'Sync',
onClick: () => {
setConfirmSyncOpen(false)
void controller.sync()
},
pending: controller.submitting,
pendingLabel: 'Syncing...',
}}
>
{controller.archivedWorkflowNames.length > 0 ? (
<div className='flex flex-col gap-1 px-2'>
<p className='break-words text-[var(--text-primary)] text-sm'>
Will be archived in <span className='font-medium'>{targetWorkspaceName}</span>{' '}
(deleted in the source):
</p>
{controller.archivedWorkflowNames
.slice(0, ARCHIVED_PREVIEW_LIMIT)
.map((name, index) => (
<div
key={`${name}:${index}`}
className='min-w-0 truncate text-[var(--text-muted)] text-small'
>
{name}
</div>
))}
{controller.archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? (
<div className='text-[var(--text-muted)] text-small'>
and {controller.archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more
</div>
) : null}
</div>
) : null}
</ChipConfirmModal>
</>
)
}
interface ForkActivityDetailViewProps {
workspaceId: string
/** Lineage partner names by id, for phrasing rows recorded on the other side of an edge. */
workspaceNames: ReadonlyMap<string, string>
onBack: () => void
/** Header actions (e.g. the destructive Rollback chip while the last sync is undoable). */
actions?: SettingsAction[]
}
/**
* Workspace-scoped activity: every fork, sync, and rollback involving this workspace
* (both sides of each edge), reached from the page header's "See activity" action.
*/
function ForkActivityDetailView({
workspaceId,
workspaceNames,
onBack,
actions,
}: ForkActivityDetailViewProps) {
return (
<SettingsPanel
back={{ text: 'Workspace Forks', icon: ArrowLeft, onSelect: onBack }}
title='Activity'
actions={actions}
>
<ForkActivityPanel workspaceId={workspaceId} workspaceNames={workspaceNames} />
</SettingsPanel>
)
}
/**
* Forks settings page. The workspace's single parent (if it's a fork) sits in its own
* "Parent" section, above the "Forks" list of child forks. The parent row's `...` menu
* has Edit mappings (the child owns its edge's re-picks), Open workspace, and
* Disconnect; fork rows offer Open workspace and Disconnect only. Activity is
* workspace-scoped and lives behind the header's "See activity" action (including
* Rollback when the last sync into this workspace is undoable). Sync lives on the
* parent's sync detail page.
* Forking and sync rewrite workflow state and deployments en masse, so the page is
* workspace-admin only and gated on the workspace's fork entitlement - every fork route
* re-checks both; the server remains the boundary.
*/
export function Forks() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { canAdmin, isLoading: permissionsLoading } = useUserPermissionsContext()
const { available: forkingAvailable, isLoading: availabilityLoading } =
useForkingAvailability(workspaceId)
const canUseForking = forkingAvailable && canAdmin
const { data: workspaces } = useWorkspacesQuery()
const { data: creationPolicy } = useWorkspaceCreationPolicy()
const { navigateToSettings } = useSettingsNavigation()
const lineage = useForkLineage(workspaceId, canUseForking)
const rollback = useRollbackFork()
const unlink = useUnlinkFork()
const [searchTerm, setSearchTerm] = useState('')
const [isForkModalOpen, setIsForkModalOpen] = useState(false)
const [confirmRollbackOpen, setConfirmRollbackOpen] = useState(false)
const [confirmUnlink, setConfirmUnlink] = useState<{ id: string; name: string } | null>(null)
const [selectedForkId, setSelectedForkId] = useQueryState(forkIdParam.key, {
...forkIdParam.parser,
...forkIdUrlKeys,
})
const [forkView, setForkView] = useQueryState(forkViewParam.key, {
...forkViewParam.parser,
...forkViewUrlKeys,
})
const workspaceName = workspaces?.find((workspace) => workspace.id === workspaceId)?.name
const canFork = creationPolicy?.canCreate ?? true
const parent = lineage.data?.parent ?? null
const forks = lineage.data?.children ?? []
const undoableRun = lineage.data?.undoableRun ?? null
const gateLoading = availabilityLoading || permissionsLoading
// Rollback undoes the last sync INTO this workspace, restoring each affected
// workflow to its prior deployed version.
const runRollback = async () => {
if (!undoableRun) return
try {
await rollback.mutateAsync({
workspaceId,
body: { otherWorkspaceId: undoableRun.otherWorkspaceId },
})
toast.success(`Undid sync from "${undoableRun.otherName}"`)
setConfirmRollbackOpen(false)
} catch (err) {
toast.error(getErrorMessage(err, 'Undo failed'))
}
}
const openForkWorkspace = (forkId: string) => {
router.push(`/workspace/${forkId}/w`)
}
const openForkMappings = (forkId: string) => {
void setSelectedForkId(forkId)
}
/** Permanently dissolve the edge with the confirmed workspace; both workspaces remain. */
const runUnlink = async () => {
if (!confirmUnlink) return
try {
await unlink.mutateAsync({
workspaceId,
body: { otherWorkspaceId: confirmUnlink.id },
})
toast.success(`Disconnected "${confirmUnlink.name}"`)
setConfirmUnlink(null)
} catch (err) {
toast.error(getErrorMessage(err, 'Disconnect failed'))
}
}
if (gateLoading) {
return <SettingsPanel />
}
if (!canUseForking) {
return (
<SettingsPanel>
<SettingsEmptyState>
{canAdmin
? 'Forking is not available for this workspace.'
: 'Only workspace admins can manage forks.'}
</SettingsEmptyState>
</SettingsPanel>
)
}
const searchLower = searchTerm.trim().toLowerCase()
const parentVisible =
parent !== null && (!searchLower || parent.name.toLowerCase().includes(searchLower))
const filteredForks = forks.filter((fork) => fork.name.toLowerCase().includes(searchLower))
const hasRows = parent !== null || forks.length > 0
// The sync detail exists only for the PARENT edge: sync (and the mapping re-picks it
// persists) belongs to the child workspace configuring how it maps its parent's
// resources, so a parent browsing its forks gets no detail for them (a stale fork-id
// deep link falls back to the list). Fork rows offer Open workspace / Disconnect only.
const showParentDetail = Boolean(selectedForkId && parent && parent.id === selectedForkId)
// Open workspace sits left of the detail view's primary Sync chip, which the sync
// page owns (it carries the gating). Rollback lives on the Activity view only.
const parentHeaderActions: SettingsAction[] = parent
? [
{
text: 'Open workspace',
onSelect: () => openForkWorkspace(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
]
: []
return (
<>
{showParentDetail && parent ? (
<ForkSyncDetailView
key={parent.id}
title={parent.name}
workspaceId={workspaceId}
otherWorkspaceId={parent.id}
otherWorkspaceName={parent.name}
onBack={() => setSelectedForkId(null)}
actions={parentHeaderActions}
/>
) : forkView === 'activity' ? (
<ForkActivityDetailView
workspaceId={workspaceId}
workspaceNames={lineagePartnerNames(parent, forks)}
onBack={() => setForkView(null)}
actions={
undoableRun
? [
{
text: 'Rollback',
variant: 'destructive',
onSelect: () => setConfirmRollbackOpen(true),
disabled: rollback.isPending,
tooltip: `The last sync into this workspace (from ${undoableRun.otherName}) can be undone — it restores each workflow's prior deployed version.`,
},
]
: undefined
}
/>
) : (
<SettingsPanel
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search forks...',
}}
actions={[
{ text: 'See activity', onSelect: () => void setForkView('activity') },
{
text: 'Create fork',
icon: Plus,
variant: 'primary',
onSelect: () => setIsForkModalOpen(true),
},
]}
>
{lineage.isError ? (
<div className='flex h-full flex-col items-center justify-center gap-2'>
<p className='text-[var(--text-error)] text-sm leading-tight'>
{getErrorMessage(lineage.error, 'Failed to load forks')}
</p>
</div>
) : lineage.isLoading ? null : !hasRows ? (
<SettingsEmptyState>Click "Create fork" above to get started</SettingsEmptyState>
) : (
<div className='flex flex-col gap-7'>
{parentVisible && parent !== null && (
<SettingsSection label='Parent'>
<ForkListRow
name={parent.name}
actions={[
{
label: 'Edit mappings',
onSelect: () => openForkMappings(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
{
label: 'Open workspace',
onSelect: () => openForkWorkspace(parent.id),
disabled: !parent.viewerAccessible,
tooltip: parent.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
// Disconnect stays enabled regardless of access: severing the edge is a
// current-workspace operation (admin on the acting side only), and must
// remain reachable exactly when the other side is inaccessible.
{
label: 'Disconnect',
destructive: true,
onSelect: () => setConfirmUnlink({ id: parent.id, name: parent.name }),
},
]}
/>
</SettingsSection>
)}
<SettingsSection label='Forks'>
{filteredForks.length > 0 ? (
<div className='flex flex-col gap-2'>
{filteredForks.map((fork) => (
<ForkListRow
key={fork.id}
name={fork.name}
actions={[
{
label: 'Open workspace',
onSelect: () => openForkWorkspace(fork.id),
disabled: !fork.viewerAccessible,
tooltip: fork.viewerAccessible ? undefined : NO_ACCESS_TOOLTIP,
},
{
label: 'Disconnect',
destructive: true,
onSelect: () => setConfirmUnlink({ id: fork.id, name: fork.name }),
},
]}
/>
))}
</div>
) : (
<SettingsEmptyState variant='inline'>
{searchTerm.trim()
? `No forks found matching "${searchTerm}"`
: 'No forks yet — click "Create fork" above to get started'}
</SettingsEmptyState>
)}
</SettingsSection>
</div>
)}
</SettingsPanel>
)}
<ForkWorkspaceModal
open={isForkModalOpen}
onOpenChange={setIsForkModalOpen}
sourceWorkspaceId={workspaceId}
sourceWorkspaceName={workspaceName || 'Workspace'}
canFork={canFork}
onUpgrade={() => {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
}}
/>
<ChipConfirmModal
open={confirmUnlink !== null}
onOpenChange={(open) => {
if (!open) setConfirmUnlink(null)
}}
srTitle='Disconnect fork'
title='Disconnect fork'
text={[
'This permanently removes the fork relationship with ',
{ text: confirmUnlink?.name ?? '', bold: true },
". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.",
]}
confirm={{
label: 'Disconnect',
onClick: () => void runUnlink(),
pending: unlink.isPending,
pendingLabel: 'Disconnecting...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
This cannot be undone the saved mappings and sync history for this pair are deleted,
and forking again creates a brand-new workspace.
</span>
</div>
</ChipConfirmModal>
<ChipConfirmModal
open={confirmRollbackOpen}
onOpenChange={setConfirmRollbackOpen}
srTitle='Undo last sync'
title='Undo last sync'
text={[
'This restores each affected workflow to its ',
{ text: 'prior deployed version', bold: true },
' and removes workflows the sync created. Continue?',
]}
confirm={{
label: 'Rollback',
onClick: () => void runRollback(),
pending: rollback.isPending,
pendingLabel: 'Rolling back...',
}}
>
<div className='flex items-start gap-1.5 px-2 text-[var(--text-secondary)] text-caption'>
<AlertTriangle className='mt-[1px] size-[14px] shrink-0' />
<span>
Resources copied into this workspace during syncs may remain afterward rollback
restores workflows to their prior versions but does not remove copied resources.
</span>
</div>
</ChipConfirmModal>
</>
)
}