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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+193
@@ -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,
|
||||
}
|
||||
}
|
||||
+299
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user