diff --git a/apps/sim/app/api/organizations/[id]/resources/search/route.ts b/apps/sim/app/api/organizations/[id]/resources/search/route.ts new file mode 100644 index 00000000000..5c8fa5e1308 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/resources/search/route.ts @@ -0,0 +1,64 @@ +import { searchOrganizationResourcesContract } from '@/lib/api/contracts/resource-search' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationResourceOperations, + searchOrganizationResources, +} from '@/lib/workspaces/application/search-organization-resources' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' + +/** Match position, then name: the one order this search pages in. */ +const SEARCH_SORT = 'relevance' + +/** Binds a cursor to the search it was minted for, so it cannot resume a different one. */ +function cursorScope( + organizationId: string, + search: string | undefined, + types: readonly string[] | undefined, + workspaceIds: readonly string[] | undefined +) { + return cursorScopeKey(cursorRoute(searchOrganizationResourcesContract, { id: organizationId }), { + search, + types: types ? [...types].sort().join(',') : undefined, + workspaceIds: workspaceIds ? [...workspaceIds].sort().join(',') : undefined, + }) +} + +/** GET /api/organizations/[id]/resources/search — Resources named like `q` across the viewer's workspaces. */ +export const GET = defineInternalJsonRoute({ + contract: searchOrganizationResourcesContract, + auth: internalSessionAuth, + operation: organizationResourceOperations.search, + rateLimit: internalRateLimits.user({ bucketName: 'organization-resource-search' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + search: query.q, + types: query.types, + workspaceIds: query.workspaceIds, + limit: query.limit, + cursorKeys: readSortedCursor( + query.cursor, + SEARCH_SORT, + 'asc', + cursorScope(params.id, query.q, query.types, query.workspaceIds) + ), + }), + useCase: searchOrganizationResources, + present: ({ items, nextCursorKeys }, { input }) => ({ + items, + nextCursor: writeSortedCursor( + nextCursorKeys, + SEARCH_SORT, + 'asc', + cursorScope(input.organizationId, input.search, input.types, input.workspaceIds) + ), + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts index 91a9f077cb3..d05b543a409 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts @@ -1,4 +1,5 @@ import { + createForkSecretMappingContract, getForkMappingContract, updateForkMappingContract, } from '@/lib/api/contracts/workspace-fork' @@ -8,6 +9,7 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { createWorkspaceForkSecretMapping } from '@/ee/workspace-forking/application/create-secret-mapping' import { getWorkspaceForkMappingDetails } from '@/ee/workspace-forking/application/mapping-details' import { forkOperations } from '@/ee/workspace-forking/application/operations' import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' @@ -38,3 +40,14 @@ export const PUT = defineInternalJsonRoute({ useCase: updateWorkspaceForkMappings, present: (result) => ({ success: true as const, ...result }), }) + +export const POST = defineInternalJsonRoute({ + contract: createForkSecretMappingContract, + auth: internalSessionAuth, + operation: forkOperations.createSecretMapping, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: createWorkspaceForkSecretMapping, + present: ({ name }) => ({ name }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts b/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts new file mode 100644 index 00000000000..9e7a7dbfe10 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts @@ -0,0 +1,24 @@ +import { getWorkspaceSettingsNavigationContract } from '@/lib/api/contracts/workspace-settings' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + readWorkspaceSettingsNavigation, + readWorkspaceSettingsNavigationOperation, +} from '@/lib/settings/application/workspace-navigation' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceSettingsNavigationContract, + auth: internalSessionAuth, + operation: readWorkspaceSettingsNavigationOperation, + rateLimit: internalRateLimits.none({ + reason: 'Read-only settings navigation, matching existing settings-page admission', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: readWorkspaceSettingsNavigation, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/o/[organizationId]/components/create-project-modal/create-project-modal.tsx b/apps/sim/app/o/[organizationId]/components/create-project-modal/create-project-modal.tsx new file mode 100644 index 00000000000..03c687ffcbd --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/create-project-modal/create-project-modal.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' +import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal' +import { useCreateProject } from '@/hooks/queries/projects' +import { useWorkspaceCreationPolicy } from '@/hooks/queries/workspace' + +/** Whether the viewer may create a project in this organization, and why not. */ +export function useProjectCreationPolicy(organizationId: string) { + const { data: policy } = useWorkspaceCreationPolicy() + const canCreate = Boolean(policy?.canCreate && policy.organizationId === organizationId) + return { + canCreate, + disabledReason: canCreate ? null : (policy?.reason ?? 'Project creation is unavailable.'), + } +} + +interface CreateProjectModalProps { + organizationId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** Names a new project, creates it, and opens its pane on organization Home. */ +export function CreateProjectModal({ + organizationId, + open, + onOpenChange, +}: CreateProjectModalProps) { + const router = useRouter() + const { mutateAsync: createProject, isPending } = useCreateProject() + return ( + { + const { initialEnvironment } = await createProject({ organizationId, name }) + onOpenChange(false) + router.push( + `${organizationRoutes(organizationId).home}?project=${encodeURIComponent(initialEnvironment.id)}&pane=project` + ) + }} + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index d3077103b59..6cbfd0b7c08 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReactNode } from 'react' import { chipVariants, cn, @@ -28,6 +29,8 @@ import { interface ChatRowProps { chat: OrganizationChat + /** Shown before the title, such as the projects the chat worked in. */ + leading?: ReactNode isCurrentRoute: boolean isSelected: boolean isMenuOpen: boolean @@ -39,6 +42,7 @@ interface ChatRowProps { function ChatRow({ chat, + leading, isCurrentRoute, isSelected, isMenuOpen, @@ -66,6 +70,7 @@ function ChatRow({ onContextMenu={(e) => onContextMenu(e, chat.id)} onSelectChat={onSelectChat} > + {leading} ReactNode } export function ChatsSection({ @@ -111,6 +118,7 @@ export function ChatsSection({ isLoading, isCollapsed, pathname, + leadingFor, }: ChatsSectionProps) { const actions = useOrganizationChatActions({ organizationId, chats }) const { menu, hover, rename, selectedChat, selectedChats, menuOpenChatId } = actions @@ -140,7 +148,7 @@ export function ChatsSection({ 1 && selectedChats.has(chat.id)} onSelectChat={actions.handleChatClick} isMenuOpen={menuOpenChatId === chat.id} @@ -184,7 +192,8 @@ export function ChatsSection({ 1 && selectedChats.has(chat.id)} isMenuOpen={menuOpenChatId === chat.id} onSelectChat={actions.handleChatClick} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts index c7f48483337..d95672eb885 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts @@ -1,4 +1,5 @@ export { ChatsSection } from './chats-section' export { OrganizationFooter } from './organization-footer' export { OrganizationHeader } from './organization-header' +export { ProjectsSection } from './projects-section' export { WorkspacesSection } from './workspaces-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/index.ts new file mode 100644 index 00000000000..e62509df564 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/index.ts @@ -0,0 +1 @@ +export { ProjectsSection } from './projects-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row.tsx new file mode 100644 index 00000000000..4b21e00c25c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row.tsx @@ -0,0 +1,253 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import { + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + RowActions, + rowActionsGroupClass, +} from '@sim/emcn' +import { + Duplicate, + MoreHorizontal, + Pencil, + Pin, + PinOff, + SquareArrowUpRight, + Trash, +} from '@sim/emcn/icons' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { SettingsGuardedLink as Link } from '@/components/settings/settings-guarded-link' +import { useProjectActions } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions' +import type { ProjectDragProps } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' +import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import { + useItemDrag, + useItemRename, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { createSidebarDragGhost } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' +import { useContextMenu } from '@/hooks/use-context-menu' + +interface ProjectRowProps { + project: Project + href: string + active: boolean + drag: ProjectDragProps + isAnyDragActive: boolean +} + +/** + * A project in the organization sidebar with the workspace sidebar's row ergonomics: hover + * reveals the options menu, right-click opens the same menu, double-click + * or Rename edits the name inline, drag reorders, and the menu pins or deletes the project. + */ +export function ProjectRow({ project, href, active, drag, isAnyDragActive }: ProjectRowProps) { + const actions = useProjectActions() + const pinned = actions.isPinned(project) + const [deleteOpen, setDeleteOpen] = useState(false) + const dragGhostRef = useRef(null) + + const { + isOpen: menuOpen, + position, + menuRef, + handleContextMenu, + openMenuAt, + closeMenu, + } = useContextMenu() + + const rename = useItemRename({ + initialName: project.name, + onSave: (name) => actions.rename(project, name), + itemType: 'workspace', + itemId: project.id, + }) + + const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({ + onDragStart: (event) => { + event.dataTransfer.effectAllowed = 'move' + const ghost = createSidebarDragGhost(project.name) + void ghost.offsetHeight + event.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + drag.onDragStart() + }, + }) + + const onDragEnd = useCallback(() => { + dragGhostRef.current?.remove() + dragGhostRef.current = null + handleDragEnd() + drag.onDragEnd() + }, [drag, handleDragEnd]) + + const openMenuFromButton = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + openMenuAt({ x: rect.right, y: rect.bottom }) + } + + const openInNewTab = () => window.open(href, '_blank', 'noopener') + const copyLink = () => void navigator.clipboard.writeText(`${window.location.origin}${href}`) + + return ( + <> +
+ {drag.indicator && ( + + )} + {rename.isEditing ? ( +
+ + rename.setEditValue(event.target.value)} + onKeyDown={rename.handleKeyDown} + onBlur={rename.handleInputBlur} + disabled={rename.isRenaming} + onClick={(event) => event.stopPropagation()} + className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-hidden focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + /> +
+ ) : ( + { + if (shouldPreventClickRef.current) { + event.preventDefault() + return + } + }} + onDoubleClick={(event) => { + event.preventDefault() + if (project.canAdminister) rename.handleStartEdit() + }} + className='flex min-w-0 flex-1 items-center gap-2' + > + + + + )} + {!rename.isEditing && ( + + ) : undefined + } + > + + + + + )} +
+ + !open && closeMenu()} modal={false}> + +
+ + event.preventDefault()} + > + + + Open in new tab + + + + Copy link + + {project.canAdminister && ( + rename.handleStartEdit()}> + + Rename + + )} + actions.setPinned(project, !pinned)}> + {pinned ? : } + {pinned ? 'Unpin' : 'Pin'} + + {project.canAdminister && ( + <> + + setDeleteOpen(true)}> + + Delete + + + )} + + + + setDeleteOpen(false)} + onConfirm={() => { + void actions.remove(project).finally(() => setDeleteOpen(false)) + }} + isDeleting={actions.isDeleting} + itemType='workspace' + itemName={ + project.environments.length > 1 + ? `${project.name} (${project.environments.length} environments)` + : project.name + } + /> + + ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx new file mode 100644 index 00000000000..05b2937688c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState } from 'react' +import { Button, cn, DropdownMenuItem, OverflowText, Tooltip } from '@sim/emcn' +import { Box, Pin, Plus } from '@sim/emcn/icons' +import { useSearchParams } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { SettingsGuardedLink as Link } from '@/components/settings/settings-guarded-link' +import { organizationRoutes } from '@/lib/navigation/paths' +import { + CreateProjectModal, + useProjectCreationPolicy, +} from '@/app/o/[organizationId]/components/create-project-modal/create-project-modal' +import { ProjectRow } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row' +import { useProjectActions } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions' +import { useProjectOrder } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order' +import { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +interface ProjectsSectionProps { + organizationId: string + isCollapsed: boolean + pathname: string | null +} + +/** Projects select the pinned pane; chats remain independent organization conversations. */ +export function ProjectsSection({ organizationId, isCollapsed, pathname }: ProjectsSectionProps) { + const { roots } = useProjects(organizationId) + const { projects, dragProps, isAnyDragActive } = useProjectOrder(roots) + const [isCreateOpen, setIsCreateOpen] = useState(false) + const { canCreate, disabledReason } = useProjectCreationPolicy(organizationId) + const hover = useHoverMenu() + const actions = useProjectActions() + const projectLink = useProjectLink(organizationId, pathname) + + const openCreate = () => { + if (!canCreate) return + useSettingsDirtyStore.getState().requestLeave(() => { + hover.close() + setIsCreateOpen(true) + }) + } + + return ( + <> + + + + + {disabledReason ?? 'New project'} + + ) + } + > + {isCollapsed ? ( +
+ } + hover={hover} + ariaLabel='Projects' + primaryAction={canCreate ? { label: 'New project', onSelect: openCreate } : undefined} + > + {projects.length === 0 ? ( + No projects yet + ) : ( + projects.map((project) => { + const { href, active } = projectLink(project) + return ( + + + + + {actions.isPinned(project) && ( + + )} + + + ) + }) + )} + +
+ ) : ( +
+ {projects.map((project) => { + const { href, active } = projectLink(project) + return ( + + ) + })} +
+ )} +
+ + + ) +} + +/** A project link toggles the pinned pane on the current chat (or home), keeping other params. */ +function useProjectLink(organizationId: string, pathname: string | null) { + const routes = organizationRoutes(organizationId) + const searchParams = useSearchParams() + const [{ project: selectedWorkspace }] = useQueryStates(projectPaneParsers) + const chatPath = pathname?.startsWith(`/o/${organizationId}/chat/`) ? pathname : routes.home + return (project: Project) => { + const active = project.environments.some( + (environment) => environment.workspaceId === selectedWorkspace + ) + const query = new URLSearchParams(searchParams.toString()) + if (active) query.delete('project') + else query.set('project', project.id) + query.delete('section') + query.set('pane', 'project') + return { href: `${chatPath}?${query}`, active } + } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts new file mode 100644 index 00000000000..5070057e854 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts @@ -0,0 +1,47 @@ +'use client' + +import { useCallback } from 'react' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useArchiveProject, useRenameProject } from '@/hooks/queries/projects' +import { usePinnedWorkspaceIds, useToggleWorkspacePin } from '@/hooks/queries/workspace' + +/** + * Rename, pin and delete for a project: a rename changes the project's own name, a delete + * archives the project with every environment, and the pin lives on its root workspace. + */ +export function useProjectActions() { + const { data: pinnedIds } = usePinnedWorkspaceIds() + const { mutate: togglePin } = useToggleWorkspacePin() + const { mutateAsync: renameProject, isPending: isRenaming } = useRenameProject() + const { mutateAsync: archiveProject, isPending: isDeleting } = useArchiveProject() + + const isPinned = useCallback( + (project: Project) => Boolean(pinnedIds?.has(project.rootId)), + [pinnedIds] + ) + + const setPinned = useCallback( + (project: Project, pinned: boolean) => { + togglePin({ workspaceId: project.rootId, pinned }) + }, + [togglePin] + ) + + const rename = useCallback( + async (project: Project, name: string) => { + const next = name.trim() + if (!next || next === project.name) return + await renameProject({ projectId: project.projectId, name: next }) + }, + [renameProject] + ) + + const remove = useCallback( + async (project: Project) => { + await archiveProject({ projectId: project.projectId }) + }, + [archiveProject] + ) + + return { isPinned, setPinned, rename, remove, isRenaming, isDeleting } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order.ts new file mode 100644 index 00000000000..110b3657d4d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order.ts @@ -0,0 +1,89 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { usePinnedWorkspaceIds } from '@/hooks/queries/workspace' +import { orderProjectIds, useProjectOrderStore } from '@/stores/project-order/store' + +/** Where a dragged project would land relative to the row under the pointer. */ +export interface ProjectDropIndicator { + projectId: string + position: 'before' | 'after' +} + +/** Drag handlers a project row binds; `indicator` is set while its row is the drop target. */ +export interface ProjectDragProps { + isDragging: boolean + indicator: 'before' | 'after' | null + onDragStart: () => void + onDragOver: (event: React.DragEvent) => void + onDragLeave: () => void + onDrop: (event: React.DragEvent) => void + onDragEnd: () => void +} + +/** + * The sidebar's project list in the viewer's order: pinned projects first, then the manual + * order, then everything the order does not know yet. Also owns the drag-to-reorder state + * the rows bind; a drop moves the dragged project before or after the row it landed on. + */ +export function useProjectOrder(unordered: readonly Project[]) { + const { data: pinnedIds } = usePinnedWorkspaceIds() + const order = useProjectOrderStore((state) => state.order) + const moveProject = useProjectOrderStore((state) => state.moveProject) + const [draggingId, setDraggingId] = useState(null) + const [dropIndicator, setDropIndicator] = useState(null) + + const byId = new Map(unordered.map((project) => [project.id, project])) + const ordered = orderProjectIds( + unordered.map((project) => project.id), + order + ).flatMap((id) => { + const project = byId.get(id) + return project ? [project] : [] + }) + const pinned = ordered.filter((project) => pinnedIds?.has(project.rootId)) + const unpinned = ordered.filter((project) => !pinnedIds?.has(project.rootId)) + const projects = [...pinned, ...unpinned] + const visibleIds = projects.map((project) => project.id) + + const dragProps = useCallback( + (project: Project): ProjectDragProps => ({ + isDragging: draggingId === project.id, + indicator: dropIndicator?.projectId === project.id ? dropIndicator.position : null, + onDragStart: () => setDraggingId(project.id), + onDragOver: (event) => { + if (!draggingId || draggingId === project.id) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + const rect = event.currentTarget.getBoundingClientRect() + const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after' + setDropIndicator((current) => + current?.projectId === project.id && current.position === position + ? current + : { projectId: project.id, position } + ) + }, + onDragLeave: () => + setDropIndicator((current) => (current?.projectId === project.id ? null : current)), + onDrop: (event) => { + event.preventDefault() + if (!draggingId || draggingId === project.id) return + const rect = event.currentTarget.getBoundingClientRect() + const before = event.clientY < rect.top + rect.height / 2 + const index = visibleIds.indexOf(project.id) + const beforeId = before ? project.id : (visibleIds[index + 1] ?? null) + moveProject(visibleIds, draggingId, beforeId) + setDraggingId(null) + setDropIndicator(null) + }, + onDragEnd: () => { + setDraggingId(null) + setDropIndicator(null) + }, + }), + [draggingId, dropIndicator, moveProject, visibleIds] + ) + + return { projects, dragProps, isAnyDragActive: draggingId !== null } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts index 0bff17ebe9e..79aa1569afb 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts @@ -1,3 +1,6 @@ +import { useQueryStates } from 'nuqs' +import { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats' export interface OrganizationChat { @@ -13,11 +16,15 @@ export interface OrganizationChat { /** Lists only the current member's private organization conversations. */ export function useOrganizationChats(organizationId: string) { + const [{ project, section }] = useQueryStates(projectPaneParsers) + const enabled = useFeatureFlag('org-project-view') + const paneQuery = + enabled && project ? `?${new URLSearchParams({ project, section, pane: 'project' })}` : '' const query = useOrganizationMothershipChats(organizationId) const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({ id: chat.id, name: chat.name, - href: `/o/${organizationId}/chat/${chat.id}`, + href: `/o/${organizationId}/chat/${chat.id}${paneQuery}`, isActive: chat.isActive, isUnread: chat.isUnread, isPinned: chat.isPinned, diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts index 3a6cc1b5b0c..b8d0aa35070 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts @@ -1,4 +1,4 @@ -import { ChartColumn, Home, Integration, Search } from '@sim/emcn/icons' +import { ChartColumn, Home, Integration, Search, SquarePen } from '@sim/emcn/icons' import { organizationRoutes } from '@/lib/navigation/paths' import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' @@ -22,18 +22,29 @@ const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [ { id: 'benchmark', label: 'Benchmark', icon: ChartColumn, route: 'benchmark' }, ] +/** With the org project view, Home is where a new chat starts, so it reads as one. */ +const PROJECT_VIEW_HOME: Pick = { + label: 'New chat', + icon: SquarePen, +} + export function buildOrganizationNavItems( organizationId: string, searchAvailable: boolean, mothershipAvailable: boolean, - benchmarkEnabled = false + benchmarkEnabled = false, + projectViewEnabled = false ): SidebarNavItemData[] { const routes = organizationRoutes(organizationId) return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => { if (route === 'benchmark') return benchmarkEnabled - return route === 'home' ? mothershipAvailable : searchAvailable + if (route === 'home') return mothershipAvailable + /** With the org project view, Search is a mode of the Home composer, not a destination. */ + if (route === 'search') return searchAvailable && !projectViewEnabled + return searchAvailable }).map(({ route, ...entry }) => ({ ...entry, + ...(route === 'home' && projectViewEnabled ? PROJECT_VIEW_HOME : {}), href: routes[route], })) } diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index eb07b37568a..91472a2de11 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -14,6 +14,7 @@ import { ChatsSection, OrganizationFooter, OrganizationHeader, + ProjectsSection, WorkspacesSection, } from '@/app/o/[organizationId]/components/organization-sidebar/components' import { useOrganizationChats } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' @@ -21,6 +22,7 @@ import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/o import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar' import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { @@ -96,11 +98,13 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({ const isMac = isMacPlatform() const canUseHome = mothershipAvailable && (canBuild || searchAccess.memberScoped) + const projectViewEnabled = useFeatureFlag('org-project-view') const navItems = buildOrganizationNavItems( organization.id, searchAccess.memberScoped, canUseHome, - benchmarkEnabled + benchmarkEnabled, + projectViewEnabled ) const settingsPath = organizationRoutes(organization.id).settings const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) @@ -278,11 +282,19 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({ {...scrollFadeAttributes(scrollEdges)} >
- + {projectViewEnabled ? ( + + ) : ( + + )} {canUseHome && ( @@ -415,6 +416,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => { changelog: false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + 'org-project-view': false, }} > diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 25e1ef28eed..c91a447d824 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn' import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons' import { escapeRegExp } from '@sim/utils/string' @@ -26,6 +26,7 @@ import { import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options' import { useConversationModeShortcut } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-conversation-mode-shortcut' import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import { getSkillsQueryOptions } from '@/hooks/queries/skills' @@ -36,6 +37,8 @@ import { useVoiceInput } from '@/hooks/use-voice-input' import type { ChatContext } from '@/stores/panel' interface ComposerProps { + lookupMode?: boolean + onSearchMode?: () => void searchEnabled?: boolean requestMode?: ChatRequestMode onModeChange?: (mode: ChatRequestMode) => void @@ -58,6 +61,8 @@ interface ComposerProps { * carries only the controls that are wired for the organization. */ export function Composer({ + lookupMode = false, + onSearchMode, searchEnabled = true, requestMode = 'assistant', onModeChange, @@ -74,21 +79,29 @@ export function Composer({ }: ComposerProps) { const attachedFilesRef = useRef(files.attachedFiles) attachedFilesRef.current = files.attachedFiles - const imagesOnly = requestMode === 'assistant' + const imagesOnly = lookupMode || requestMode === 'assistant' + /** With the org project view, Search keeps the composer's framed box instead of its own bar. */ + const projectViewEnabled = useFeatureFlag('org-project-view') + const searchBar = imagesOnly && !projectViewEnabled const { organization } = useOrganizationContext() const { data: allWorkspaces = [] } = useWorkspacesQuery(!imagesOnly) const workspaces = (imagesOnly ? [] : allWorkspaces).filter( (workspace) => workspace.organizationId === organization.id ) + /** Every workspace's skills feed only the `/` menu, so they load once a `/` is typed. */ + const [skillsWanted, setSkillsWanted] = useState(false) + if (!skillsWanted && value.includes('/')) setSkillsWanted(true) const skillQueries = useQueries({ - queries: workspaces.map((workspace) => getSkillsQueryOptions(workspace.id)), + queries: (skillsWanted ? workspaces : []).map((workspace) => + getSkillsQueryOptions(workspace.id) + ), }) const skills = imagesOnly ? [] : organizationSkillOptions( workspaces.map((workspace, index) => ({ ...workspace, - skills: skillQueries[index].isPlaceholderData ? [] : (skillQueries[index].data ?? []), + skills: skillQueries[index]?.isPlaceholderData ? [] : (skillQueries[index]?.data ?? []), })) ) const editor = usePromptEditor({ @@ -98,7 +111,7 @@ export function Composer({ contextsEnabled: !imagesOnly, initialValue: value, initialContexts: restoredContexts, - onPasteFiles: files.processFiles, + onPasteFiles: lookupMode ? undefined : files.processFiles, }) const { textareaRef } = editor const searchBlocked = @@ -169,7 +182,13 @@ export function Composer({ isInitialView && !value, imagesOnly ? 'search' : 'build' ) - const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + const placeholder = lookupMode + ? 'Search your sources' + : projectViewEnabled && imagesOnly + ? 'Ask a question about your sources' + : isInitialView + ? animatedPlaceholder + : 'Send message to Sim' const submit = () => { if (attachedFilesRef.current.some((file) => file.uploading)) return @@ -184,7 +203,7 @@ export function Composer({ voice.resetTranscript() const contexts = imagesOnly ? [] : editor.getActiveContexts() onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined) - editor.clear() + if (!lookupMode) editor.clear() } const contextPicker = (kind: 'resources' | 'skills', icon: typeof Plus, label: string) => ( @@ -211,7 +230,7 @@ export function Composer({ ) + const modeSelector = showModeSelector && ( + { + if (editor.getActiveContexts().length || files.attachedFiles.length) { + toast.info('Remove mentions and attachments before switching to Search.') + return + } + onSearchMode() + } + : undefined + } + searchEnabled={searchEnabled} + onChange={ + onModeChange + ? (mode) => { + if ( + mode === 'assistant' && + (editor.getActiveContexts().length > 0 || + files.attachedFiles.some((file) => !isAssistantImageType(file.type))) + ) { + toast.info( + projectViewEnabled + ? 'Remove resource and skill mentions and non-image attachments before switching to Ask.' + : 'Remove resource and skill mentions and non-image attachments before switching to Search.' + ) + return + } + onModeChange(mode) + } + : undefined + } + /> + ) + const leadingControls = ( <> + {/** With the org project view the mode leads, then what the message carries. */} + {projectViewEnabled && modeSelector} {imagesOnly && !showModeSelector && ( )} @@ -241,13 +300,7 @@ export function Composer({ )} {!imagesOnly && contextPicker('skills', Slash, 'Skills')} - {showModeSelector && ( - - )} + {!projectViewEnabled && modeSelector} ) const voiceControl = voice.isSupported && ( @@ -266,7 +319,7 @@ export function Composer({ type='button' onClick={submit} disabled={!canSubmit} - aria-label='Send' + aria-label={lookupMode ? 'Search' : 'Send'} active={canSubmit} > @@ -284,22 +337,22 @@ export function Composer({ return (
- {!imagesOnly && attachmentList} - {!imagesOnly && promptEditor} + {!searchBar && attachmentList} + {!searchBar && promptEditor} - {imagesOnly ? ( + {searchBar ? ( ) : ( +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx new file mode 100644 index 00000000000..78876ab25c7 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx @@ -0,0 +1,282 @@ +'use client' + +import { useState } from 'react' +import { + ChipInput, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + scrollFadeAttributes, + scrollFadeClass, + Tooltip, + useScrollEdges, +} from '@sim/emcn' +import { Box, ChevronDown, Plus, Search } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { WORKSPACE_SEARCH_THRESHOLD } from '@/lib/workspaces/constants' +import { + CreateProjectModal, + useProjectCreationPolicy, +} from '@/app/o/[organizationId]/components/create-project-modal/create-project-modal' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { swellSlotRun } from '@/app/workspace/[workspaceId]/home/components/chat-panel-swell' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +const SLOT_HEIGHT = 24 +const TOP_RADIUS = 8 +const BOTTOM_RADIUS = 2.5 + +/** + * A slot diagonal rising `run` across the slot's height, with its corners' tangent points: `top` + * along the top edge and the diagonal, `bottom` along the bottom edge and the diagonal. + */ +function slotDiagonal(run: number) { + const length = Math.hypot(run, SLOT_HEIGHT) + const ux = run / length + const uy = SLOT_HEIGHT / length + const top = (TOP_RADIUS * (1 - ux)) / uy + const bottom = (BOTTOM_RADIUS * (1 + ux)) / uy + return { ux, uy, top, bottom } +} + +/** + * The name slot's silhouette at its rendered width: a leading diagonal parallel to the swell's + * side, and, alone in the swell, the mirrored trailing edge. + */ +function nameSlotClip(width: number, run: number, solo: boolean) { + const { ux, uy, top, bottom } = slotDiagonal(run) + const h = SLOT_HEIGHT + const leading = `M${run + top} 0A${TOP_RADIUS} ${TOP_RADIUS} 0 0 0 ${run - ux * top} ${uy * top}L${ux * bottom} ${h - uy * bottom}A${BOTTOM_RADIUS} ${BOTTOM_RADIUS} 0 0 0 ${bottom} ${h}` + const trailing = solo + ? `L${width - bottom} ${h}A${BOTTOM_RADIUS} ${BOTTOM_RADIUS} 0 0 0 ${width - ux * bottom} ${h - uy * bottom}L${width - run + ux * top} ${uy * top}A${TOP_RADIUS} ${TOP_RADIUS} 0 0 0 ${width - run - top} 0Z` + : `L${width - 4} ${h}A4 4 0 0 0 ${width} ${h - 4}L${width} 4A4 4 0 0 0 ${width - 4} 0Z` + return `path('${leading}${trailing}')` +} + +/** The switcher's silhouette: square on the name's side, the swell's trailing diagonal on its own. */ +function switchSlotClip(width: number, run: number) { + const { ux, uy, top, bottom } = slotDiagonal(run) + const h = SLOT_HEIGHT + return `path('M${width - run - top} 0A${TOP_RADIUS} ${TOP_RADIUS} 0 0 1 ${width - run + ux * top} ${uy * top}L${width - ux * bottom} ${h - uy * bottom}A${BOTTOM_RADIUS} ${BOTTOM_RADIUS} 0 0 1 ${width - bottom} ${h}L4 ${h}A4 4 0 0 1 0 ${h - 4}L0 4A4 4 0 0 1 4 0Z')` +} + +/** Re-clips both slots on every width change, so their diagonals follow the swell as it animates. */ +function clipSlots(control: HTMLDivElement | null) { + if (!control) return + const name = control.querySelector('[data-project-name]') + const switcher = control.querySelector('[data-project-switch]') + if (!name || !switcher) throw new Error('Project control is missing a slot') + const observer = new ResizeObserver(() => { + const run = swellSlotRun(control.getBoundingClientRect().width) + name.style.clipPath = nameSlotClip(name.offsetWidth, run, switcher.offsetWidth < 1) + switcher.style.clipPath = switchSlotClip(switcher.offsetWidth, run) + }) + for (const el of [control, name, switcher]) observer.observe(el) + return () => observer.disconnect() +} + +interface ProjectControlProps { + active: boolean + onShow: () => void +} + +/** Project navigation stays separate from the chat's resource tabs and ownership. */ +export function ProjectControl({ active, onShow }: ProjectControlProps) { + const { organization } = useOrganizationContext() + const { projects, roots } = useProjects(organization.id) + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) + const { canCreate, disabledReason } = useProjectCreationPolicy(organization.id) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [search, setSearch] = useState('') + const [listElement, setListElement] = useState(null) + const listEdges = useScrollEdges(listElement) + const project = projects.find((candidate) => candidate.id === params.project) + const name = project?.name ?? organization.name + const showSearch = roots.length >= WORKSPACE_SEARCH_THRESHOLD + const needle = search.trim().toLowerCase() + const visibleRoots = needle + ? roots.filter((candidate) => candidate.name.toLowerCase().includes(needle)) + : roots + const pick = (workspaceId: string) => + requestLeave(() => { + void setParams({ + project: workspaceId, + section: null, + resourceKind: '', + resourceDetail: '', + pane: 'project', + }) + onShow() + }) + return ( +
+ + { + setIsMenuOpen(open) + if (!open) setSearch('') + }} + > + + + + + {showSearch && ( + setSearch(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + /> + )} +
+ {visibleRoots.length === 0 && needle && ( +
+ No results for "{search}" +
+ )} + {visibleRoots.map((candidate) => { + const isCurrent = candidate.projectId === project?.projectId + return ( + pick(isCurrent && project ? project.id : candidate.id)} + > + + + + ) + })} +
+ + { + setIsMenuOpen(false) + requestLeave(() => setIsCreateOpen(true)) + }} + disabled={!canCreate} + /> +
+
+ +
+ ) +} + +interface NewProjectItemProps { + disabled: boolean + disabledReason: string | null + onSelect: () => void +} + +function NewProjectItem({ disabled, disabledReason, onSelect }: NewProjectItemProps) { + const item = ( + { + if (disabled) { + event.preventDefault() + return + } + onSelect() + }} + > + + New project + + ) + if (!disabledReason) return item + return ( + + +
{item}
+
+ +

{disabledReason}

+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx new file mode 100644 index 00000000000..d3b00c0c2da --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx @@ -0,0 +1,71 @@ +import { OverflowText, Skeleton } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { ProjectView } from '@/app/o/[organizationId]/p/components/project-view' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +/** The pinned tab shows the organization until a project environment is selected. */ +export function ProjectPaneContent() { + const { organization } = useOrganizationContext() + const { roots, projects, isPending, error } = useProjects(organization.id) + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) + const navigate = (next: Parameters[0]) => + requestLeave(() => { + void setParams(next) + }) + const project = projects.find((candidate) => candidate.id === params.project) + if (isPending) return + if (error) + return ( +

+ {error.message} +

+ ) + if (!project) + return ( +
+
+

{organization.name}

+
+

Projects

+ {roots.map((candidate) => ( + + ))} + {!roots.length && ( +

No projects yet.

+ )} +
+
+
+ ) + return ( + + navigate({ section: 'resources', resourceKind: '', resourceDetail: '', pane: 'project' }) + } + onSectionChange={(section) => + navigate({ section, resourceKind: '', resourceDetail: '', pane: 'project' }) + } + onEnvironmentChange={(workspaceId) => + navigate({ project: workspaceId, resourceDetail: '', pane: 'project' }) + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx new file mode 100644 index 00000000000..50a84557de9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx @@ -0,0 +1,416 @@ +import { type KeyboardEvent, type ReactNode, useRef, useState } from 'react' +import { + Chip, + ChipInput, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, + DropdownMenuTrigger, + OverflowText, + Skeleton, + toast, +} from '@sim/emcn' +import { ArrowLeft, ChevronDown, Globe, Loader, Search, TerminalWindow } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { RESOURCE_SEARCH_TYPES, type ResourceSearchItem } from '@/lib/api/contracts/resource-search' +import { openBrowserTab } from '@/lib/browser-agent/transport' +import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/fields' +import { terminalResourceId } from '@/lib/terminal/resource-id' +import { openTerminal } from '@/lib/terminal/transport' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { + type PaneTab, + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' +import { useOrganizationResourceSearch } from '@/hooks/queries/resource-search' +import { useDebounce } from '@/hooks/use-debounce' +import { useLoadMoreSentinel } from '@/hooks/use-load-more-sentinel' + +const EXCLUDED: readonly MothershipResourceType[] = [ + 'sources', + 'search', + 'folder', + 'filefolder', + 'task', + 'integration', + 'generic', + 'dashboard', +] +/** Kinds a permission group hides along with their sidebar tab. */ +const HIDDEN_BY: Partial> = { + file: 'hideFilesTab', + table: 'hideTablesTab', + knowledgebase: 'hideKnowledgeBaseTab', +} +interface ProjectResourceBrowserProps { + tab: Extract + resources: MothershipResource[] + desktopScopeId: string +} +interface BrowseEntry { + key: string + icon: ReactNode + label: string + detail?: string + onSelect: () => void +} +interface BrowseRowProps { + entry: BrowseEntry + index: number + highlighted: boolean + onHover: (index: number) => void +} +function BrowseRow({ entry, index, highlighted, onHover }: BrowseRowProps) { + return ( + + ) +} + +/** Browse live resources without creating a chat or changing its ownership. */ +export function ProjectResourceBrowser({ + tab, + resources, + desktopScopeId, +}: ProjectResourceBrowserProps) { + const { id, workspaceId, resourceKind: kind } = tab + const { organization } = useOrganizationContext() + const { projects, roots, isPending } = useProjects(organization.id) + const [, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const { addResource, selectResource } = useMothershipResources() + const [query, setQuery] = useState('') + const searchInputRef = useRef(null) + /** -1 until an arrow key or the pointer picks a row, so nothing is highlighted at rest. */ + const [highlight, setHighlight] = useState(-1) + const [listElement, setListElement] = useState(null) + const project = projects.find((candidate) => candidate.id === workspaceId) + const inventory = useAvailableResources(project?.id ?? '', { + enabled: Boolean(project), + excludeTypes: EXCLUDED, + }) + const { data: permissions } = useUserPermissionConfig(project?.id) + const permissionConfig = permissions?.config ?? DEFAULT_PERMISSION_GROUP_CONFIG + const isHidden = (type: MothershipResourceType) => { + const key = HIDDEN_BY[type] + return Boolean(key && permissionConfig[key]) + } + const needle = query.trim().toLowerCase() + const debouncedNeedle = useDebounce(query.trim(), SEARCH_DEBOUNCE_MS) + const browse = (workspace = '', type = '') => { + setHighlight(-1) + void setParams( + (current) => ({ + tabs: current.tabs.map((entry) => + entry.id === id + ? { kind: 'browse', id, workspaceId: workspace, resourceKind: type } + : entry + ), + }), + { history: 'replace' } + ) + } + /** Like a browser's new-tab page, opening a resource from here replaces this tab. */ + const replaceWith = (show: () => void) => { + show() + void setParams((current) => ({ tabs: current.tabs.filter((entry) => entry.id !== id) }), { + history: 'replace', + }) + } + const open = (resource: MothershipResource) => { + if (resource.type === 'browser') { + void openBrowserTab(desktopScopeId) + .then((state) => { + const tabId = state?.activeTabId + if (tabId) replaceWith(() => selectResource(tabId)) + }) + .catch(() => toast.error('Could not open a browser tab.')) + } else if (resource.type === 'terminal') { + void openTerminal(undefined, desktopScopeId) + .then((state) => { + const terminalId = state.activeTerminalId + if (terminalId) replaceWith(() => selectResource(terminalResourceId(terminalId))) + }) + .catch(() => toast.error('Could not open a terminal.')) + } else replaceWith(() => addResource(resource)) + } + const resourceEntry = (resource: MothershipResource, detail?: string): BrowseEntry => ({ + key: getChatResourceSelectionId(resource), + icon: getResourceConfig(resource.type).renderTabIcon( + resource, + 'size-[14px] shrink-0 text-[var(--text-icon)]', + desktopScopeId + ), + label: resource.title, + detail, + onSelect: () => open(resource), + }) + const mentioned = resources.filter( + (resource) => !EXCLUDED.includes(resource.type) && !isHidden(resource.type) + ) + const groups = inventory.groups.filter( + (group) => !EXCLUDED.includes(group.type) && !isHidden(group.type) + ) + const searchableKind = RESOURCE_SEARCH_TYPES.find((type) => type === kind) + const environmentOptions = (project?.environments ?? []).map((environment) => ({ + value: environment.workspaceId, + label: environment.label, + })) + /** + * Search spans the project's environments; types the server cannot search by name stay scoped + * to the environment being browsed. + */ + const searchesServer = Boolean(project) && Boolean(needle) && (!kind || Boolean(searchableKind)) + const resourceSearch = useOrganizationResourceSearch( + organization.id, + { + query: debouncedNeedle, + types: searchableKind ? [searchableKind] : [], + workspaceIds: environmentOptions.map((option) => option.value), + }, + { enabled: searchesServer } + ) + const searchItems = resourceSearch.data?.pages.flatMap((page) => page.items) ?? [] + const loadMoreRef = useLoadMoreSentinel({ + onLoadMore: () => void resourceSearch.fetchNextPage(), + enabled: searchesServer && resourceSearch.hasNextPage && !resourceSearch.isFetchingNextPage, + }) + /** Results for the typed text have not arrived: the debounce is pending or the request in flight. */ + const isSearching = + searchesServer && + (debouncedNeedle !== query.trim() || + resourceSearch.isPending || + resourceSearch.isPlaceholderData) + /** The environment being browsed ranks first. */ + const searchRank = (workspaceId: string) => (workspaceId === project?.id ? 0 : 1) + const environmentLabel = (item: ResourceSearchItem) => + environmentOptions.find((option) => option.value === item.workspaceId)?.label ?? + item.workspaceName + const scopedMatches = project + ? groups.flatMap((group) => + !kind || group.type === kind + ? group.items + .filter((item) => !needle || item.name.toLowerCase().includes(needle)) + .map((item) => ({ + resource: resourceFromItem(group.type, { ...item, workspaceId: project.id }), + detail: project.environment, + })) + : [] + ) + : [] + const matches = searchesServer + ? [...searchItems] + .sort((a, b) => searchRank(a.workspaceId) - searchRank(b.workspaceId)) + .map((item) => ({ + resource: resourceFromItem(item.type, item), + detail: environmentLabel(item), + })) + : scopedMatches + const isLoading = isPending || Boolean(project && inventory.isHydrating) + const IntegrationIcon = getResourceConfig('integration').icon + const primary: BrowseEntry[] = isLoading + ? [] + : !project + ? roots.map((candidate) => ({ + key: candidate.projectId, + icon: , + label: candidate.name, + onSelect: () => browse(candidate.id), + })) + : needle || kind + ? matches.map(({ resource, detail }) => resourceEntry(resource, detail)) + : [ + ...(permissionConfig.hideIntegrationsTab + ? [] + : [ + { + key: 'integration', + icon: , + label: getResourceConfig('integration').label, + onSelect: () => + replaceWith(() => + setParams({ + project: project.id, + section: 'resources', + resourceKind: 'integrations', + resourceDetail: '', + pane: 'project', + }) + ), + }, + ]), + ...groups.map((group) => { + const config = getResourceConfig(group.type) + const Icon = + group.type === 'browser' + ? Globe + : group.type === 'terminal' + ? TerminalWindow + : config.icon + return { + key: group.type, + icon: , + label: config.label, + onSelect: () => + group.type === 'browser' || group.type === 'terminal' + ? open({ type: group.type, id: group.type, title: config.label }) + : browse(project.id, group.type), + } + }), + ] + const secondary = kind ? [] : mentioned.map((resource) => resourceEntry(resource)) + const entries = [...primary, ...secondary] + const active = highlight < entries.length ? highlight : -1 + const moveHighlight = (next: number) => { + setHighlight(next) + listElement + ?.querySelector(`[data-browse-index="${next}"]`) + ?.scrollIntoView({ block: 'nearest' }) + } + const onSearchKeyDown = (event: KeyboardEvent) => { + if (!entries.length) return + if (event.key === 'ArrowDown') { + event.preventDefault() + moveHighlight((active + 1) % entries.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + moveHighlight(active <= 0 ? entries.length - 1 : active - 1) + } else if (event.key === 'Enter') { + event.preventDefault() + entries[active]?.onSelect() + } + } + const renderRow = (entry: BrowseEntry, index: number) => ( + + ) + return ( +
+
+
+ {kind && ( +
+ browse(workspaceId)}> + Resources + +
+ )} + + + + + + {roots.map((root) => ( + browse(root.id, kind)} + > + + + + ))} + + + {project && ( + { + setQuery(event.target.value) + setHighlight(-1) + }} + onKeyDown={onSearchKeyDown} + autoFocus + className='mb-2 w-full' + /> + )} + {isLoading ? ( + + ) : ( + <> + {primary.map(renderRow)} + {searchesServer && resourceSearch.hasNextPage && ( +
+ {secondary.length > 0 && ( +
+

Mentioned in this chat

+ {secondary.map((entry, index) => renderRow(entry, primary.length + index))} +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts b/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts new file mode 100644 index 00000000000..9d7598dce4c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts @@ -0,0 +1,60 @@ +import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +export const SYNC_STEPS = ['mappings', 'copy', 'review'] as const +export type SyncStep = (typeof SYNC_STEPS)[number] + +const SYNC_DIRECTIONS = ['push', 'pull'] as const + +/** + * A pane tab that exists only in the URL. It is never a chat resource, so it is never + * mentioned, persisted with the chat, or sent to the agent. + */ +export type PaneTab = + | { kind: 'browse'; id: string; workspaceId: string; resourceKind: string } + | { + kind: 'sync' + id: string + /** The fork; its parent is the other side of the sync. */ + childId: string + direction: (typeof SYNC_DIRECTIONS)[number] + step: SyncStep + } + +function serializePaneTab(tab: PaneTab): string { + return tab.kind === 'browse' + ? `browse:${tab.id}:${tab.workspaceId}:${tab.resourceKind}` + : `sync:${tab.id}:${tab.childId}:${tab.direction}:${tab.step}` +} + +const paneTabParser = createParser({ + parse: (value) => { + const [kind, id, first = '', second = '', third = ''] = value.split(':') + if (!id) return null + if (kind === 'browse') return { kind, id, workspaceId: first, resourceKind: second } + if (kind !== 'sync' || !first) return null + const direction = SYNC_DIRECTIONS.find((candidate) => candidate === second) + const step = SYNC_STEPS.find((candidate) => candidate === third) + return direction && step ? { kind, id, childId: first, direction, step } : null + }, + serialize: serializePaneTab, + eq: (a, b) => serializePaneTab(a) === serializePaneTab(b), +}) + +/** Project names are presentation; workspace ids address the selected environment. */ +export const projectPaneParsers = { + project: parseAsString.withDefault(''), + section: parseAsStringLiteral([ + 'dashboard', + 'changelog', + 'issues', + 'environments', + 'settings', + 'resources', + ] as const).withDefault('dashboard'), + resourceKind: parseAsString.withDefault(''), + resourceDetail: parseAsString.withDefault(''), + pane: parseAsString.withDefault(''), + tabs: parseAsArrayOf(paneTabParser).withDefault([]), +} + +export const projectPaneOptions = { history: 'push', clearOnDefault: true } as const diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts new file mode 100644 index 00000000000..44724e3b346 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import type { MothershipResource } from '@/lib/mothership/resources/types' + +const RESTORABLE_TYPES = new Set([ + 'workflow', + 'table', + 'knowledgebase', + 'file', + 'filefolder', + 'log', +]) + +/** Store addresses only: previews, source content and desktop sessions have separate lifecycles. */ +function describeResource(resource: MothershipResource): MothershipResource | null { + if (!RESTORABLE_TYPES.has(resource.type) || !resource.workspaceId) return null + return { + type: resource.type, + id: resource.id, + title: resource.title, + workspaceId: resource.workspaceId, + path: resource.path, + viewId: resource.viewId, + executionId: resource.executionId, + } +} + +function isResourceDescriptor(value: unknown): value is MothershipResource { + if (!value || typeof value !== 'object') return false + const entry = value as Record + return ( + typeof entry.type === 'string' && + RESTORABLE_TYPES.has(entry.type) && + typeof entry.id === 'string' && + entry.id.length > 0 && + typeof entry.title === 'string' && + typeof entry.workspaceId === 'string' && + entry.workspaceId.length > 0 && + ['path', 'viewId', 'executionId'].every( + (key) => entry[key] === undefined || typeof entry[key] === 'string' + ) + ) +} + +interface UseDraftResourceTabsProps { + storageKey: string + enabled: boolean + hasChat: boolean + resources: MothershipResource[] + addResource: (resource: MothershipResource) => boolean +} + +/** Restore the current browser session's pre-chat tabs; saved chats own their own resources. */ +export function useDraftResourceTabs({ + storageKey, + enabled, + hasChat, + resources, + addResource, +}: UseDraftResourceTabsProps) { + const [restored, setRestored] = useState(false) + useEffect(() => { + if (!enabled || hasChat || restored) return + try { + const raw = sessionStorage.getItem(storageKey) + const parsed: unknown = raw ? JSON.parse(raw) : [] + if (Array.isArray(parsed)) { + for (const entry of parsed.slice(0, 100)) { + if (isResourceDescriptor(entry)) { + const descriptor = describeResource(entry) + if (descriptor) addResource(descriptor) + } + } + } + } catch { + /** Unavailable or malformed browser storage must not prevent opening Home. */ + } + setRestored(true) + }, [storageKey, enabled, hasChat, restored, addResource]) + + useEffect(() => { + if (!enabled || !restored) return + try { + if (hasChat) sessionStorage.removeItem(storageKey) + else + sessionStorage.setItem( + storageKey, + JSON.stringify(resources.map(describeResource).filter(Boolean)) + ) + } catch { + /** Storage quota and private-browser restrictions leave tabs in memory. */ + } + }, [storageKey, enabled, hasChat, resources, restored]) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/use-open-sync-tab.ts b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-open-sync-tab.ts new file mode 100644 index 00000000000..fa2a0716f47 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-open-sync-tab.ts @@ -0,0 +1,24 @@ +import { generateShortId } from '@sim/utils/id' +import { useQueryStates } from 'nuqs' +import { + type PaneTab, + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' + +/** Focuses the pane tab syncing this fork in this direction, opening one if there is none. */ +export function useOpenSyncTab() { + const [, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + return (childId: string, direction: Extract['direction']) => + void setParams((current) => { + const existing = current.tabs.find( + (tab) => tab.kind === 'sync' && tab.childId === childId && tab.direction === direction + ) + if (existing) return { pane: existing.id } + const id = generateShortId(8) + return { + pane: id, + tabs: [...current.tabs, { kind: 'sync', id, childId, direction, step: 'mappings' }], + } + }) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx new file mode 100644 index 00000000000..ae2b063c3fc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx @@ -0,0 +1,179 @@ +import { useEffect } from 'react' +import type { TabStripItem } from '@sim/emcn' +import { ArrowLeftRight } from '@sim/emcn/icons' +import { generateShortId } from '@sim/utils/id' +import { useRouter } from 'next/navigation' +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { createLoader } from 'nuqs/server' +import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { ProjectControl } from '@/app/o/[organizationId]/home/components/project-pane/project-control' +import { ProjectPaneContent } from '@/app/o/[organizationId]/home/components/project-pane/project-pane-content' +import { ProjectResourceBrowser } from '@/app/o/[organizationId]/home/components/project-pane/project-resource-browser' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { EnvironmentSync } from '@/app/o/[organizationId]/p/components/environments/environment-sync' +import type { ResourcePanelNavigation } from '@/app/workspace/[workspaceId]/home/components/resource-panel-navigation' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' +import { + logFilterParsers, + logFilterUrlKeys, +} from '@/app/workspace/[workspaceId]/logs/search-params' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +const RESOURCE_SECTIONS: Partial> = { + workflow: 'workflows', + table: 'tables', + knowledgebase: 'knowledge', + file: 'files', + log: 'logs', + test: 'tests', +} + +const loadLogFilters = createLoader(logFilterParsers) + +/** Workspace route segments the project's Resources section can show. */ +const PATH_SECTIONS: Record = { + w: 'workflows', + tables: 'tables', + knowledge: 'knowledge', + files: 'files', + logs: 'logs', + tests: 'tests', + integrations: 'integrations', +} + +/** UI tabs never enter the chat's persisted resource list or execution context. */ +export function useProjectPane( + resources: MothershipResource[], + activeResourceId: string | null, + desktopScopeId: string, + onReveal: () => void, + enabled: boolean +) { + const router = useRouter() + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const [, setLogFilters] = useQueryStates(logFilterParsers, logFilterUrlKeys) + const [, setListLocation] = useQueryStates({ folderId: parseAsString, search: parseAsString }) + const [, setConnect] = useQueryState(connectParam.key, connectParam.parser) + const { data: workspaces } = useWorkspacesQuery(enabled) + const activeTab = params.tabs.find((tab) => tab.id === params.pane) + const dashboard = resources.find( + (resource) => + resource.type === 'dashboard' && getChatResourceSelectionId(resource) === activeResourceId + ) + useEffect(() => { + if (enabled && dashboard?.workspaceId && !params.pane) { + void setParams( + { project: dashboard.workspaceId, section: 'dashboard', pane: 'project' }, + { history: 'replace' } + ) + onReveal() + } + }, [enabled, dashboard?.workspaceId, params.pane, setParams, onReveal]) + const hasActiveResource = resources.some( + (resource) => getChatResourceSelectionId(resource) === activeResourceId + ) + const showProject = params.pane === 'project' || (!activeTab && !hasActiveResource) + const browse = (workspaceId = params.project, kind = '') => { + const id = generateShortId(8) + void setParams({ + pane: id, + tabs: [...params.tabs, { kind: 'browse', id, workspaceId, resourceKind: kind }], + }) + onReveal() + } + const select = (id: string) => { + void setParams({ pane: id }) + onReveal() + } + const close = (id: string) => { + const index = params.tabs.findIndex((tab) => tab.id === id) + const remaining = params.tabs.filter((tab) => tab.id !== id) + const neighbor = remaining[Math.max(0, index - 1)]?.id + void setParams( + { + tabs: remaining, + ...(params.pane === id ? { pane: neighbor ?? (hasActiveResource ? null : 'project') } : {}), + }, + { history: 'replace' } + ) + } + const nameOf = new Map(workspaces?.map((workspace) => [workspace.id, workspace.name])) + const parentOf = new Map( + workspaces?.map((workspace) => [workspace.id, workspace.forkedFromWorkspaceId ?? '']) + ) + const tabs: TabStripItem[] = params.tabs.map((tab) => { + if (tab.kind === 'browse') return { id: tab.id, title: 'New tab', active: tab === activeTab } + const child = nameOf.get(tab.childId) ?? 'Fork' + const parent = nameOf.get(parentOf.get(tab.childId) ?? '') ?? 'Parent' + return { + id: tab.id, + title: tab.direction === 'push' ? `${child} → ${parent}` : `${parent} → ${child}`, + icon: , + active: tab === activeTab, + } + }) + const navigation: ResourcePanelNavigation = { + control: select('project')} />, + controlActive: showProject, + tabs, + active: showProject || Boolean(activeTab), + onSelect: select, + onClose: close, + onNew: () => browse(), + onBrowseResource: (resource) => { + if (resource.type === 'dashboard' && resource.workspaceId) { + void setParams({ project: resource.workspaceId, section: 'dashboard', pane: 'project' }) + onReveal() + return + } + const kind = RESOURCE_SECTIONS[resource.type] + if (!kind || !resource.workspaceId) return + void setParams({ + project: resource.workspaceId, + section: 'resources', + resourceKind: kind, + resourceDetail: '', + pane: 'project', + }) + onReveal() + }, + navigate: (href) => { + const url = new URL(href, window.location.origin) + const [, scope, workspaceId, path = '', ...rest] = url.pathname.split('/') + const kind = PATH_SECTIONS[path] + if (url.origin !== window.location.origin || scope !== 'workspace' || !kind) { + router.push(href) + return + } + void setParams({ + project: workspaceId, + section: 'resources', + resourceKind: kind, + resourceDetail: rest.join('/'), + pane: 'project', + }) + if (kind === 'logs') void setLogFilters(loadLogFilters(url.searchParams)) + else void setListLocation({ folderId: url.searchParams.get('folderId'), search: null }) + if (kind === 'integrations') void setConnect(url.searchParams.get(connectParam.key)) + onReveal() + }, + content: + activeTab?.kind === 'sync' ? ( + + ) : activeTab ? ( + + ) : showProject ? ( + + ) : null, + } + return { navigation } +} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index aa3b1c5a65a..30fc610cff0 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -14,7 +14,11 @@ import { organizationProviderMock, organizationProviderMockFns, } from '@sim/testing/mocks/organization-provider.mock' -import { reactQueryMock } from '@sim/testing/mocks/react-query.mock' +import { + createQueryResultMock, + reactQueryMock, + reactQueryMockFns, +} from '@sim/testing/mocks/react-query.mock' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -72,7 +76,11 @@ vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ getMothershipUseChatOptions: (options: object) => ({ ...options, mothership: true }), useChat: mocks.chat, })) +vi.mock('@/hooks/queries/projects', () => ({ + useOrganizationProjects: () => ({ data: [], isPending: false }), +})) vi.mock('@/hooks/queries/mothership-chats', () => ({ + useOrganizationMothershipChats: () => ({ data: [] }), useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }), })) vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer })) @@ -102,6 +110,7 @@ deploymentShapeMockFns.mockGetDeploymentShape.mockImplementation(deploymentShape let root: Root let container: HTMLDivElement beforeEach(() => { + reactQueryMockFns.mockUseQuery.mockReturnValue(createQueryResultMock()) useMothershipDraftsStore.setState({ drafts: {} }) mocks.plan = false mocks.activeResource = null diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 305ec3db460..9129313573e 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,9 +1,10 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useState } from 'react' import { toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { requestJson } from '@/lib/api/client/request' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' @@ -14,13 +15,21 @@ import { getMothershipAttachmentPreviewUrl, getMothershipAttachmentUrl, } from '@/lib/mothership/chat/attachment-preview' +import { + MOTHERSHIP_SEND_MESSAGE_EVENT, + type MothershipSendMessageDetail, +} from '@/lib/mothership/events' import { createSearchResource } from '@/lib/mothership/resources/search' import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing' import { Composer } from '@/app/o/[organizationId]/home/components/composer' import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started' +import { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useDraftResourceTabs } from '@/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs' +import { useProjectPane } from '@/app/o/[organizationId]/home/components/project-pane/use-project-pane' import { organizationHomeParsers } from '@/app/o/[organizationId]/home/search-params' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchUrlKeys } from '@/app/o/[organizationId]/search/search-params' +import { SearchResultsView } from '@/app/o/[organizationId]/search/search-results-view' import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel' import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context' import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' @@ -53,10 +62,26 @@ import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' import type { ChatContext } from '@/stores/panel' +/** What a host surface adds to the empty Home for one mode. */ +interface OrganizationHomeLanding { + heading?: string + /** Rendered above the composer, such as a picker that scopes the next message. */ + accessory?: ReactNode +} + interface OrganizationHomeProps { userName?: string chatId?: string requestMode?: ChatRequestMode + /** Lets a host surface change the empty Home's heading and add a control, per mode. */ + landing?: (requestMode: ChatRequestMode) => OrganizationHomeLanding | undefined + /** + * Called with the first message of a new chat before it is sent here; returning true means + * the host sent it elsewhere, so this Home clears the draft and sends nothing. + */ + onBeforeSend?: (message: string, requestMode: ChatRequestMode) => boolean + /** False keeps the address bar where it is when the chat gets its id. */ + syncChatUrl?: boolean } /** Home chooses the conversation mode before its first message. */ @@ -80,6 +105,9 @@ function OrganizationHomeContent({ userName, chatId, requestMode: savedMode, + landing, + onBeforeSend, + syncChatUrl, }: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() @@ -87,15 +115,32 @@ function OrganizationHomeContent({ const rememberedMode = useOrganizationChatModeStore( (state) => state.modes[`${userId}:${organization.id}`] ) - const [{ q, source, updated, from, to, searchLevel: urlSearchLevel }, setSearchParams] = - useQueryStates(organizationHomeParsers, organizationSearchUrlKeys) + const [ + { q, source, updated, from, to, mode: composerMode, searchLevel: urlSearchLevel }, + setSearchParams, + ] = useQueryStates(organizationHomeParsers, organizationSearchUrlKeys) const rememberMode = useOrganizationChatModeStore((state) => state.setMode) const [selectedMode, setSelectedMode] = useState(null) const planEnabled = useFeatureFlag('mothership-plan-mode') + const projectViewEnabled = useFeatureFlag('org-project-view') + const lookupMode = + projectViewEnabled && !chatId && composerMode === 'search' && searchAccess.memberScoped + const [, setPaneParams] = useQueryStates(projectPaneParsers) + const onResourceSelected = useCallback(() => { + if (!projectViewEnabled) return + void setPaneParams({ pane: null }) + }, [projectViewEnabled, setPaneParams]) const requestMode = selectedMode ?? (urlSearchLevel && searchAccess.memberScoped && !chatId ? 'assistant' : undefined) ?? savedMode ?? + (projectViewEnabled && + !chatId && + (composerMode === 'agent' || + (composerMode === 'plan' && planEnabled) || + (composerMode === 'assistant' && searchAccess.memberScoped)) + ? composerMode + : undefined) ?? (!canBuild || !mothershipAvailable ? 'assistant' : rememberedMode === 'plan' && planEnabled @@ -103,7 +148,10 @@ function OrganizationHomeContent({ : rememberedMode === 'assistant' && searchAccess.memberScoped ? 'assistant' : 'agent') - const controller = useResourcePanelController() + const controller = useResourcePanelController({ + defaultExpanded: projectViewEnabled, + onResourceSelected, + }) const queryClient = useQueryClient() const chat = useChat( { organizationId: organization.id }, @@ -112,14 +160,23 @@ function OrganizationHomeContent({ requestMode, onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, + ...(syncChatUrl === false ? { syncChatUrl } : {}), }) ) + useDraftResourceTabs({ + storageKey: `org-draft-tabs:${userId}:${organization.id}`, + enabled: projectViewEnabled, + hasChat: Boolean(chatId || chat.resolvedChatId), + resources: chat.resources, + addResource: chat.addResource, + }) const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}` const savedDraft = useMothershipDraftsStore.getState().drafts const initialDraft = savedDraft[draftKey] ?? savedDraft[initialDraftKey] const draft = useMothershipDraftsStore( - (state) => (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? '' + (state) => + (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? (lookupMode ? q : '') ) const setDraft = useCallback( (text: string, contexts?: ChatContext[]) => { @@ -138,11 +195,21 @@ function OrganizationHomeContent({ const canSelectMode = !hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled) const assistantSearchLevel = 'fast' - const panel = useChatResourcePanel(chat, controller, userId) + const panel = useChatResourcePanel(chat, controller, { + userId, + keepEmptyPanel: projectViewEnabled, + }) + const projectPane = useProjectPane( + chat.resources, + chat.activeResourceId, + chat.desktopScopeId, + panel.expandResource, + projectViewEnabled + ) const addResource = panel.addResourceFromUser /** Restore only an explicitly selected results tab on an empty Home; closing it clears the URL. */ useEffect(() => { - if (hasChat || !q.trim()) return + if (lookupMode || hasChat || !q.trim()) return const resource = createSearchResource({ scope: { kind: 'organization', organizationId: organization.id }, query: q.trim(), @@ -155,6 +222,7 @@ function OrganizationHomeContent({ return addResource(resource) }, [ + lookupMode, hasChat, q, source, @@ -224,6 +292,57 @@ function OrganizationHomeContent({ }, [chat.error]) const { recordQuery } = useSearchHistoryActions() const { sendMessage } = chat + const router = useRouter() + useEffect(() => { + // Off the project view, sends from other surfaces keep the workspace home handoff. + if (!projectViewEnabled) return + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (event.defaultPrevented || !detail?.message) return + const mode = detail.requestMode ?? requestMode + if (!mothershipAvailable || (mode !== 'assistant' && !canBuild)) return + event.preventDefault() + if (hasChat && mode !== requestMode) { + if ( + !MothershipHandoffStorage.store( + { ...detail, requestMode: mode }, + { organizationId: organization.id } + ) + ) { + toast.error('Could not start dashboard setup in a new chat.') + return + } + const params = new URLSearchParams(window.location.search) + params.set('mode', mode) + params.delete('q') + params.delete('searchLevel') + router.push(`/o/${organization.id}/home?${params}`) + return + } + setSelectedMode(mode) + void setSearchParams({ mode, searchLevel: null, q: null }) + void sendMessage(detail.message, detail.fileAttachments, detail.contexts, { + requestMode: mode, + ...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}), + ...(detail.assistantSearch ? { assistantSearch: detail.assistantSearch } : {}), + ...(detail.assistantSearchLevel !== undefined + ? { assistantSearchLevel: detail.assistantSearchLevel } + : {}), + }) + } + window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) + return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) + }, [ + projectViewEnabled, + requestMode, + hasChat, + canBuild, + mothershipAvailable, + organization.id, + router, + sendMessage, + setSearchParams, + ]) const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id }) const firstName = userName?.split(' ')[0] ?? '' @@ -273,14 +392,24 @@ function OrganizationHomeContent({ }) } const changeMode = (mode: ChatRequestMode) => { - if (!canSelectMode || mode === requestMode) return + if (!canSelectMode || (mode === requestMode && !lookupMode)) return setSelectedMode(mode) - void setSearchParams({ searchLevel: null }) + // Only the project view puts the mode in the URL; leaving lookup drops its query with it. + void setSearchParams( + projectViewEnabled ? { searchLevel: null, mode, q: null } : { searchLevel: null } + ) if (userId) rememberMode(userId, organization.id, mode) } const submit = (text: string, contexts?: ChatContext[]) => { const message = text.trim() + if (lookupMode) { + if (!message) return + recordQuery(message) + setDraft(message) + void setSearchParams({ q: message }) + return + } if (files.attachedFiles.some((file) => file.uploading)) return const attachments: FileAttachmentForApi[] = files.attachedFiles .filter((file) => file.key) @@ -293,6 +422,11 @@ function OrganizationHomeContent({ path: file.path, })) if (!message && !attachments.length) return + if (!hasChat && message && onBeforeSend?.(message, requestMode)) { + useMothershipDraftsStore.getState().clearDraft(draftKey) + files.clearAttachedFiles() + return + } useMothershipDraftsStore.getState().clearDraft(draftKey) send(message, attachments.length ? attachments : undefined, contexts) setRestoredContexts([]) @@ -305,13 +439,19 @@ function OrganizationHomeContent({ Build requires permission to create workspaces.{' '} {searchAccess.memberScoped && ( - Start a Search chat + Ask about your sources )}
) : ( void setSearchParams({ mode: 'search', searchLevel: null }) + : undefined + } searchEnabled={searchAccess.memberScoped} showModeSelector={canSelectMode} onModeChange={canSelectMode ? changeMode : undefined} @@ -331,91 +471,116 @@ function OrganizationHomeContent({ /> ) + const hostLanding = hasChat ? undefined : landing?.(requestMode) const content = (
- {hasChat ? ( - - addResource({ - type: 'sources', - id: 'cited-sources', - title: 'Sources', - sources: { messageId, ...(requestId ? { requestId } : {}) }, + {lookupMode ? ( + { + setSelectedMode('assistant') + void setSearchParams({ mode: 'assistant', q: null }) + void sendMessage(message, undefined, undefined, { + requestMode: 'assistant', + assistantSearchLevel, + assistantSearch: filters, }) - } - SearchConnectionComponent={SearchIntegrationConnection} - messages={chat.messages} - isSending={chat.isSending} - isReconnecting={chat.isReconnecting} - isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending} - onSubmit={send} - onStopGeneration={() => { - void chat.stopGeneration() - }} - messageQueue={chat.messageQueue} - editingQueuedId={chat.editingQueuedId} - dispatchingHeadId={chat.dispatchingHeadId} - onRemoveQueuedMessage={chat.removeFromQueue} - onSendQueuedMessage={chat.sendNow} - onEditQueuedMessage={(id) => { - const queued = chat.editQueuedMessage(id) - if (queued) { - const queuedMode = queued.requestMode ?? requestMode - setSelectedMode(queuedMode) - setDraft(queued.content, queued.contexts) - setRestoredContexts(queued.contexts ?? []) - files.restoreAttachedFiles( - (queued.fileAttachments ?? []).map((file) => ({ - id: file.id, - key: file.key, - name: file.filename, - type: file.media_type, - size: file.size, - path: - file.path || - getMothershipAttachmentPreviewUrl(file) || - getMothershipAttachmentUrl(file), - previewUrl: getMothershipAttachmentPreviewUrl(file), - uploading: false, - })) - ) - } - return queued }} - onCancelQueueEdit={chat.cancelQueueEdit} - userId={session?.user?.id} - chatId={chat.resolvedChatId} - composer={composer} - onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined} - initialScrollBlocked={panel.initialScrollBlocked} /> ) : ( - - {composer} -
- {requestMode === 'agent' ? ( - setDraft(mentionifyIntegrations(prompt))} - /> - ) : searchAccess.memberScoped ? ( - - ) : null} -
-
+ <> + {hasChat ? ( + + addResource({ + type: 'sources', + id: 'cited-sources', + title: 'Sources', + sources: { messageId, ...(requestId ? { requestId } : {}) }, + }) + } + SearchConnectionComponent={SearchIntegrationConnection} + messages={chat.messages} + isSending={chat.isSending} + isReconnecting={chat.isReconnecting} + isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending} + onSubmit={send} + onStopGeneration={() => { + void chat.stopGeneration() + }} + messageQueue={chat.messageQueue} + editingQueuedId={chat.editingQueuedId} + dispatchingHeadId={chat.dispatchingHeadId} + onRemoveQueuedMessage={chat.removeFromQueue} + onSendQueuedMessage={chat.sendNow} + onEditQueuedMessage={(id) => { + const queued = chat.editQueuedMessage(id) + if (queued) { + const queuedMode = queued.requestMode ?? requestMode + setSelectedMode(queuedMode) + setDraft(queued.content, queued.contexts) + setRestoredContexts(queued.contexts ?? []) + files.restoreAttachedFiles( + (queued.fileAttachments ?? []).map((file) => ({ + id: file.id, + key: file.key, + name: file.filename, + type: file.media_type, + size: file.size, + path: + file.path || + getMothershipAttachmentPreviewUrl(file) || + getMothershipAttachmentUrl(file), + previewUrl: getMothershipAttachmentPreviewUrl(file), + uploading: false, + })) + ) + } + return queued + }} + onCancelQueueEdit={chat.cancelQueueEdit} + userId={session?.user?.id} + chatId={chat.resolvedChatId} + composer={composer} + onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined} + initialScrollBlocked={panel.initialScrollBlocked} + /> + ) : ( + + {hostLanding?.accessory} + {composer} +
+ {requestMode === 'agent' ? ( + setDraft(mentionifyIntegrations(prompt))} + /> + ) : searchAccess.memberScoped ? ( + + ) : null} +
+
+ )} + )}
) return ( }> - + {projectViewEnabled ? ( + + ) : ( + + )} ) } diff --git a/apps/sim/app/o/[organizationId]/home/search-params.ts b/apps/sim/app/o/[organizationId]/home/search-params.ts index 5d9af1370ee..8faf7471d7d 100644 --- a/apps/sim/app/o/[organizationId]/home/search-params.ts +++ b/apps/sim/app/o/[organizationId]/home/search-params.ts @@ -12,6 +12,7 @@ export const SEARCH_LEVEL_VALUES = [ ] as const satisfies readonly SearchLevel[] export const organizationHomeParsers = { + mode: parseAsStringLiteral(['agent', 'plan', 'assistant', 'search'] as const), ...organizationSearchParsers, ...searchFilterParsers, searchLevel: parseAsStringLiteral(SEARCH_LEVEL_VALUES), diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index b7525c40adb..d31a09d4075 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -73,6 +74,7 @@ export default async function OrganizationLayout({ computerUseEnabled, workflowTestsEnabled, changelogEnabled, + orgProjectViewEnabled, ] = await Promise.all([ prefetchOrganizationSidebar( queryClient, @@ -89,6 +91,7 @@ export default async function OrganizationLayout({ isComputerUseAvailable(), isWorkflowTestsEnabled(organizationId), isChangelogEnabled(organizationId), + isOrgProjectViewEnabled(organizationId), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -103,6 +106,7 @@ export default async function OrganizationLayout({ 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, + 'org-project-view': orgProjectViewEnabled, }} > diff --git a/apps/sim/app/o/[organizationId]/p/[workspaceId]/[section]/page.tsx b/apps/sim/app/o/[organizationId]/p/[workspaceId]/[section]/page.tsx new file mode 100644 index 00000000000..f2d47becbcf --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/[section]/page.tsx @@ -0,0 +1,22 @@ +import { notFound, redirect } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' +import { isProjectSection } from '@/app/o/[organizationId]/p/routes' + +interface ProjectSectionPageProps { + params: Promise<{ organizationId: string; workspaceId: string; section: string }> + searchParams: Promise> +} + +/** Existing project bookmarks enter the same shell as Home and organization chats. */ +export default async function ProjectSectionPage({ + params, + searchParams, +}: ProjectSectionPageProps) { + const { organizationId, workspaceId, section } = await params + if (!isProjectSection(section)) notFound() + const incoming = await searchParams + const query = new URLSearchParams({ project: workspaceId, section, pane: 'project' }) + if (typeof incoming.dashboard === 'string') query.set('dashboard', incoming.dashboard) + if (typeof incoming.resource === 'string') query.set('environment-resource', incoming.resource) + redirect(`${organizationRoutes(organizationId).home}?${query}`) +} diff --git a/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx b/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx new file mode 100644 index 00000000000..d38e5c115e3 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { notFound } from 'next/navigation' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' +import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + +/** + * A project's pages, on while the org project view is rolled out to the organization. The + * `[workspaceId]` segment lets the Chat components read the project's workspace from the route, + * with the permissions its message actions expect. + */ +export default async function ProjectLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ organizationId: string; workspaceId: string }> +}) { + const { organizationId, workspaceId } = await params + if (!(await isOrgProjectViewEnabled(organizationId))) notFound() + return ( + + {children} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx b/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx new file mode 100644 index 00000000000..dc1a7885aa5 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' + +export default async function ProjectIndexPage({ + params, +}: { + params: Promise<{ organizationId: string; workspaceId: string }> +}) { + const { organizationId, workspaceId } = await params + redirect(organizationRoutes(organizationId).project(workspaceId)) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx b/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx new file mode 100644 index 00000000000..a290ac0de6d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx @@ -0,0 +1,73 @@ +'use client' + +import { + Chip, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Branch, Check, ChevronDown } from '@sim/emcn/icons' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import type { ProjectSection } from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { workspaceHostContextQueryOptions } from '@/hooks/queries/workspace-host' + +interface EnvironmentSwitcherProps { + project: Project + section: ProjectSection + onChange?: (workspaceId: string) => void +} + +/** + * The project's environment (Prod, Staging, Sandbox): each is a forked workspace in the same + * lineage, and switching keeps the section you are looking at. + */ +export function EnvironmentSwitcher({ project, section, onChange }: EnvironmentSwitcherProps) { + const router = useRouter() + const queryClient = useQueryClient() + const { organization } = useOrganizationContext() + if (project.environments.length <= 1) + return ( + + {project.environment} + + ) + /** Warms each environment's host on open, so picking one swaps the body without a skeleton. */ + const prefetchEnvironments = (open: boolean) => { + if (!open) return + for (const environment of project.environments) + void queryClient.prefetchQuery(workspaceHostContextQueryOptions(environment.workspaceId)) + } + return ( + + + + {project.environment} + + + + {project.environments.map((environment) => ( + + onChange + ? onChange(environment.workspaceId) + : router.push( + organizationRoutes(organization.id).project(environment.workspaceId, section) + ) + } + > + {environment.label} + {environment.workspaceId === project.id && ( + + )} + + ))} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-details.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-details.tsx new file mode 100644 index 00000000000..6555e340d22 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-details.tsx @@ -0,0 +1,178 @@ +'use client' + +import type { ReactNode } from 'react' +import { + Chip, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Skeleton, +} from '@sim/emcn' +import { ArrowLeft, ArrowRight } from '@sim/emcn/icons' +import { EnvironmentName } from '@/app/o/[organizationId]/p/components/environments/environment-name' +import type { EnvironmentColumn } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { + type RowAction, + RowActionsMenu, +} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' +import { useChangelogReleases } from '@/hooks/queries/changelog' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflowTests } from '@/hooks/queries/workflow-tests' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' + +interface EnvironmentDetailsProps { + environment: EnvironmentColumn + parent?: EnvironmentColumn + forkCount: number + /** Present when the viewer can sync this fork with its parent. */ + onSync?: (direction: ForkDirection) => void + /** For the root: the forks that can pull from it, each running that fork's pull. */ + pullInto?: { id: string; label: string; onSelect: () => void }[] + /** Everything else the viewer can do to the environment, behind the row menu. */ + actions: RowAction[] + renaming: boolean + onRenamingChange: (renaming: boolean) => void + onOpenChangelog: () => void +} + +/** What one environment holds, with push and pull to its parent as the only direct actions. */ +export function EnvironmentDetails({ + environment, + parent, + forkCount, + onSync, + pullInto = [], + actions, + renaming, + onRenamingChange, + onOpenChangelog, +}: EnvironmentDetailsProps) { + const workspaceId = environment.id + const testsEnabled = useFeatureFlag('workflow-tests') + const changelogEnabled = useFeatureFlag('changelog') + const workflows = useWorkflows(workspaceId) + const tables = useTablesList(workspaceId, 'active') + const files = useWorkspaceFiles(workspaceId, 'active') + const knowledgeBases = useKnowledgeBasesQuery(workspaceId) + const tests = useWorkflowTests(workspaceId, { enabled: testsEnabled }) + const releases = useChangelogReleases(changelogEnabled ? workspaceId : '') + + const deployedCount = workflows.data?.filter((workflow) => workflow.isDeployed).length ?? 0 + const passingCount = tests.data?.filter((test) => test.status === 'passing').length ?? 0 + const resourceCount = + (tables.data?.length ?? 0) + (files.data?.length ?? 0) + (knowledgeBases.data?.length ?? 0) + const latestRelease = releases.data?.pages[0]?.releases[0] + + return ( +
+
+
+ + + {parent ? `Forked from ${parent.label}` : 'Root environment'} + {forkCount > 0 ? ` · ${forkCount} ${forkCount === 1 ? 'fork' : 'forks'}` : ''} + +
+
+ {onSync ? ( + <> + onSync('pull')}> + Pull + + onSync('push')}> + Push + + + ) : pullInto.length === 1 ? ( + + Pull into {pullInto[0].label} + + ) : pullInto.length > 1 ? ( + + + + Pull + + + + {pullInto.map((source) => ( + + Into {source.label} + + ))} + + + ) : null} + {actions.length > 0 ? ( + + ) : null} +
+
+
+ + {workflows.data?.length ?? 0} + · {deployedCount} deployed + + {testsEnabled ? ( + + {tests.data?.length ? `${passingCount}/${tests.data.length} passing` : 'None'} + + ) : null} + + {resourceCount} + + {changelogEnabled ? ( + + {latestRelease ? ( + + ) : ( + 'None' + )} + + ) : null} +
+
+ ) +} + +interface StatProps { + label: string + loading: boolean + children: ReactNode +} + +function Stat({ label, loading, children }: StatProps) { + return ( +
+
{label}
+
+ {loading ? : children} +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-graph.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-graph.tsx new file mode 100644 index 00000000000..530eccb3d0d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-graph.tsx @@ -0,0 +1,538 @@ +'use client' + +import { type ComponentType, type ReactNode, useEffect, useState } from 'react' +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + OverflowText, + Tooltip, +} from '@sim/emcn' +import { ArrowLeft, ArrowRight, Branch, Plus } from '@sim/emcn/icons' +import { + BLOCK_DIMENSIONS, + CONNECTION_KNOB_PEAK_PX, + useCanvasColorMode, + WorkflowBlockBorder, + type WorkflowBorderPort, +} from '@sim/workflow-renderer' +import { + BaseEdge, + type Edge, + type EdgeProps, + getSmoothStepPath, + Handle, + type Node, + type NodeProps, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import type { EnvironmentColumn } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' + +const NODE_WIDTH = BLOCK_DIMENSIONS.FIXED_WIDTH +const NODE_HEIGHT = BLOCK_DIMENSIONS.MIN_PAINTED_HEIGHT +const COLUMN_GAP = 120 +const ROW_GAP = 40 +/** The canvas sizes a side port to half its card's height. */ +const PORT_LENGTH = NODE_HEIGHT / 2 +/** Zoom when an environment is focused. */ +const FOCUS_ZOOM = 1.1 +const FIT_PADDING = 0.2 +const VIEWPORT_DURATION_MS = 400 +/** The action swell's height above a card's top edge. */ +const ACTION_MENU_HEIGHT = 28 +const ACTION_MENU_RIGHT_INSET = 24 +const ACTION_MENU_AMPLITUDE = 7 +/** The canvas's gap between actions and the swell's horizontal padding. */ +const ACTION_GAP = 2 +const ACTION_MENU_PADDING = 6 + +type ActionShape = 'first' | 'middle' | 'last' | 'only' + +/** The swell's buttons; the end ones follow its sloped shoulders. */ +const ACTION_SHAPES: Record = { + first: { + width: 40, + className: + "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&>svg]:translate-x-[6px] [&>svg]:translate-y-px", + }, + middle: { width: 24, className: '' }, + last: { + width: 40, + className: + "!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px", + }, + only: { + width: 56, + className: + "!w-[56px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L50.66_24A2.5_2.5_0_0_0_52.59_19.9L38.4_2.88A8_8_0_0_0_32.25_0Z')] [&>svg]:translate-y-px", + }, +} + +function actionShape(index: number, count: number): ActionShape { + if (count === 1) return 'only' + if (index === 0) return 'first' + return index === count - 1 ? 'last' : 'middle' +} + +interface PullSource { + id: string + label: string + onSelect: () => void +} + +interface SwellAction { + id: string + icon: ComponentType<{ className?: string }> + label: string + onClick?: () => void + /** Opens a menu of choices instead of acting directly. */ + menu?: PullSource[] +} + +/** Pushing or pulling a fork against the environment it was forked from. */ +interface EnvironmentSyncActions { + parentLabel: string + onSync: (direction: ForkDirection) => void +} + +interface EnvironmentNodeData extends Record { + column: EnvironmentColumn + /** Other environments were forked from this one: an input port on the left. */ + hasForks: boolean + /** This environment was forked from another: an output port on the right. */ + hasParent: boolean + selected: boolean + /** Present when the viewer can sync this fork with its parent. */ + sync?: EnvironmentSyncActions + /** Present when the viewer can fork this environment. */ + onFork?: () => void + /** For the root: the forks that can pull from it, each running that fork's pull. */ + pullInto: PullSource[] +} + +interface LineageEdgeData extends Record { + /** An endpoint is the selected environment. */ + connectedToSelection: boolean +} + +type EnvironmentNode = Node +type LineageEdge = Edge + +interface SyncActionProps { + shape: ActionShape + icon: ComponentType<{ className?: string }> + label: string + selected: boolean + onClick?: () => void + menu?: PullSource[] +} + +function SwellButton({ shape, icon: Icon, label, selected, onClick, menu }: SyncActionProps) { + const button = ( + + ) + if (menu) { + return ( + + {button} + + {menu.map((source) => ( + + Into {source.label} + + ))} + + + ) + } + return ( + + {button} + {label} + + ) +} + +function EnvironmentNodeView({ data }: NodeProps) { + const [actionMenuReady, setActionMenuReady] = useState(false) + const swellActions: SwellAction[] = [ + ...(data.sync + ? [ + { + id: 'pull', + icon: ArrowLeft, + label: `Pull from ${data.sync.parentLabel}`, + onClick: () => data.sync?.onSync('pull'), + }, + { + id: 'push', + icon: ArrowRight, + label: `Push to ${data.sync.parentLabel}`, + onClick: () => data.sync?.onSync('push'), + }, + ] + : data.pullInto.length === 1 + ? [ + { + id: 'pull', + icon: ArrowLeft, + label: `Pull into ${data.pullInto[0].label}`, + onClick: data.pullInto[0].onSelect, + }, + ] + : data.pullInto.length > 1 + ? [{ id: 'pull', icon: ArrowLeft, label: 'Pull into a fork', menu: data.pullInto }] + : []), + ...(data.onFork + ? [{ id: 'fork', icon: Plus, label: `Fork ${data.column.label}`, onClick: data.onFork }] + : []), + ] + const swellWidth = + swellActions.reduce( + (width, _, index) => width + ACTION_SHAPES[actionShape(index, swellActions.length)].width, + 0 + ) + + ACTION_GAP * Math.max(0, swellActions.length - 1) + + ACTION_MENU_PADDING + const ports: WorkflowBorderPort[] = [ + ...(data.hasForks + ? [{ id: 'forks', side: 'left' as const, position: 'center' as const, plateau: PORT_LENGTH }] + : []), + ...(data.hasParent + ? [ + { + id: 'parent', + side: 'right' as const, + position: 'center' as const, + plateau: PORT_LENGTH, + }, + ] + : []), + ...(swellActions.length + ? [ + { + id: 'action-menu', + side: 'top' as const, + position: { fromEnd: ACTION_MENU_RIGHT_INSET + swellWidth / 2 }, + plateau: swellWidth, + restAmplitude: ACTION_MENU_AMPLITUDE, + hoverAmplitude: ACTION_MENU_AMPLITUDE, + magnetizable: false, + }, + ] + : []), + ] + return ( +
+ {swellActions.length ? ( +
+ {swellActions.map((action, index) => ( + + ))} +
+ ) : null} + +
+ + + + +
+ + +
+ ) +} + +function LineageEdgeView({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: EdgeProps) { + const [path] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 8, + offset: 30, + }) + return ( + + ) +} + +const NODE_TYPES = { environment: EnvironmentNodeView } +const EDGE_TYPES = { lineage: LineageEdgeView } + +/** + * Lays forks out in columns that flow right into their parent: leaves take successive rows and + * each parent centres on its children, so every fork-to-parent edge reads left to right. + */ +function layout(columns: readonly EnvironmentColumn[]) { + const known = new Set(columns.map((column) => column.id)) + const childrenOf = new Map() + for (const column of columns) { + const key = column.parentId && known.has(column.parentId) ? column.parentId : null + childrenOf.set(key, [...(childrenOf.get(key) ?? []), column]) + } + const depthOf = new Map() + const rowOf = new Map() + let nextRow = 0 + const place = (column: EnvironmentColumn, depth: number): number => { + depthOf.set(column.id, depth) + const children = childrenOf.get(column.id) ?? [] + const row = children.length + ? children.reduce((sum, child) => sum + place(child, depth + 1), 0) / children.length + : nextRow++ + rowOf.set(column.id, row) + return row + } + for (const root of childrenOf.get(null) ?? []) place(root, 0) + const maxDepth = Math.max(0, ...depthOf.values()) + const positions = new Map( + columns.map((column) => [ + column.id, + { + x: (maxDepth - (depthOf.get(column.id) ?? 0)) * (NODE_WIDTH + COLUMN_GAP), + y: (rowOf.get(column.id) ?? 0) * (NODE_HEIGHT + ROW_GAP), + }, + ]) + ) + return { + positions, + width: (maxDepth + 1) * NODE_WIDTH + maxDepth * COLUMN_GAP, + height: Math.max(1, nextRow) * NODE_HEIGHT + Math.max(0, nextRow - 1) * ROW_GAP, + } +} + +interface EnvironmentGraphProps { + columns: readonly EnvironmentColumn[] + /** The focused environment; the canvas zooms to it and the sheet describes it. */ + selectedId: string | null + onSelect: (id: string | null) => void + /** Forks the viewer can push to and pull from their parent. */ + syncableIds: ReadonlySet + onSync: (childId: string, direction: ForkDirection) => void + /** Environments the viewer can fork. */ + forkableIds: ReadonlySet + onFork: (sourceId: string) => void + /** Details of the selected environment, raised from the bottom of the canvas. */ + sheet: ReactNode + /** Status shown over the canvas's top edge. */ + notice?: ReactNode +} + +function Graph({ + columns, + selectedId, + onSelect, + syncableIds, + onSync, + forkableIds, + onFork, + sheet, + notice, +}: EnvironmentGraphProps) { + const colorMode = useCanvasColorMode() + const { fitView, setCenter } = useReactFlow() + const [sheetElement, setSheetElement] = useState(null) + const [sheetHeight, setSheetHeight] = useState(0) + const { positions } = layout(columns) + const byId = new Map(columns.map((column) => [column.id, column])) + const selectedPosition = selectedId ? positions.get(selectedId) : undefined + + useEffect(() => { + if (!sheetElement) return + const observer = new ResizeObserver(([entry]) => setSheetHeight(entry.contentRect.height)) + observer.observe(sheetElement) + return () => observer.disconnect() + }, [sheetElement]) + + /** Focus centers the environment in the space above the sheet; clearing it fits everything. */ + useEffect(() => { + if (!selectedPosition) { + void fitView({ padding: FIT_PADDING, maxZoom: 1, duration: VIEWPORT_DURATION_MS }) + return + } + void setCenter( + selectedPosition.x + NODE_WIDTH / 2, + selectedPosition.y + NODE_HEIGHT / 2 + sheetHeight / 2 / FOCUS_ZOOM, + { zoom: FOCUS_ZOOM, duration: VIEWPORT_DURATION_MS } + ) + }, [selectedPosition?.x, selectedPosition?.y, sheetHeight, fitView, setCenter]) + + const nodes: EnvironmentNode[] = columns.map((column) => { + const parent = column.parentId ? byId.get(column.parentId) : undefined + return { + id: column.id, + type: 'environment', + position: positions.get(column.id) ?? { x: 0, y: 0 }, + data: { + column, + hasForks: columns.some((candidate) => candidate.parentId === column.id), + hasParent: Boolean(parent), + selected: column.id === selectedId, + sync: + parent && syncableIds.has(column.id) + ? { + parentLabel: parent.label, + onSync: (direction: ForkDirection) => onSync(column.id, direction), + } + : undefined, + onFork: forkableIds.has(column.id) ? () => onFork(column.id) : undefined, + pullInto: parent + ? [] + : columns + .filter( + (candidate) => candidate.parentId === column.id && syncableIds.has(candidate.id) + ) + .map((fork) => ({ + id: fork.id, + label: fork.label, + onSelect: () => onSync(fork.id, 'pull'), + })), + }, + } + }) + const edges: LineageEdge[] = columns.flatMap((column) => { + const parent = column.parentId ? byId.get(column.parentId) : undefined + if (!parent) return [] + return [ + { + id: `${column.id}->${parent.id}`, + source: column.id, + target: parent.id, + type: 'lineage', + data: { connectedToSelection: selectedId === column.id || selectedId === parent.id }, + }, + ] + }) + + return ( +
+ { + if ((event.target as HTMLElement).closest('button, [role="menuitem"]')) return + onSelect(node.id === selectedId ? null : node.id) + }} + onPaneClick={() => onSelect(null)} + proOptions={{ hideAttribution: true }} + /> + {notice ?
{notice}
: null} +
+ {sheet} +
+
+ ) +} + +/** The project's environments on a pannable canvas, each block wired into the one it was forked from. */ +export function EnvironmentGraph(props: EnvironmentGraphProps) { + return ( + + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx new file mode 100644 index 00000000000..1c3ede889b8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { ChipInput, OverflowText, toast } from '@sim/emcn' +import { useUpdateWorkspace } from '@/hooks/queries/workspace' + +interface EnvironmentNameProps { + workspaceId: string + name: string + /** Shows the inline rename form; started from the card's actions menu. */ + renaming: boolean + onRenamingChange: (renaming: boolean) => void +} + +/** Renames the workspace represented by an environment without leaving its card. */ +export function EnvironmentName({ + workspaceId, + name, + renaming, + onRenamingChange, +}: EnvironmentNameProps) { + if (!renaming) { + return + } + return onRenamingChange(false)} /> +} + +interface RenameFormProps { + workspaceId: string + name: string + onDone: () => void +} + +function RenameForm({ workspaceId, name, onDone }: RenameFormProps) { + const rename = useUpdateWorkspace() + const [draft, setDraft] = useState(name) + const commit = () => { + if (rename.isPending) return + const next = draft.trim() + if (!next || next === name) { + onDone() + return + } + rename.mutate( + { workspaceId, name: next }, + { + onSuccess: () => { + onDone() + toast.success('Environment renamed') + }, + onError: (error) => toast.error(error.message), + } + ) + } + + return ( +
{ + event.preventDefault() + commit() + }} + > + event.target.select()} + onChange={(event) => setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === 'Escape' && !rename.isPending) { + event.preventDefault() + event.stopPropagation() + onDone() + } + }} + disabled={rename.isPending} + error={Boolean(rename.error)} + /> + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx new file mode 100644 index 00000000000..587e0f27d73 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + permissions: vi.fn(), + credentials: vi.fn(), + environment: vi.fn(), + save: vi.fn(), +})) +vi.mock('@/hooks/queries/workspace', () => ({ useWorkspacePermissionsQuery: mocks.permissions })) +vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredentials: mocks.credentials })) +vi.mock('@/hooks/queries/environment', () => ({ + useWorkspaceEnvironment: mocks.environment, + useUpsertWorkspaceEnvironment: () => ({ + mutate: mocks.save, + isPending: false, + error: null, + reset: vi.fn(), + }), +})) + +import { EnvironmentSecretPeek } from '@/app/o/[organizationId]/p/components/environments/environment-secret-peek' + +let container: HTMLDivElement +let root: Root +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + mocks.permissions.mockReturnValue({ data: { viewer: { isAdmin: true } } }) + mocks.credentials.mockReturnValue({ data: [] }) + mocks.environment.mockReturnValue({ + data: { workspace: { TOKEN: 'test-only-value' }, personal: { TOKEN: 'personal-value' } }, + isLoading: false, + isError: false, + isSuccess: true, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) +function render() { + act(() => + root.render( + + ) + ) +} +function field() { + const input = container.querySelector('input') + if (!input) throw new Error('Missing secret field') + return input +} +it('reveals an allowed read-only value on focus and masks it again on blur', () => { + render() + expect(field().value).toBe('•'.repeat(10)) + act(() => field().focus()) + expect(field().value).toBe('test-only-value') + expect(field().readOnly).toBe(true) + act(() => field().blur()) + expect(field().value).toBe('•'.repeat(10)) +}) +it('does not expose a cached value when reveal permission is absent', () => { + mocks.permissions.mockReturnValue({ data: { viewer: { isAdmin: false } } }) + render() + act(() => field().focus()) + expect(field().value).toBe('•'.repeat(10)) + expect(field().readOnly).toBe(true) + expect(mocks.environment).toHaveBeenLastCalledWith('staging-id', { enabled: false }) +}) +it('only makes the field editable for the secret administrator', () => { + mocks.credentials.mockReturnValue({ data: [{ envKey: 'TOKEN', role: 'admin' }] }) + render() + act(() => field().focus()) + expect(field().readOnly).toBe(false) + expect(field().value).toBe('test-only-value') +}) +it('does not substitute a personal value for a missing workspace value', () => { + mocks.environment.mockReturnValue({ + data: { workspace: {}, personal: { TOKEN: 'personal-value' } }, + isLoading: false, + isError: false, + isSuccess: true, + }) + render() + expect(container.querySelector('input')).toBeNull() + expect(container.textContent).not.toContain('personal-value') +}) + +it('saves only the edited key to its own environment', () => { + mocks.credentials.mockReturnValue({ data: [{ envKey: 'TOKEN', role: 'admin' }] }) + render() + act(() => field().focus()) + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(field(), 'replacement-test-value') + field().dispatchEvent(new Event('input', { bubbles: true })) + }) + act(() => + container + .querySelector('form') + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + ) + expect(mocks.save).toHaveBeenCalledWith( + { workspaceId: 'staging-id', variables: { TOKEN: 'replacement-test-value' } }, + expect.any(Object) + ) +}) diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.tsx new file mode 100644 index 00000000000..4a655a3f227 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useState } from 'react' +import { Chip, cn, toast } from '@sim/emcn' +import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field/secret-value-field' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useUpsertWorkspaceEnvironment, useWorkspaceEnvironment } from '@/hooks/queries/environment' +import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' + +interface EnvironmentSecretPeekProps { + workspaceId: string + environmentName: string + secretKey: string + /** Column sizing from the mappings table. */ + className?: string +} + +/** Shares the workspace settings field's reveal behavior and per-secret edit permissions. */ +export function EnvironmentSecretPeek({ + workspaceId, + environmentName, + secretKey, + className, +}: EnvironmentSecretPeekProps) { + const [draft, setDraft] = useState(null) + const permissions = useWorkspacePermissionsQuery(workspaceId) + const credentials = useWorkspaceCredentials({ + workspaceId, + type: 'env_workspace', + enabled: Boolean(workspaceId), + }) + const credential = credentials.data?.find((item) => item.envKey === secretKey) + const canEdit = credential?.role === 'admin' + const canReveal = Boolean(permissions.data?.viewer?.isAdmin || canEdit || credential?.unredacted) + const environment = useWorkspaceEnvironment(workspaceId, { enabled: canReveal }) + const save = useUpsertWorkspaceEnvironment() + const storedValue = environment.data?.workspace[secretKey] + const value = draft ?? storedValue ?? '' + const changed = draft !== null && draft !== storedValue + /** Absent here, the value has nothing to show; the mapping row already says what to do. */ + if (canReveal && environment.isSuccess && storedValue === undefined) return null + + return ( +
{ + event.preventDefault() + if (!canEdit || !changed || save.isPending || storedValue === undefined) return + save.mutate( + { workspaceId, variables: { [secretKey]: value } }, + { + onSuccess: () => { + setDraft(null) + toast.success('Secret saved') + }, + } + ) + }} + > + + {environment.isError && ( +

+ Could not load this secret. +

+ )} + {save.error && ( +

+ {save.error.message} +

+ )} + {changed && canEdit && ( +
+ + {save.isPending ? 'Saving…' : 'Save'} + + { + setDraft(null) + save.reset() + }} + > + Discard + +
+ )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-sync.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-sync.tsx new file mode 100644 index 00000000000..086f2846295 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-sync.tsx @@ -0,0 +1,331 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipCombobox, Skeleton, Tooltip } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import { useQueryState, useQueryStates } from 'nuqs' +import { + type PaneTab, + projectPaneOptions, + projectPaneParsers, + SYNC_STEPS, + type SyncStep, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import type { EnvironmentColumn } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { RESOURCE_TABS } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { ResourceMappingEditor } from '@/app/o/[organizationId]/p/components/environments/resource-mapping-editor' +import { SyncReview } from '@/app/o/[organizationId]/p/components/environments/sync-review' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' +import { + type FilterTag, + ResourceFilterPanel, + ResourceFilterSection, + ResourceOptions, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { ForkSyncConfirmModal } from '@/ee/workspace-forking/components/fork-sync/fork-sync-confirm-modal' +import { ForkCopyResources } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import { useForkSync } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +type MappingView = 'unmapped' | 'sync' | 'all' + +const MAPPING_VIEW_OPTIONS: { value: MappingView; label: string }[] = [ + { value: 'unmapped', label: 'Not mapped yet' }, + { value: 'sync', label: 'Needed for this sync' }, + { value: 'all', label: 'All mappings' }, +] + +const STEP_TITLES: Record = { + mappings: 'Map resources', + copy: 'Copy resources', + review: 'Review and sync', +} + +interface EnvironmentSyncProps { + tab: Extract +} + +/** + * One fork's sync with its parent as its own pane tab, in three steps: map resources, choose + * what to copy, then review the workflow changes and sync. Needs admin on both environments + * and the fork entitlement, as the server does. + */ +export function EnvironmentSync({ tab }: EnvironmentSyncProps) { + const { data: workspaces, isPending: workspacesPending } = useWorkspacesQuery() + const availability = useForkingAvailability(tab.childId) + const childWorkspace = workspaces?.find((workspace) => workspace.id === tab.childId) + const parentWorkspace = workspaces?.find( + (workspace) => workspace.id === childWorkspace?.forkedFromWorkspaceId + ) + + if (workspacesPending || availability.isLoading) { + return + } + if (!childWorkspace || !parentWorkspace) { + return ( +

+ This fork is no longer connected to an environment you can access. +

+ ) + } + if ( + !availability.available || + childWorkspace.permissions !== 'admin' || + parentWorkspace.permissions !== 'admin' + ) { + return ( +

+ Syncing a fork needs admin access to both environments. +

+ ) + } + + const child: EnvironmentColumn = { + id: childWorkspace.id, + label: childWorkspace.name, + name: childWorkspace.name, + parentId: parentWorkspace.id, + } + const parent: EnvironmentColumn = { + id: parentWorkspace.id, + label: parentWorkspace.name, + name: parentWorkspace.name, + parentId: parentWorkspace.forkedFromWorkspaceId ?? null, + } + return +} + +interface SyncStepsProps { + tab: Extract + child: EnvironmentColumn + parent: EnvironmentColumn +} + +function SyncSteps({ tab, child, parent }: SyncStepsProps) { + const [, setPaneParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const [kinds, setKinds] = useQueryState( + 'environment-resource', + projectParsers.resource.withOptions({ history: 'replace', clearOnDefault: true }) + ) + const [mappingSearch, setMappingSearchParam] = useQueryState( + 'mapping-search', + projectParsers.mappingSearch.withOptions({ history: 'replace', clearOnDefault: true }) + ) + const setMappingSearch = useDebouncedSearchSetter(setMappingSearchParam) + const [mappingView, setMappingView] = useState('unmapped') + const sync = { + workspaceId: child.id, + workspaceName: child.name, + otherWorkspaceId: parent.id, + otherWorkspaceName: parent.name, + direction: tab.direction, + enabled: true, + } + const controller = useForkSync(sync) + const allMappings = useForkSync({ ...sync, mappingScope: 'all' }) + + const visibleGroups = allMappings.groups.map((group) => ({ + ...group, + items: + mappingView !== 'sync' + ? group.items + : group.items.filter((entry) => + controller.groups.some( + (syncGroup) => + syncGroup.kind === group.kind && + syncGroup.items.some((item) => item.sourceId === entry.sourceId) + ) + ), + })) + const mappingEditor = { ...allMappings, groups: visibleGroups } + const countOf = new Map(visibleGroups.map((group) => [group.kind, group.items.length])) + const labelOf = new Map(RESOURCE_TABS.map((resourceTab) => [resourceTab.id, resourceTab.label])) + const kindsLabel = + kinds.length === 0 + ? 'All' + : kinds.length === 1 + ? (labelOf.get(kinds[0]) ?? kinds[0]) + : `${kinds.length} types` + const filterTags: FilterTag[] = [ + ...(kinds.length > 0 + ? [{ label: `Type: ${kindsLabel}`, onRemove: () => void setKinds(null) }] + : []), + ...(mappingView !== 'all' + ? [ + { + label: MAPPING_VIEW_OPTIONS.find((option) => option.value === mappingView)?.label ?? '', + onRemove: () => setMappingView('all'), + }, + ] + : []), + ] + const filterContent = ( + + + ({ + value: resourceTab.id, + label: countOf.get(resourceTab.id) + ? `${resourceTab.label} (${countOf.get(resourceTab.id)})` + : resourceTab.label, + }))} + multiSelect + multiSelectValues={kinds} + onMultiSelectChange={(next) => + void setKinds( + RESOURCE_TABS.filter((resourceTab) => next.includes(resourceTab.id)).map( + (resourceTab) => resourceTab.id + ) + ) + } + overlayLabel={kindsLabel} + overlayContent={kindsLabel} + showAllOption + allOptionLabel='All' + className='w-full' + /> + + + + setMappingView( + MAPPING_VIEW_OPTIONS.find((option) => option.value === next)?.value ?? 'unmapped' + ) + } + className='w-full' + /> + + + ) + + const [confirmOpen, setConfirmOpen] = useState(false) + const mappingChangesPending = allMappings.dirty || allMappings.saving + const syncBlockedReason = mappingChangesPending + ? 'Save or discard mapping changes first' + : controller.syncDisabledReason + const stepIndex = SYNC_STEPS.indexOf(tab.step) + const goTo = (step: SyncStep) => + void setPaneParams((current) => ({ + tabs: current.tabs.map((entry) => (entry.id === tab.id ? { ...tab, step } : entry)), + })) + const source = tab.direction === 'push' ? child : parent + const target = tab.direction === 'push' ? parent : child + + return ( +
+
+
+

{STEP_TITLES[tab.step]}

+

+ Step {stepIndex + 1} of {SYNC_STEPS.length} · {source.label} + + {target.label} +

+
+
+ goTo(SYNC_STEPS[stepIndex - 1])}> + Back + + {tab.step !== 'review' ? ( + { + if (tab.step === 'mappings' && allMappings.dirty) allMappings.save() + goTo(SYNC_STEPS[stepIndex + 1]) + }} + > + {tab.step === 'mappings' && allMappings.dirty ? 'Save and next' : 'Next'} + + ) : ( + + + + setConfirmOpen(true)} + disabled={ + mappingChangesPending || controller.syncDisabled || controller.isLoading + } + > + {controller.submitting ? 'Syncing…' : 'Sync'} + + + + {syncBlockedReason ? ( + {syncBlockedReason} + ) : null} + + )} +
+
+ {tab.step === 'mappings' ? ( +
+ setMappingSearch(''), + placeholder: 'Search mappings...', + }} + filter={{ content: filterContent, active: filterTags.length > 0 }} + filterTags={filterTags} + trailing={ + allMappings.dirty ? ( + + Discard + + ) : undefined + } + /> + {allMappings.isLoading ? ( + + ) : ( + + )} +
+ ) : tab.step === 'copy' ? ( +
+ {controller.isLoading || !controller.hasDiff ? ( + + ) : controller.hasVisibleCopyables ? ( + + ) : ( +

+ Nothing to copy — every resource in {source.label} already exists in {target.label}. +

+ )} +
+ ) : ( +
+ +
+ )} + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environments-tab.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environments-tab.tsx new file mode 100644 index 00000000000..ba4bd3e118c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environments-tab.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Skeleton } from '@sim/emcn' +import { useOpenSyncTab } from '@/app/o/[organizationId]/home/components/project-pane/use-open-sync-tab' +import { LineageStrip } from '@/app/o/[organizationId]/p/components/environments/lineage-strip' +import { + lineageEdges, + orderEnvironments, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +interface EnvironmentsTabProps { + project: Project +} + +/** + * The project's environments on a canvas. Push or pull opens that pair's sync in its own pane + * tab; syncing needs admin on both environments and the fork entitlement. + */ +export function EnvironmentsTab({ project }: EnvironmentsTabProps) { + const openSyncTab = useOpenSyncTab() + const { data: workspaces, isPending: workspacesPending } = useWorkspacesQuery() + const availability = useForkingAvailability(project.rootId) + const gateLoading = availability.isLoading || workspacesPending + + /** A handful of workspaces, so the lookups are rebuilt each render rather than memoized. */ + const parentOf = new Map() + const nameOf = new Map() + for (const workspace of workspaces ?? []) { + parentOf.set(workspace.id, workspace.forkedFromWorkspaceId ?? null) + nameOf.set(workspace.id, workspace.name) + } + const columns = orderEnvironments(project.rootId, project.environments, parentOf, nameOf) + const edges = lineageEdges(columns) + /** A sync reads one side and replaces the other, so it needs admin on both (as the server does). */ + const adminIds = new Set( + workspaces + ?.filter((workspace) => workspace.permissions === 'admin') + .map((workspace) => workspace.id) + ) + const manageableEdges = availability.available + ? edges.filter((edge) => adminIds.has(edge.childId) && adminIds.has(edge.parentId)) + : [] + + const notice = gateLoading + ? null + : !availability.available + ? 'Forking is not available for this project.' + : manageableEdges.length === 0 && edges.length > 0 + ? 'Syncing a fork needs admin access to both environments.' + : null + + return ( + 0} + manageableIds={new Set(manageableEdges.map((edge) => edge.childId))} + onSync={openSyncTab} + forkGatePending={gateLoading} + notice={ + gateLoading ? ( + + ) : notice ? ( +

{notice}

+ ) : null + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/index.ts b/apps/sim/app/o/[organizationId]/p/components/environments/index.ts new file mode 100644 index 00000000000..ae82090a890 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/index.ts @@ -0,0 +1 @@ +export { EnvironmentsTab } from '@/app/o/[organizationId]/p/components/environments/environments-tab' diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/lineage-strip.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/lineage-strip.tsx new file mode 100644 index 00000000000..9a818848be7 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/lineage-strip.tsx @@ -0,0 +1,237 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' +import { TriangleAlert } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { parseAsString, useQueryStates } from 'nuqs' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { EnvironmentDetails } from '@/app/o/[organizationId]/p/components/environments/environment-details' +import { EnvironmentGraph } from '@/app/o/[organizationId]/p/components/environments/environment-graph' +import type { EnvironmentColumn } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { EnvironmentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' +import { type ForkDirection, useUnlinkFork } from '@/ee/workspace-forking/hooks/workspace-fork' +import { useWorkspaceCreationPolicy, useWorkspacesQuery } from '@/hooks/queries/workspace' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +interface UnlinkTarget { + column: EnvironmentColumn + parent: EnvironmentColumn +} + +interface LineageStripProps { + project: Project + /** Root first, each fork after its parent. */ + columns: readonly EnvironmentColumn[] + /** Forking is available and the viewer administers an environment to fork from. */ + canCreateFork: boolean + /** Forks whose edge to their parent the viewer can sync and disconnect. */ + manageableIds: ReadonlySet + /** Opens the sync of a fork with its parent in one direction. */ + onSync: (childId: string, direction: ForkDirection) => void + /** Status shown over the canvas. */ + notice?: ReactNode + /** Holds the empty state's create chip while the fork gate is still loading. */ + forkGatePending: boolean +} + +/** + * The project's environments on a canvas, each block wired into the environment it was forked + * from. A block's action bar pulls, pushes, or forks; clicking it zooms in and raises a sheet with + * its details and the rest of its actions. + */ +export function LineageStrip({ + project, + columns, + canCreateFork, + manageableIds, + onSync, + notice, + forkGatePending, +}: LineageStripProps) { + const { billingEnabled } = useDeploymentShape() + const { data: creationPolicy } = useWorkspaceCreationPolicy() + const { data: workspaces } = useWorkspacesQuery() + const { navigateToSettings } = useSettingsNavigation() + const [, setDestination] = useQueryStates( + { + project: parseAsString, + pane: parseAsString, + section: parseAsString, + setting: parseAsString, + search: parseAsString, + }, + { history: 'push' } + ) + const unlink = useUnlinkFork() + const [forkSourceId, setForkSourceId] = useState(null) + const [confirmUnlink, setConfirmUnlink] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [renaming, setRenaming] = useState(false) + + const byId = new Map(columns.map((column) => [column.id, column])) + const renameableIds = new Set( + workspaces + ?.filter((workspace) => workspace.permissions === 'admin') + .map((workspace) => workspace.id) + ) + + const selected = selectedId ? byId.get(selectedId) : undefined + const parent = selected?.parentId ? byId.get(selected.parentId) : undefined + const forkCount = selected + ? columns.filter((column) => column.parentId === selected.id).length + : 0 + const isAdmin = Boolean(selected && renameableIds.has(selected.id)) + const canSync = Boolean(selected && parent && manageableIds.has(selected.id)) + + /** Permanently dissolves the confirmed edge; both workspaces remain. */ + const runUnlink = async () => { + if (!confirmUnlink) return + try { + await unlink.mutateAsync({ + workspaceId: confirmUnlink.column.id, + body: { otherWorkspaceId: confirmUnlink.parent.id }, + }) + toast.success(`Disconnected ${confirmUnlink.column.label} from ${confirmUnlink.parent.label}`) + setConfirmUnlink(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Disconnect failed')) + } + } + + return ( +
+ {columns.length === 1 ? ( + setForkSourceId(project.rootId)} + createDisabled={forkGatePending || !canCreateFork || !renameableIds.has(project.rootId)} + /> + ) : ( + { + setRenaming(false) + setSelectedId(id) + }} + syncableIds={manageableIds} + onSync={onSync} + forkableIds={canCreateFork ? renameableIds : new Set()} + onFork={setForkSourceId} + notice={notice} + sheet={ + selected ? ( + onSync(selected.id, direction) : undefined} + pullInto={ + parent + ? undefined + : columns + .filter( + (column) => + column.parentId === selected.id && manageableIds.has(column.id) + ) + .map((fork) => ({ + id: fork.id, + label: fork.label, + onSelect: () => onSync(fork.id, 'pull'), + })) + } + renaming={renaming} + onRenamingChange={setRenaming} + onOpenChangelog={() => + void setDestination({ + project: selected.id, + pane: 'project', + section: 'changelog', + setting: null, + search: null, + }) + } + actions={[ + ...(canCreateFork && isAdmin + ? [{ label: 'Fork', onSelect: () => setForkSourceId(selected.id) }] + : []), + ...(isAdmin + ? [ + { label: 'Rename', focusesEditor: true, onSelect: () => setRenaming(true) }, + { + label: 'Manage teammates', + onSelect: () => + void setDestination({ + project: selected.id, + pane: 'project', + section: 'settings', + setting: 'teammates', + search: null, + }), + }, + ] + : []), + ...(canSync && parent + ? [ + { + label: `Disconnect from ${parent.label}`, + destructive: true, + onSelect: () => setConfirmUnlink({ column: selected, parent }), + }, + ] + : []), + ]} + /> + ) : null + } + /> + )} + + { + if (!open) setForkSourceId(null) + }} + sourceWorkspaceId={forkSourceId ?? project.rootId} + sourceWorkspaceName={byId.get(forkSourceId ?? project.rootId)?.name ?? project.name} + sources={columns.map((column) => ({ id: column.id, name: column.name }))} + canFork={creationPolicy?.canCreate ?? true} + onUpgrade={() => { + if (billingEnabled) navigateToSettings({ section: 'billing' }) + }} + /> + + { + if (!open) setConfirmUnlink(null) + }} + srTitle='Disconnect fork' + title='Disconnect fork' + text={[ + 'This permanently removes the fork relationship between ', + { text: confirmUnlink?.column.label ?? '', bold: true }, + ' and ', + { text: confirmUnlink?.parent.label ?? '', 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...', + }} + > +
+ + + This cannot be undone: the saved mappings and sync history for this pair are deleted, + and forking again creates a brand-new workspace. + +
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/mapping-model.ts b/apps/sim/app/o/[organizationId]/p/components/environments/mapping-model.ts new file mode 100644 index 00000000000..b9901d4665f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/mapping-model.ts @@ -0,0 +1,90 @@ +import type { ProjectEnvironment } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + MAPPING_SECTION, + type MappableMappingKind, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + +type ResourceTabId = MappableMappingKind + +interface ResourceTab { + id: ResourceTabId + label: string +} + +/** Mapping resource types in the sync view's order. */ +export const RESOURCE_TABS: readonly ResourceTab[] = ( + Object.keys(MAPPING_SECTION) as MappableMappingKind[] +) + .sort((a, b) => MAPPING_SECTION[a].order - MAPPING_SECTION[b].order) + .map((id) => ({ id, label: MAPPING_SECTION[id].label })) + +/** The ids the `?environment-resource=` URL param accepts. */ +export const RESOURCE_TAB_IDS = RESOURCE_TABS.map((tab) => tab.id) as [ + ResourceTabId, + ...ResourceTabId[], +] + +/** One direct parent to child fork edge; mappings and diffs exist per edge. */ +export interface LineageEdge { + childId: string + parentId: string +} + +/** One environment as a grid column and a lineage card. */ +export interface EnvironmentColumn { + id: string + label: string + name: string + /** The environment this one was forked from; null for the root. */ + parentId: string | null +} + +/** + * Orders the environments root first and each fork after its parent, so every edge's parent + * side is placed before its child side and rows can be joined edge by edge. + */ +export function orderEnvironments( + rootId: string, + environments: readonly ProjectEnvironment[], + parentOf: ReadonlyMap, + nameOf: ReadonlyMap +): EnvironmentColumn[] { + const columns: EnvironmentColumn[] = [] + const seen = new Set() + const queue = [rootId] + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index] + if (seen.has(current)) continue + seen.add(current) + const environment = environments.find((candidate) => candidate.workspaceId === current) + if (!environment) continue + columns.push({ + id: current, + label: nameOf.get(current) ?? environment.label, + name: nameOf.get(current) ?? environment.label, + parentId: current === rootId ? null : (parentOf.get(current) ?? null), + }) + for (const candidate of environments) { + if (parentOf.get(candidate.workspaceId) === current) queue.push(candidate.workspaceId) + } + } + /** An environment whose parent left the lineage still gets a column, detached. */ + for (const environment of environments) { + if (seen.has(environment.workspaceId)) continue + columns.push({ + id: environment.workspaceId, + label: environment.label, + name: nameOf.get(environment.workspaceId) ?? environment.label, + parentId: null, + }) + } + return columns +} + +export function lineageEdges(columns: readonly EnvironmentColumn[]): LineageEdge[] { + const edges: LineageEdge[] = [] + for (const column of columns) { + if (column.parentId) edges.push({ childId: column.id, parentId: column.parentId }) + } + return edges +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/resource-mapping-editor.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/resource-mapping-editor.tsx new file mode 100644 index 00000000000..5cbfde8156e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/resource-mapping-editor.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState } from 'react' +import { cn, Skeleton } from '@sim/emcn' +import { EnvironmentSecretPeek } from '@/app/o/[organizationId]/p/components/environments/environment-secret-peek' +import { + MAPPING_TARGET_TRIGGER_CLASS, + MappingEntry, +} from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import type { + ForkSyncController, + MappableMappingKind, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + +const TYPE_COLUMN_CLASS = 'w-[96px] shrink-0' +/** The canonical resource table's cell inset. */ +const ROW_INSET_CLASS = 'px-3 md:px-6' + +interface ResourceMappingEditorProps { + controller: ForkSyncController + /** Resource types to show; empty shows every type. */ + kinds: readonly MappableMappingKind[] + /** Narrows the rows by resource name. */ + search: string + /** Shows only resources that had no target when the table opened, so a pick never hides its row. */ + unmappedOnly: boolean + sourceName: string + targetName: string +} + +/** + * Every mapping between the selected pair as one table, filtered by resource type, including + * dependent field configuration. + */ +export function ResourceMappingEditor({ + controller, + kinds, + search, + unmappedOnly, + sourceName, + targetName, +}: ResourceMappingEditorProps) { + const [unmappedAtOpen] = useState( + () => + new Set( + controller.groups.flatMap((group) => + group.items + .filter((entry) => !controller.targetFor(entry)) + .map((entry) => `${entry.resourceType}:${entry.sourceId}`) + ) + ) + ) + const query = search.trim().toLowerCase() + const rows = controller.groups + .filter((group) => kinds.length === 0 || kinds.includes(group.kind)) + .flatMap((group) => group.items.map((entry) => ({ group, entry }))) + .filter(({ entry }) => entry.sourceLabel.toLowerCase().includes(query)) + .filter( + ({ entry }) => !unmappedOnly || unmappedAtOpen.has(`${entry.resourceType}:${entry.sourceId}`) + ) + if (controller.isError || controller.diffErrorMessage) { + return ( +

+ {controller.errorMessage ?? controller.diffErrorMessage} +

+ ) + } + if ( + controller.isLoading || + controller.diffIsStale || + !controller.hasDiff || + !controller.hasMapping + ) { + return + } + return ( +
+
+ + Type + +
+ + {sourceName} + + + {targetName} + +
+
+ {rows.length ? ( + rows.map(({ group, entry }) => ( +
+ + {group.label} + +
+ + {group.kind === 'env-var' && ( +
+ + +
+ )} +
+
+ )) + ) : ( +

+ {unmappedOnly + ? 'Every resource is mapped. Use Filter to review all mappings.' + : 'No mappings.'} +

+ )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/sync-review.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/sync-review.tsx new file mode 100644 index 00000000000..0e879e6c2d7 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/sync-review.tsx @@ -0,0 +1,182 @@ +'use client' + +import { lazy, Suspense } from 'react' +import { Skeleton } from '@sim/emcn' +import { ArrowRight, Columns2 } from '@sim/emcn/icons' +import type { ForkWorkflowChange, ForkWorkflowComparison } from '@/lib/api/contracts/workspace-fork' +import { ForkSyncView } from '@/ee/workspace-forking/components/fork-sync/fork-sync-view' +import type { ForkSyncController } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' +import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' + +const ForkComparisonModal = lazy(() => + import('@/ee/workspace-forking/components/fork-sync/fork-comparison-modal').then((module) => ({ + default: module.ForkComparisonModal, + })) +) + +type ComparisonUnavailableReason = Extract< + ForkWorkflowComparison, + { status: 'unavailable' } +>['reason'] + +/** Why a workflow's changes can't be compared; a new workflow's badge already says so. */ +const COMPARISON_UNAVAILABLE: Record = { + new_workflow: null, + no_baseline: 'Comparable after the first sync', + missing_baseline: 'Last synced version unavailable', +} + +const ACTION_LABEL: Record = { + update: 'Updated', + create: 'New', + archive: 'Archived', +} + +interface SyncReviewProps { + controller: ForkSyncController + direction: ForkDirection +} + +/** + * What one sync between two environments ships, as a release: the deployed workflows it + * changes, each opening the side-by-side comparison. The wizard footer owns Sync. + */ +export function SyncReview({ direction, controller }: SyncReviewProps) { + const changes = controller.workflowChanges + const changedCount = changes.length + /** Workflows marked not synced on either side; the sync leaves them alone. */ + const skipped = [...controller.excludedSourceWorkflows, ...controller.excludedTargetWorkflows] + + return ( +
+
+
+ + {controller.isError + ? 'Comparison unavailable' + : controller.isLoading || !controller.hasDiff + ? 'Comparing environments…' + : changedCount === 0 + ? 'Already in sync' + : `${changedCount} ${changedCount === 1 ? 'change' : 'changes'} to review`} + + {!controller.isLoading && + !controller.isError && + controller.hasDiff && + changedCount > 0 ? ( + + Syncing overwrites {controller.targetWorkspaceName} + + ) : null} +
+ {controller.isError ? ( +

+ {controller.errorMessage ?? "Couldn't compare these environments."} +

+ ) : controller.isLoading || !controller.hasDiff ? ( +
+ + +
+ ) : changes.length + skipped.length === 0 ? ( +

+ No deployed workflows to sync. +

+ ) : ( +
    + {changes.map((change, index) => { + const comparable = + change.action !== 'archive' && + change.comparison.status === 'available' && + controller.comparisonReady && + !controller.diffIsStale + ? change + : null + const unavailable = + change.action !== 'archive' && + controller.comparisonReady && + change.comparison.status === 'unavailable' + ? COMPARISON_UNAVAILABLE[change.comparison.reason] + : undefined + const renamed = change.currentName !== change.otherName + const row = ( + <> + + {ACTION_LABEL[change.action]} + + + {change.currentName} + + {renamed ? ( + <> + + + {change.otherName} + + + ) : null} + {comparable ? ( + + + View changes + + ) : unavailable ? ( + + {unavailable} + + ) : null} + + ) + return ( +
  • + {comparable ? ( + + ) : ( +
    {row}
    + )} +
  • + ) + })} + {skipped.map((name, index) => ( +
  • + {name} + + Not synced + +
  • + ))} +
+ )} +
+ + + + {controller.comparisonSelection ? ( + + + + ) : null} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx new file mode 100644 index 00000000000..ff3f89ed666 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Chip, toast } from '@sim/emcn' +import { DashboardResource } from '@/components/dashboards/dashboard-resource' +import { EmptyState } from '@/components/empty-state/empty-state' +import { sendMothershipMessage } from '@/lib/mothership/events' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectDashboardProps { + project: Project +} + +export function ProjectDashboard({ project }: ProjectDashboardProps) { + const { canBuild, mothershipAvailable } = useOrganizationContext() + const createDashboard = () => { + const message = [ + `Help me create the first dashboard for project ${JSON.stringify(project.name)} (project ID: ${project.projectId}).`, + `I'm viewing environment ${JSON.stringify(project.environment)} (workspace ID: ${project.id}), which has no dashboard.`, + `Project environments: ${JSON.stringify(project.environments)}.`, + 'First ask me what I want to see in the dashboard: goals, metrics, data sources, and layout. Do not create dashboards until we agree on what to include.', + 'Then check the dashboard in every accessible environment of this project and create one for each environment that does not already have one, using that environment’s own tables and data. Preserve existing dashboards. Tell me if an environment needs data or permissions before it can be set up.', + ].join('\n\n') + if (!sendMothershipMessage(message, undefined, undefined, undefined, 'agent')) { + toast.error('Could not open dashboard setup in chat. Please try again.') + } + } + return ( + Create dashboard + ) : undefined + } + /> + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx b/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx new file mode 100644 index 00000000000..9a5e0d51173 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { Chip, ChipInput, Label, toast } from '@sim/emcn' +import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useRenameProject } from '@/hooks/queries/projects' +import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' +import { useUserPermissions } from '@/hooks/use-user-permissions' + +interface ProjectSettingsProps { + project: Project +} + +/** Project settings use the project API; environment names remain independent. */ +export function ProjectGeneralSettings({ project }: ProjectSettingsProps) { + const [draft, setDraft] = useState(null) + const rename = useRenameProject() + const permissions = useWorkspacePermissionsQuery(project.rootId) + const { canAdmin, isLoading } = useUserPermissions( + permissions.data ?? null, + permissions.isPending + ) + const name = draft ?? project.name + const changed = name.trim() !== project.name + useSettingsUnsavedGuard({ isDirty: changed, navigationBlocked: rename.isPending }) + return ( +
+

Project settings

+
{ + event.preventDefault() + if (!canAdmin || !changed || !name.trim() || rename.isPending) return + rename.mutate( + { projectId: project.projectId, name: name.trim() }, + { + onSuccess: () => { + setDraft(null) + toast.success('Project renamed') + }, + } + ) + }} + > + + setDraft(event.target.value)} + maxLength={100} + disabled={isLoading || !canAdmin || rename.isPending} + /> + {!isLoading && !canAdmin && ( +

+ A project administrator can change this name. +

+ )} + {rename.error && ( +

+ {rename.error.message} +

+ )} + {canAdmin && ( +
+ + {rename.isPending ? 'Saving…' : 'Save'} + + {changed && ( + { + setDraft(null) + rename.reset() + }} + > + Discard + + )} +
+ )} + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx b/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx new file mode 100644 index 00000000000..a9d3e17f1e2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx @@ -0,0 +1,398 @@ +'use client' + +import { lazy, Suspense, useCallback, useState } from 'react' +import { Skeleton, toast } from '@sim/emcn' +import { + Database, + Files as FilesIcon, + Integration, + Library, + ShieldCheck, + Table, + Workflow, +} from '@sim/emcn/icons' +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { INTEGRATIONS } from '@/lib/integrations' +import { sendMothershipMessage } from '@/lib/mothership/events' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + Resource, + type ResourceRow, +} from '@/app/workspace/[workspaceId]/components/resource/resource' +import { + ResourceNavigationProvider, + runResourceFromHref, +} from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { + PermissionAccessBoundary, + useWorkspaceAccessRequestFeatures, +} from '@/ee/access-requests/components/permission-access-boundary' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +const Workflows = lazy(() => + import('@/app/workspace/[workspaceId]/w/components/workflows-list/workflows-list').then((m) => ({ + default: m.WorkflowsList, + })) +) +const Files = lazy(() => + import('@/app/workspace/[workspaceId]/files/files').then((m) => ({ default: m.Files })) +) +const Tables = lazy(() => + import('@/app/workspace/[workspaceId]/tables/tables').then((m) => ({ default: m.Tables })) +) +const Knowledge = lazy(() => + import('@/app/workspace/[workspaceId]/knowledge/knowledge').then((m) => ({ + default: m.Knowledge, + })) +) +const Logs = lazy(() => import('@/app/workspace/[workspaceId]/logs/logs')) +const KnowledgeDocument = lazy(() => + import('@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document').then((m) => ({ + default: m.Document, + })) +) +const Tests = lazy(() => + import('@/app/workspace/[workspaceId]/tests/tests').then((m) => ({ default: m.Tests })) +) +const Integrations = lazy(() => + import('@/app/workspace/[workspaceId]/integrations/integrations').then((m) => ({ + default: m.Integrations, + })) +) +const IntegrationDetail = lazy(() => + import('@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail').then( + (m) => ({ default: m.IntegrationBlockDetail }) + ) +) +const Skills = lazy(() => + import('@/app/workspace/[workspaceId]/skills/skills').then((m) => ({ default: m.Skills })) +) +const SkillCreate = lazy(() => + import('@/app/workspace/[workspaceId]/skills/new/skill-create').then((m) => ({ + default: m.SkillCreate, + })) +) +const SkillDetail = lazy(() => + import('@/app/workspace/[workspaceId]/skills/[skillId]/skill-detail').then((m) => ({ + default: m.SkillDetail, + })) +) +const CredentialDetail = lazy(() => + import( + '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' + ).then((m) => ({ default: m.ConnectedCredentialDetail })) +) + +const KINDS = [ + { + id: 'workflows', + path: 'w', + type: 'workflow', + label: 'Workflows', + icon: Workflow, + component: Workflows, + }, + { + id: 'files', + path: 'files', + type: 'file', + label: 'Files', + icon: FilesIcon, + component: Files, + permission: 'hideFilesTab', + }, + { + id: 'tables', + path: 'tables', + type: 'table', + label: 'Tables', + icon: Table, + component: Tables, + permission: 'hideTablesTab', + }, + { + id: 'knowledge', + path: 'knowledge', + type: 'knowledgebase', + label: 'Knowledge', + icon: Database, + component: Knowledge, + permission: 'hideKnowledgeBaseTab', + }, + { id: 'logs', path: 'logs', type: 'log', label: 'Logs', icon: Library, component: Logs }, + { id: 'tests', path: 'tests', type: 'test', label: 'Tests', icon: ShieldCheck, component: Tests }, + { + id: 'integrations', + path: 'integrations', + type: 'integration', + label: 'Integrations', + icon: Integration, + component: Integrations, + permission: 'hideIntegrationsTab', + }, +] as const +const EXCLUDED: readonly MothershipResourceType[] = [ + 'dashboard', + 'sources', + 'search', + 'folder', + 'filefolder', + 'task', + 'integration', + 'generic', + 'browser', + 'terminal', +] +const COLUMNS = [ + { id: 'name', header: 'Name', widthMultiplier: 2 }, + { id: 'detail', header: 'Kind' }, +] +const KIND_COLUMNS = [ + { id: 'name', header: 'Name', widthMultiplier: 2 }, + { id: 'detail', header: 'Items' }, +] + +interface ProjectResourcesProps { + project: Project +} + +/** Production lists retain their actions while navigation stays beside the chat. */ +export function ProjectResources({ project }: ProjectResourcesProps) { + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const [, setListLocation] = useQueryStates({ folderId: parseAsString, search: parseAsString }) + const [, setConnect] = useQueryState(connectParam.key, connectParam.parser) + const { addResource } = useMothershipResources() + const { config } = usePermissionConfig() + const [search, setSearch] = useState('') + const { data: credentials } = useWorkspaceCredentials({ workspaceId: project.id }) + const inventory = useAvailableResources(project.id, { excludeTypes: EXCLUDED }) + const testsEnabled = useFeatureFlag('workflow-tests') + const accessRequests = useWorkspaceAccessRequestFeatures() + const accessRequestsEnabled = accessRequests.data?.enabled === true + /** A hidden kind stays listed where its page offers an access request, as in the rail. */ + const kinds = KINDS.filter( + (kind) => + (!('permission' in kind) || !config[kind.permission] || accessRequestsEnabled) && + (kind.id !== 'tests' || testsEnabled) + ) + const selected = kinds.find((kind) => kind.id === params.resourceKind) + const select = useCallback( + (kind: string) => { + void setListLocation({ folderId: null, search: null }) + void setParams({ resourceKind: kind, resourceDetail: '' }) + }, + [setListLocation, setParams] + ) + const navigate = useCallback( + (href: string, replace = false) => { + const url = new URL(href, window.location.origin) + const [, scope, workspaceId, path, ...rest] = url.pathname.split('/') + if ( + url.origin !== window.location.origin || + scope !== 'workspace' || + workspaceId !== project.id + ) { + toast.error('This destination is outside the selected environment.') + return + } + if (path === 'home') { + const prompt = LandingPromptStorage.consume() + if (prompt) sendMothershipMessage(prompt, undefined, undefined, undefined, 'agent') + return + } + if (path === 'skills') { + /** Skills sit under Integrations, as its second tab. */ + void setParams( + { resourceKind: 'integrations', resourceDetail: ['skills', ...rest].join('/') }, + { history: replace ? 'replace' : 'push' } + ) + return + } + const kind = KINDS.find((candidate) => candidate.path === path) + const run = kind?.id === 'logs' ? runResourceFromHref(url, project.id) : null + if (run) { + addResource(run) + void setParams({ pane: null }) + return + } + if (!kind) { + void setParams({ resourceKind: '', resourceDetail: '' }) + return + } + if (rest[0] && kind.type !== 'integration' && !(kind.id === 'knowledge' && rest[1])) { + const item = inventory.groups + .find((group) => group.type === kind.type) + ?.items.find((item) => item.id === rest[0]) + addResource( + resourceFromItem(kind.type, { + ...item, + id: rest[0], + name: item?.name ?? kind.label, + workspaceId: project.id, + }) + ) + /** Reveal the tab even when it is already the active resource behind the project view. */ + void setParams({ pane: null, ...(kind.id === 'knowledge' ? { resourceDetail: '' } : {}) }) + return + } + void setParams( + { resourceKind: kind.id, resourceDetail: rest.join('/') }, + { history: replace ? 'replace' : 'push' } + ) + if (kind.id === 'integrations') void setConnect(url.searchParams.get(connectParam.key)) + }, + [project.id, inventory.groups, addResource, setParams, setConnect] + ) + const needle = search.trim().toLowerCase() + const matches = inventory.groups.flatMap((group) => { + const kind = kinds.find((kind) => kind.type === group.type) + if (!kind) return [] + return group.items + .filter((item) => item.name.toLowerCase().includes(needle)) + .map((item) => ({ + kind, + resource: resourceFromItem(group.type, { ...item, workspaceId: project.id }), + })) + }) + const rows: ResourceRow[] = needle + ? matches.map(({ kind, resource }, index) => ({ + id: String(index), + cells: { + name: { icon: , label: resource.title }, + detail: { label: kind.label }, + }, + })) + : kinds.map((kind) => ({ + id: kind.id, + cells: { + name: { icon: , label: kind.label }, + detail: { + label: + kind.id === 'integrations' + ? String( + credentials?.filter( + (credential) => + credential.type === 'oauth' || + credential.type === 'service_account' || + credential.type === 'personal_token' + ).length ?? '—' + ) + : inventory.isHydrating + ? '—' + : String( + inventory.groups.find((group) => group.type === kind.type)?.items.length ?? 0 + ) + + (kind.id === 'logs' && + (inventory.groups.find((group) => group.type === kind.type)?.items.length ?? + 0) >= 50 + ? '+' + : ''), + }, + }, + })) + if (!selected) + return ( + + + {needle && inventory.isHydrating ? ( + + ) : needle && !rows.length ? ( + + ) : ( + (needle ? addResource(matches[Number(id)].resource) : select(id))} + /> + )} + + ) + const Component = selected.component + const integration = INTEGRATIONS.find((item) => item.slug === params.resourceDetail) + const [knowledgeBaseId, documentId] = + selected.id === 'knowledge' ? params.resourceDetail.split('/') : [] + return ( + select('')} + > + {/* Managed: links stay in the pane, behind the same unsaved-changes prompt as elsewhere. */} +
{ + const anchor = (event.target as HTMLElement).closest('a') + const href = anchor?.getAttribute('href') + if (href?.startsWith(`/workspace/${project.id}/`)) { + event.preventDefault() + event.stopPropagation() + useSettingsDirtyStore.getState().requestLeave(() => navigate(href)) + } + }} + > + {selected.id === 'integrations' && ( + select('') }, + { label: 'Integrations', onClick: () => select('integrations') }, + ]} + /> + )} +
+ }> + {selected.id === 'integrations' && params.resourceDetail ? ( + + {params.resourceDetail === 'skills' ? ( + + ) : params.resourceDetail === 'skills/new' ? ( + + ) : params.resourceDetail.startsWith('skills/') ? ( + + ) : params.resourceDetail.startsWith('connected/') ? ( + + ) : integration ? ( + + ) : ( +

Integration unavailable.

+ )} +
+ ) : knowledgeBaseId && documentId ? ( + + + + ) : 'permission' in selected ? ( + + + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-settings.tsx b/apps/sim/app/o/[organizationId]/p/components/project-settings.tsx new file mode 100644 index 00000000000..9995030dd51 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-settings.tsx @@ -0,0 +1,199 @@ +import { Suspense } from 'react' +import { Chip, ChipConfirmModal, ChipDropdown, Skeleton } from '@sim/emcn' +import { Settings } from '@sim/emcn/icons' +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { + getSettingsPermissionConfigKey, + type SettingsNavigationItem, + toSettingsHeaderMeta, + WORKSPACE_SETTINGS_ITEMS, + type WorkspaceSettingsSection, +} from '@/components/settings/navigation' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { ProjectGeneralSettings } from '@/app/o/[organizationId]/p/components/project-general-settings' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { SettingsPage } from '@/app/workspace/[workspaceId]/settings/[section]/settings' +import type { DeletedResource } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted' +import { SecretDetail } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { useWorkspaceSettingsNavigation } from '@/hooks/queries/workspace-settings' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +interface ProjectSettingsProps { + project: Project + onBrowse?: () => void +} + +/** Existing environment settings, gated by the same server policy as their routed pages. */ +export function ProjectSettings({ project, onBrowse }: ProjectSettingsProps) { + const [section, setSection] = useQueryState( + 'setting', + parseAsString.withDefault('general').withOptions({ history: 'push' }) + ) + const [credentialId] = useQueryState('credential', parseAsString) + const [, clearDetail] = useQueryStates({ + search: parseAsString, + mcpServerId: parseAsString, + 'server-id': parseAsString, + 'custom-tool-id': parseAsString, + 'custom-block-id': parseAsString, + sandboxId: parseAsString, + credential: parseAsString, + 'secret-view': parseAsString, + 'usage-tab': parseAsString, + }) + const { addResource } = useMothershipResources() + const openResource = (resource: DeletedResource) => { + const type = + resource.type === 'knowledge' + ? 'knowledgebase' + : resource.type === 'workspace_folder' + ? 'filefolder' + : resource.type + if (type === 'knowledge_folder' || type === 'table_folder' || type === 'chat') { + onBrowse?.() + return + } + addResource({ id: resource.id, title: resource.name, workspaceId: resource.workspaceId, type }) + } + const access = useWorkspaceSettingsNavigation(project.id) + const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) + const pendingLeave = useSettingsDirtyStore((state) => state.pendingLeave) + const confirmLeave = useSettingsDirtyStore((state) => state.confirmLeave) + const cancelLeave = useSettingsDirtyStore((state) => state.cancelLeave) + const items = WORKSPACE_SETTINGS_ITEMS.filter( + (item) => + item.id !== 'self-host' && + item.id !== 'forks' && + access.data?.sections.some((entry) => entry.id === item.id) + ) + const selected = items.find((item) => item.id === section) + const entry = access.data?.sections.find((item) => item.id === section) + const unifiedSection = selected?.id === 'api-keys' ? 'apikeys' : selected?.id + const configKey = unifiedSection && getSettingsPermissionConfigKey(unifiedSection) + const navigate = (next: string) => + requestLeave(() => { + void clearDetail({ + search: null, + mcpServerId: null, + 'server-id': null, + 'custom-tool-id': null, + 'custom-block-id': null, + sandboxId: null, + credential: null, + 'secret-view': null, + 'usage-tab': null, + }) + void setSection(next) + }) + return ( +
+ +
+
+ }> + {section === 'general' ? ( + + ) : access.isPending ? ( + + ) : access.error ? ( +
+

{access.error.message}

+ void access.refetch()}>Try again +
+ ) : !selected || !unifiedSection ? ( +

+ These settings are unavailable for this environment. +

+ ) : ( + + {entry?.access === 'allowed' && selected.id === 'secrets' && credentialId ? ( + + ) : ( + + {entry?.access === 'allowed' ? ( + + ) : configKey ? ( + + ) : null} + + )} + + )} +
+
+ +
+ !open && cancelLeave()} + srTitle='Unsaved changes' + title='Unsaved changes' + text='You have unsaved changes. Are you sure you want to discard them?' + dismissLabel='Keep editing' + confirm={{ label: 'Discard changes', onClick: confirmLeave }} + /> +
+ ) +} + +interface SettingsRailProps { + section: string + items: readonly SettingsNavigationItem[] + onSelect: (section: string) => void +} + +/** Section navigation on the pane's right edge; the dropdown above replaces it when the pane is narrow. */ +function SettingsRail({ section, items, onSelect }: SettingsRailProps) { + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-view.tsx b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx new file mode 100644 index 00000000000..4590490eb3c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx @@ -0,0 +1,137 @@ +'use client' + +import { ChipLink, cn } from '@sim/emcn' +import { organizationRoutes } from '@/lib/navigation/paths' +import { EnvironmentSwitcher } from '@/app/o/[organizationId]/p/components/environment-switcher' +import { EnvironmentsTab } from '@/app/o/[organizationId]/p/components/environments' +import { ProjectDashboard } from '@/app/o/[organizationId]/p/components/project-dashboard' +import { ProjectResources } from '@/app/o/[organizationId]/p/components/project-resources' +import { ProjectSettings } from '@/app/o/[organizationId]/p/components/project-settings' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { PROJECT_SECTIONS, type ProjectSection } from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { ResourceWorkspaceHost } from '@/app/workspace/[workspaceId]/home/components/resource-workspace-host' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' + +interface ProjectViewProps { + project: Project + section: ProjectSection + onBrowse?: () => void + onSectionChange?: (section: ProjectSection) => void + onEnvironmentChange?: (workspaceId: string) => void +} + +/** The header stays mounted across environments; only the body loads the selected workspace. */ +export function ProjectView({ + project, + section, + onBrowse, + onSectionChange, + onEnvironmentChange, +}: ProjectViewProps) { + const { organization } = useOrganizationContext() + const routes = organizationRoutes(organization.id) + const dashboardsEnabled = useFeatureFlag('dashboards') + const changelogEnabled = useFeatureFlag('changelog') + const sections = PROJECT_SECTIONS.filter( + (item) => + (item.id !== 'dashboard' || dashboardsEnabled) && + (item.id !== 'changelog' || changelogEnabled) + ) + /** A section whose flag is off, such as the default Dashboard, shows the first one available. */ + const activeSection = sections.some((item) => item.id === section) ? section : sections[0].id + return ( +
+
+
+
+

{project.name}

+
+
+ +
+
+ +
+
+ + + +
+
+ ) +} + +interface SectionBodyProps { + onBrowse?: () => void + project: Project + section: ProjectSection +} + +function SectionBody({ project, section, onBrowse }: SectionBodyProps) { + switch (section) { + case 'resources': + return + case 'settings': + return + case 'dashboard': + return ( +
+ + + +
+ ) + case 'changelog': + return ( +
+ +
+ ) + case 'issues': + return No issues yet. + case 'environments': + return + } +} + +/** Issues until its entity exists. */ +function EmptySection({ children }: { children: string }) { + return

{children}

+} diff --git a/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts new file mode 100644 index 00000000000..75e169512bc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts @@ -0,0 +1,92 @@ +import { useMemo } from 'react' +import type { ProjectRecord } from '@/lib/api/contracts/projects' +import { useOrganizationProjects, useWorkspaceProject } from '@/hooks/queries/projects' + +/** One workspace of a project, labelled by its workspace name. */ +export interface ProjectEnvironment { + workspaceId: string + label: string + /** The environment this one was forked from; null for a root of the project. */ + parentId: string | null +} + +/** A workspace seen as a project: the project it belongs to, with this workspace as one environment. */ +export interface Project { + /** The workspace this view is on; every route and hook uses it. */ + id: string + /** The project's own id, used for project settings. */ + projectId: string + name: string + organizationId: string | null + /** The project's first environment; lists show one project per root. */ + rootId: string + /** This workspace's environment, labelled by its name. */ + environment: string + /** Every environment of the project the viewer can access, in the API's fork order. */ + environments: ProjectEnvironment[] + /** Organization admin or admin of every environment: may rename and archive the project. */ + canAdminister: boolean +} + +function toEnvironments(project: ProjectRecord): ProjectEnvironment[] { + return project.environments.map((environment) => ({ + workspaceId: environment.id, + label: environment.name, + parentId: environment.forkedFromWorkspaceId, + })) +} + +/** Each accessible workspace of each project, as the view it opens on. */ +function toProjectViews(projects: readonly ProjectRecord[]): Project[] { + return projects.flatMap((project) => { + const environments = toEnvironments(project) + const rootId = environments[0]?.workspaceId + if (!rootId) return [] + return environments.map((environment) => ({ + id: environment.workspaceId, + projectId: project.id, + name: project.name, + organizationId: project.organizationId, + rootId, + environment: environment.label, + environments, + canAdminister: project.capabilities.administer, + })) + }) +} + +/** The organization's projects, from the projects the viewer can access there. */ +export function useProjects(organizationId: string) { + const query = useOrganizationProjects(organizationId) + const projects = useMemo(() => toProjectViews(query.data ?? []), [query.data]) + /** One entry per project for the sidebar and pickers: each project's root. */ + const roots = projects.filter((project) => project.rootId === project.id) + const rootById = new Map(roots.map((root) => [root.id, root])) + /** The project each workspace belongs to, by workspace id. */ + const projectByWorkspace = new Map() + for (const project of projects) { + const root = rootById.get(project.rootId) + if (root) projectByWorkspace.set(project.id, root) + } + /** Each project's root view, by project id, for things that record projects rather than workspaces. */ + const projectById = new Map(roots.map((root) => [root.projectId, root])) + return { + projects, + roots, + projectByWorkspace, + projectById, + isPending: query.isPending, + error: query.error, + } +} + +/** The project a workspace belongs to, seen from that workspace, without loading the organization's list. */ +export function useWorkspaceProjectView(workspaceId: string) { + const query = useWorkspaceProject(workspaceId) + const project = useMemo( + () => + query.data ? toProjectViews([query.data]).find((view) => view.id === workspaceId) : undefined, + [query.data, workspaceId] + ) + return { project, isPending: query.isPending, error: query.error } +} diff --git a/apps/sim/app/o/[organizationId]/p/routes.ts b/apps/sim/app/o/[organizationId]/p/routes.ts new file mode 100644 index 00000000000..53b69a523c3 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/routes.ts @@ -0,0 +1,17 @@ +import { Branch, Folder, Layout, ListChecks, Rss, Settings } from '@sim/emcn/icons' + +/** The project main view's sections, in the order its tabs read. */ +export const PROJECT_SECTIONS = [ + { id: 'dashboard', label: 'Dashboard', icon: Layout }, + { id: 'changelog', label: 'Changelog', icon: Rss }, + { id: 'issues', label: 'Issues', icon: ListChecks }, + { id: 'resources', label: 'Resources', icon: Folder }, + { id: 'environments', label: 'Environments', icon: Branch }, + { id: 'settings', label: 'Settings', icon: Settings }, +] as const + +export type ProjectSection = (typeof PROJECT_SECTIONS)[number]['id'] + +export function isProjectSection(value: string): value is ProjectSection { + return PROJECT_SECTIONS.some((section) => section.id === value) +} diff --git a/apps/sim/app/o/[organizationId]/p/search-params.ts b/apps/sim/app/o/[organizationId]/p/search-params.ts new file mode 100644 index 00000000000..1ae17ba92bf --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/search-params.ts @@ -0,0 +1,11 @@ +import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { RESOURCE_TAB_IDS } from '@/app/o/[organizationId]/p/components/environments/mapping-model' + +export const projectParsers = { + /** Dashboard shown on the project page: a dashboard file id, or `runs`. */ + dashboard: parseAsString.withDefault(''), + /** Narrows a sync tab's mappings table by resource name. */ + mappingSearch: parseAsString.withDefault(''), + /** Resource types a sync tab's mappings table shows; empty shows every type. */ + resource: parseAsArrayOf(parseAsStringLiteral(RESOURCE_TAB_IDS)).withDefault([]), +} diff --git a/apps/sim/app/o/[organizationId]/search/page.tsx b/apps/sim/app/o/[organizationId]/search/page.tsx index d9702b1b53d..1dc6dd53ace 100644 --- a/apps/sim/app/o/[organizationId]/search/page.tsx +++ b/apps/sim/app/o/[organizationId]/search/page.tsx @@ -1,16 +1,19 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' export const metadata: Metadata = { title: 'Search' } export default async function OrganizationSearchPage({ params, + searchParams, }: { params: Promise<{ organizationId: string }> + searchParams: Promise> }) { const { organizationId } = await params const session = await getSession() @@ -18,5 +21,14 @@ export default async function OrganizationSearchPage({ const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + /** With the org project view, search is a mode of the Home composer rather than its own page. */ + if (await isOrgProjectViewEnabled(organizationId)) { + const query = new URLSearchParams({ mode: 'search' }) + for (const [key, value] of Object.entries(await searchParams)) { + if (['q', 'source', 'updated', 'from', 'to'].includes(key) && typeof value === 'string') + query.set(key, value) + } + redirect(`${organizationRoutes(organizationId).home}?${query}`) + } return } diff --git a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx index 2376c6f2bad..c4325bcddb5 100644 --- a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx +++ b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx @@ -19,6 +19,7 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' interface SearchResultsViewProps { + embedded?: boolean composer: ReactNode query: string onSummarize: (message: string, filters: WorkspaceSearchFilters) => void @@ -31,6 +32,7 @@ export function SearchResultsView({ query, onSummarize, onSearchChange, + embedded = false, }: SearchResultsViewProps) { const { organization } = useOrganizationContext() const scope = { kind: 'organization' as const, organizationId: organization.id } @@ -45,9 +47,11 @@ export function SearchResultsView({
{searching ? ( <> -
-
-
+ {!embedded && ( +
+
+
+ )}
{composer}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts index 89a5ec32257..ee5c25feed4 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts @@ -5,8 +5,8 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { filterUndefined } from '@sim/utils/object' -import { useRouter } from 'next/navigation' import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import { useResourceRouter } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -132,7 +132,7 @@ export function useCredentialDetailForm({ const isSectionDirty = section?.isDirty ?? false const isDirty = isMetadataDirty || isSectionDirty - const router = useRouter() + const router = useResourceRouter() const discard = useCallback(() => { if (isSaving) return setDraft(null) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx index 34ab496419e..caf6590fd56 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row.tsx @@ -49,6 +49,7 @@ export function folderRow(folder: WorkflowFolder, options: FolderRowOptions = {} icon: FOLDER_ICON, label: folder.name, pinned, + locked: folder.locked, }, }, } diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts new file mode 100644 index 00000000000..b2dc6bfc9e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts @@ -0,0 +1,89 @@ +import type { ComponentType } from 'react' +import { + Database, + Files, + Integration, + Layout, + Library, + ListChecks, + Rss, + Server, + Settings, + Table, + Workflow, +} from '@sim/emcn/icons' +import { organizationRoutes } from '@/lib/navigation/paths' + +export type ProjectSection = 'dashboard' | 'changelog' | 'issues' | 'environments' + +/** + * Href of one section of the project main view, which lives on the organization surface; a + * workspace outside any organization has no project view, so there is none. + */ +export function projectHomeHref( + organizationId: string | null, + workspaceId: string, + section: ProjectSection = 'dashboard' +): string | null { + return organizationId ? organizationRoutes(organizationId).project(workspaceId, section) : null +} + +interface ProjectNavSection { + id: ProjectSection + label: string + icon: ComponentType<{ className?: string }> +} + +/** The project main view's sections, top-down, as the main view's own chips read them. */ +export const PROJECT_NAV_SECTIONS: readonly ProjectNavSection[] = [ + { id: 'dashboard', label: 'Dashboard', icon: Layout }, + { id: 'changelog', label: 'Changelog', icon: Rss }, + { id: 'issues', label: 'Issues', icon: ListChecks }, + { id: 'environments', label: 'Environments', icon: Server }, +] + +type BuildSectionId = + | 'integrations' + | 'workflows' + | 'files' + | 'logs' + | 'tables' + | 'knowledge' + | 'settings' + +interface BuildNavSection { + id: BuildSectionId + label: string + icon: ComponentType<{ className?: string }> + /** Path under `/workspace//` the section opens. */ + segment: string + /** Sibling path prefixes under `/workspace//` that keep the section active. */ + activeSegments?: readonly string[] +} + +/** + * The Build sections in the order the project view lists them, which is also the order the + * main view's Build tables and the playground's `WORKSPACE_SECTIONS` read. One constant so + * every listing of these sections follows it. + */ +export const BUILD_NAV_SECTIONS: readonly BuildNavSection[] = [ + { + id: 'integrations', + label: 'Integrations', + icon: Integration, + segment: 'integrations', + activeSegments: ['skills'], + }, + { id: 'workflows', label: 'Workflows', icon: Workflow, segment: 'w' }, + { id: 'files', label: 'Files', icon: Files, segment: 'files' }, + { id: 'logs', label: 'Logs', icon: Library, segment: 'logs' }, + { id: 'tables', label: 'Tables', icon: Table, segment: 'tables' }, + { id: 'knowledge', label: 'Knowledge', icon: Database, segment: 'knowledge' }, + { + id: 'settings', + label: 'Settings', + icon: Settings, + segment: 'settings/general', + activeSegments: ['settings'], + }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts new file mode 100644 index 00000000000..06f2cab9d8a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts @@ -0,0 +1 @@ +export { ProjectBuildSidebar } from '@/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx new file mode 100644 index 00000000000..a1389902e7e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx @@ -0,0 +1,638 @@ +'use client' + +import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { + Chip, + ChipLink, + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { + Branch, + Building, + Check, + ChevronDown, + ChevronLeft, + PanelLeft, + Pin, + Plus, + Search, + SquarePen, +} from '@sim/emcn/icons' +import { useParams, usePathname, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' +import { isMacPlatform } from '@/lib/core/utils/platform' +import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { captureEvent } from '@/lib/posthog/client' +import { useWorkspaceProjectView } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + BUILD_NAV_SECTIONS, + PROJECT_NAV_SECTIONS, + projectHomeHref, +} from '@/app/workspace/[workspaceId]/components/project-build-sidebar/constants' +import { useProjectChatMenu } from '@/app/workspace/[workspaceId]/components/project-build-sidebar/use-project-chat-menu' +import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { + allNavigationItems, + type SettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' +import { + ChatNavigationLink, + isNavItemActive, + SettingsSidebar, + SidebarFooter, + SidebarNavChip, + type SidebarNavItemData, + SidebarSection, + SidebarTooltip, + StatusNotice, + WorkspaceSidebarModals, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_RAIL_CHIP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { + type SidebarChat, + useWorkspaceSidebarServices, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/use-workspace-sidebar-services' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' +import { useUserProfile } from '@/hooks/queries/user-profile' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useSearchModalStore } from '@/stores/modals/search/store' +import { useSidebarStore } from '@/stores/sidebar/store' + +/** How many chats show before "See more", matching the workspace sidebar. */ +const CHAT_PREVIEW_COUNT = 5 + +/** + * Opts a control out of the desktop shell's window-drag region. The header row is + * draggable chrome, so anything clickable inside it has to say so or the click is + * swallowed by the drag handler. + */ +const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' + +interface ProjectBuildSidebarProps { + organizationHref: string | null +} + +/** + * The workspace rail while the org project view is on: the project's Build view. + * + * Same chrome as the workspace `Sidebar` (collapse, peek, resize, footer, settings + * sub-navigation) and the same invisible services underneath it through + * {@link useWorkspaceSidebarServices}, but the rail lists the project instead of the + * workspace: a back chip to the project main view, the environment switcher, the main view's + * sections, the flat Build sections, and the chats. No + * resource trees: every Build page owns its own folder tree in the content area. + */ +export const ProjectBuildSidebar = memo(function ProjectBuildSidebar({ + organizationHref, +}: ProjectBuildSidebarProps) { + const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome() + const isCollapsed = isCollapsedProp && !isPeeking + const params = useParams() + const workspaceId = params.workspaceId as string + const workflowId = params.workflowId as string | undefined + const router = useRouter() + const pathname = usePathname() + + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + + const posthog = usePostHog() + const { data: profile } = useUserProfile() + const hostContext = useWorkspaceHostContext() + const { hosted } = useDeploymentShape() + const { config: permissionConfig } = usePermissionConfig() + const accessRequests = useWorkspaceAccessRequestFeatures() + const accessRequestsEnabled = accessRequests.data?.enabled === true + const { getSettingsHref, navigateToSettings } = useSettingsNavigation() + const openSearchModal = useSearchModalStore((state) => state.open) + const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) + const { data: workspaces } = useWorkspacesQuery() + + const services = useWorkspaceSidebarServices({ workspaceId, workflowId }) + const { chats, chatsLoading, chatsEnabled } = services + const chatMenu = useProjectChatMenu({ workspaceId, chats }) + + const { handlePointerDown } = useSidebarResize() + const [visibleChatCount, setVisibleChatCount] = useState(CHAT_PREVIEW_COUNT) + + const scrollEdges = useScrollEdges(scrollContainerRef, { + contentRef: scrollContentRef, + enabled: !isCollapsed, + }) + + const isMac = isMacPlatform() + const showCollapsedTooltips = isCollapsed + const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false + + const { project: scope, error: projectError } = useWorkspaceProjectView(workspaceId) + /** + * The project this workspace belongs to. Until it loads, or when it cannot, the header names + * the workspace itself as a one-environment project; a load failure is shown under the chip. + */ + const project = useMemo(() => { + if (scope) { + const home = workspaces?.find((workspace) => workspace.id === scope.rootId) + return { + name: scope.name, + environment: scope.environment, + environments: scope.environments, + logoUrl: home?.logoUrl ?? null, + } + } + const { name } = hostContext.workspace + return { + name, + environment: name, + environments: [{ workspaceId, label: name, parentId: null }], + logoUrl: null, + } + }, [scope, workspaces, workspaceId, hostContext.workspace]) + + /** The page the viewer is on, relative to the workspace, so a switch lands on its peer. */ + const workspaceSubPath = useMemo(() => { + const base = `/workspace/${workspaceId}` + return pathname?.startsWith(base) ? pathname.slice(base.length) : '' + }, [pathname, workspaceId]) + + const organizationId = hostContext.hostOrganizationId ?? null + const projectHome = projectHomeHref(organizationId, workspaceId) + const environmentsHref = projectHomeHref(organizationId, workspaceId, 'environments') + const projectNavItems = useMemo( + (): SidebarNavItemData[] => + PROJECT_NAV_SECTIONS.flatMap((section) => { + const href = projectHomeHref(organizationId, workspaceId, section.id) + return href ? [{ id: section.id, label: section.label, icon: section.icon, href }] : [] + }), + [organizationId, workspaceId] + ) + + const buildNavItems = useMemo(() => { + const base = `/workspace/${workspaceId}` + const hiddenBySection: Partial> = { + integrations: permissionConfig.hideIntegrationsTab, + files: permissionConfig.hideFilesTab, + tables: permissionConfig.hideTablesTab, + knowledge: permissionConfig.hideKnowledgeBaseTab, + } + return BUILD_NAV_SECTIONS.map((section): SidebarNavItemData & { hidden: boolean } => { + const restricted = hiddenBySection[section.id] === true + return { + id: section.id, + label: section.label, + icon: section.icon, + href: `${base}/${section.segment}`, + additionalActivePaths: section.activeSegments?.map((segment) => `${base}/${segment}`), + restricted, + hidden: restricted && !accessRequestsEnabled, + } + }).filter((item) => !item.hidden) + }, [ + workspaceId, + permissionConfig.hideIntegrationsTab, + permissionConfig.hideFilesTab, + permissionConfig.hideTablesTab, + permissionConfig.hideKnowledgeBaseTab, + accessRequestsEnabled, + ]) + + const newChatItem = useMemo( + (): SidebarNavItemData => ({ + id: 'new-chat', + label: 'New chat', + icon: SquarePen, + href: `/workspace/${workspaceId}/home`, + restricted: permissionConfig.hideCopilot, + }), + [workspaceId, permissionConfig.hideCopilot] + ) + + const handleOpenSettings = (section: SettingsSection) => { + navigateToSettings({ section }) + } + + const profileNavigationLinks = allNavigationItems + .filter( + ({ id }) => + id === 'teammates' || + id === 'recently-deleted' || + (id === 'billing' && canViewWorkspaceBillingSettings(hostContext, profile?.id)) + ) + .map(({ id, label, icon }) => ({ + label, + icon, + href: getSettingsHref({ section: id }), + onNavigate: () => handleOpenSettings(id), + })) + + if (organizationHref) { + profileNavigationLinks.push({ + label: 'Organization', + icon: Building, + href: organizationHref, + onNavigate: () => router.push(organizationHref), + }) + } + + const handleSwitchEnvironment = useCallback( + (targetWorkspaceId: string) => { + if (targetWorkspaceId === workspaceId) return + router.push(`/workspace/${targetWorkspaceId}${workspaceSubPath}`) + }, + [router, workspaceId, workspaceSubPath] + ) + + const handleSeeMoreChats = useCallback( + () => setVisibleChatCount((prev) => prev + CHAT_PREVIEW_COUNT), + [] + ) + const handleSeeLessChats = useCallback(() => setVisibleChatCount(CHAT_PREVIEW_COUNT), []) + + const handleEdgeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + toggleCollapsed() + } + }, + [isCollapsed, toggleCollapsed] + ) + + const handleOpenDocs = () => { + window.open(DOCS_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) + } + + const handleOpenSlackCommunity = () => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + } + + const environmentChip = ( + + {project.environment} + + ) + + return ( + <> +
+ + + {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
+ )} +
+ + + {chatMenu.menu} + + ) +}) + +interface ProjectChatItemProps { + chat: SidebarChat + isCurrentRoute: boolean + showCollapsedTooltips: boolean + onContextMenu: (event: React.MouseEvent, chatId: string) => void +} + +/** One chat row: the same link and prefetch behavior as the workspace sidebar's chats. */ +const ProjectChatItem = memo(function ProjectChatItem({ + chat, + isCurrentRoute, + showCollapsedTooltips, + onContextMenu, +}: ProjectChatItemProps) { + const showStatusDot = chat.isActive || (!isCurrentRoute && chat.isUnread) + return ( + + onContextMenu(event, chat.id)} + > + + {showStatusDot && ( + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/use-project-chat-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/use-project-chat-menu.tsx new file mode 100644 index 00000000000..1e7e3a66a83 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/use-project-chat-menu.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { usePathname, useRouter } from 'next/navigation' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' +import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import type { SidebarChat } from '@/app/workspace/[workspaceId]/w/components/sidebar/use-workspace-sidebar-services' +import { + useDeleteMothershipChat, + useMarkMothershipChatRead, + useMarkMothershipChatUnread, + useRenameMothershipChat, + useSetMothershipChatPinned, +} from '@/hooks/queries/mothership-chats' +import { useContextMenu } from '@/hooks/use-context-menu' + +interface UseProjectChatMenuProps { + workspaceId: string + chats: SidebarChat[] +} + +/** A chat row's menu in the project rail: the workspace sidebar's single-chat actions. */ +export function useProjectChatMenu({ workspaceId, chats }: UseProjectChatMenuProps) { + const router = useRouter() + const pathname = usePathname() + const { canEdit } = useUserPermissionsContext() + const { isOpen, position, menuRef, handleContextMenu, closeMenu } = useContextMenu() + const [chatId, setChatId] = useState(null) + const [renameValue, setRenameValue] = useState(null) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const markRead = useMarkMothershipChatRead(workspaceId) + const markUnread = useMarkMothershipChatUnread(workspaceId) + const setPinned = useSetMothershipChatPinned(workspaceId) + const renameChat = useRenameMothershipChat(workspaceId) + const deleteChat = useDeleteMothershipChat(workspaceId) + const chat = chats.find((candidate) => candidate.id === chatId) + const chatHref = `/workspace/${workspaceId}/chat/${chatId}` + + const openMenu = (event: React.MouseEvent, id: string) => { + setChatId(id) + handleContextMenu(event) + } + + const submitRename = async () => { + const title = renameValue?.trim() + if (!chat || !title) return + try { + await renameChat.mutateAsync({ chatId: chat.id, title }) + setRenameValue(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to rename chat')) + } + } + + const confirmDelete = () => { + if (!chat) return + const viewingDeleted = pathname === chatHref + deleteChat.mutate(chat.id, { + onSuccess: () => { + if (viewingDeleted) router.push(`/workspace/${workspaceId}/home`) + }, + }) + setConfirmingDelete(false) + } + + const menu = ( + <> + window.open(chatHref, '_blank', 'noopener,noreferrer')} + onMarkAsRead={() => chat && markRead.mutate(chat.id)} + onMarkAsUnread={() => chat && markUnread.mutate(chat.id)} + onTogglePin={() => chat && setPinned.mutate({ chatId: chat.id, pinned: !chat.isPinned })} + onRename={() => chat && setRenameValue(chat.name)} + onDelete={() => setConfirmingDelete(true)} + showOpenInNewTab + showMarkAsRead={Boolean(chat?.isUnread)} + showMarkAsUnread={Boolean(chat) && !chat?.isUnread} + showPin={Boolean(chat)} + isPinned={Boolean(chat?.isPinned)} + showRename + showDuplicate={false} + disableRename={!canEdit} + disableDelete={!canEdit} + selectedCount={1} + /> + !open && setRenameValue(null)} + srTitle='Rename Chat' + > + setRenameValue(null)}>Rename Chat + + + + setRenameValue(null)} + cancelDisabled={renameChat.isPending} + primaryAction={{ + label: renameChat.isPending ? 'Saving...' : 'Save', + onClick: () => void submitRename(), + disabled: !renameValue?.trim() || renameChat.isPending, + }} + /> + + setConfirmingDelete(false)} + onConfirm={confirmDelete} + isDeleting={deleteChat.isPending} + itemType='task' + itemName={chat?.name} + /> + + ) + + return { openMenu, menu } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/environments-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/environments-empty-state.tsx new file mode 100644 index 00000000000..3443623e8f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/environments-empty-state.tsx @@ -0,0 +1,170 @@ +import { Chip, cn } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +/** + * Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface + * ramp. `mark` is the tables selection ring's opaque mix, so the release moving through + * the pipeline reads as the one drawn thing rather than more placeholder. + */ +const INK = { + title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', + mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))', +} as const + +/** The logs feed's falloff — each stage's deploy history is the same repeating structure. */ +const PIPELINE_FADE = + '[-webkit-mask-image:linear-gradient(to_bottom,#000_50%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_50%,transparent_100%)]' + +interface Stage { + x: number + title: number + /** Whether the newest release has reached this stage yet. */ + current: boolean + history: readonly number[] +} + +/** Dev and staging carry the newest release; production still runs the one before it. */ +const STAGES: readonly Stage[] = [ + { x: 0.5, title: 30, current: true, history: [44, 36, 50] }, + { x: 103, title: 42, current: true, history: [38, 48, 34] }, + { x: 205.5, title: 50, current: false, history: [46, 40, 52] }, +] as const + +/** The release is held at the furthest stage it has reached. */ +const HELD_INDEX = STAGES.map((stage) => stage.current).lastIndexOf(true) + +const CARD_TOP = 8 +const CARD_WIDTH = 80 +const CARD_HEIGHT = 54 +const CONNECTOR_Y = CARD_TOP + CARD_HEIGHT / 2 +const HISTORY_TOP = 80 +const HISTORY_PITCH = 20 + +interface ConnectorProps { + from: number + to: number + promoted: boolean +} + +/** A promotion between stages: solid once the release has crossed it, dashed while it waits. */ +function Connector({ from, to, promoted }: ConnectorProps) { + const x1 = from + 5 + const x2 = to - 5 + const stroke = promoted ? INK.mark : INK.title + return ( + + + + + ) +} + +interface StageCardProps { + stage: Stage + isHeld: boolean +} + +function StageCard({ stage, isHeld }: StageCardProps) { + const { x } = stage + return ( + + + + + + + {stage.history.map((width, index) => { + const y = HISTORY_TOP + index * HISTORY_PITCH + return ( + + + + + ) + })} + + ) +} + +/** + * A release pipeline: dev → staging → production, the newest release held at staging + * with its promotion to production still pending. + */ +function EnvironmentsGraphic() { + return ( + + ) +} + +interface EnvironmentsEmptyStateProps { + /** Creates an environment — the same action the header's primary chip runs. */ + onCreate: () => void + /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */ + createDisabled?: boolean +} + +/** Empty state for a workspace with no deployment environments yet. */ +export function EnvironmentsEmptyState({ onCreate, createDisabled }: EnvironmentsEmptyStateProps) { + return ( + } + title='Environments' + description='Promote each deployment from one environment to the next.' + action={ + + New environment + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index cd37a146c47..8ad3b9891b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -1,6 +1,7 @@ export { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/changelog-empty-state' export { DashboardEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/dashboard-empty-state' export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state' +export { EnvironmentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/environments-empty-state' export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-list-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-list-header.tsx new file mode 100644 index 00000000000..9b419337aa1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-list-header.tsx @@ -0,0 +1,32 @@ +'use client' + +import type { ComponentProps } from 'react' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { useResourceDirectory } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' + +/** Adds the project's directory crumb without replacing the list's folder navigation. */ +export function ResourceListHeader(props: ComponentProps) { + const onDirectory = useResourceDirectory() + if (!onDirectory) return + const drop = props.breadcrumbDrop + return ( + drop.onDragOver(event, folderId, index - 1), + onDragLeave: (event, index) => drop.onDragLeave(event, index - 1), + } + : undefined + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-navigation.tsx index 812b4230f96..a94a091869c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-navigation.tsx @@ -1,17 +1,55 @@ 'use client' -import { createContext, useCallback, useContext, useMemo } from 'react' -import { useRouter } from 'next/navigation' +import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react' +import { useParams, useRouter } from 'next/navigation' +import type { MothershipResource } from '@/lib/mothership/resources/types' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' + +/** The selected environment supplies identity when a list is embedded. */ +export function useResourceWorkspaceId() { + const host = useOptionalWorkspaceHostContext() + const params = useParams<{ workspaceId?: string }>() + return host?.workspace.id ?? params?.workspaceId ?? '' +} + +/** A Logs link addressed to one run opens that run, as the Logs page opens its details. */ +export function runResourceFromHref(url: URL, workspaceId: string): MothershipResource | null { + const executionId = url.searchParams.get('executionId') + if (!executionId) return null + return { type: 'log', id: executionId, executionId, title: 'Run', workspaceId } +} interface ResourceNavigation { + openResource: (resource: MothershipResource) => void + onDirectory: () => void /** Handles workspace destinations within a containing resource pane. */ navigate: (href: string, replace?: boolean) => void } -/** Provided by a containing resource pane; absent on the routed pages. */ const ResourceNavigationContext = createContext(null) -/** Navigates the route, or the containing resource pane when one provides navigation. */ +interface ResourceNavigationProviderProps extends ResourceNavigation { + children: ReactNode +} + +export function ResourceNavigationProvider({ + children, + navigate, + openResource, + onDirectory, +}: ResourceNavigationProviderProps) { + const value = useMemo( + () => ({ navigate, openResource, onDirectory }), + [navigate, openResource, onDirectory] + ) + return ( + + {children} + + ) +} + +/** Existing list actions use the same navigation in a route or in an embedded project. */ export function useResourceRouter() { const router = useRouter() const navigation = useContext(ResourceNavigationContext) @@ -31,3 +69,13 @@ export function useResourceRouter() { ) return useMemo(() => ({ ...router, push, replace }), [router, push, replace]) } + +/** The containing pane's directory, when a list is embedded in one. */ +export function useResourceDirectory() { + return useContext(ResourceNavigationContext)?.onDirectory +} + +/** Returns the containing pane’s resource opener when a list is embedded. */ +export function useResourceListOpen() { + return useContext(ResourceNavigationContext)?.openResource +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 3607187a16a..a5768533f84 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -25,7 +25,7 @@ import { cn, Loader, } from '@sim/emcn' -import { ChevronLeft, ChevronRight, MoreHorizontal, Pin } from '@sim/emcn/icons' +import { ChevronLeft, ChevronRight, Lock, MoreHorizontal, Pin } from '@sim/emcn/icons' import { useVirtualizer } from '@tanstack/react-virtual' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' @@ -75,6 +75,8 @@ export interface ResourceCell { * layout, and the rename field replaces the label entirely while it is open. */ pinned?: boolean + /** Marks the row as locked with a glyph after the label; honoured where `pinned` is. */ + locked?: boolean /** * Find term to tint inside the label (Cmd/Ctrl+F match). Honoured only on the * plain label cell, like `pinned` — a `content` cell owns its own rendering @@ -568,6 +570,7 @@ interface CellContentProps { content?: ReactNode editing?: ResourceCellEditing pinned?: boolean + locked?: boolean highlight?: string } @@ -577,6 +580,7 @@ const CellContent = memo(function CellContent({ content, editing, pinned, + locked, highlight, }: CellContentProps) { if (editing) { @@ -607,6 +611,13 @@ const CellContent = memo(function CellContent({ aria-label='Pinned' /> )} + {locked && ( + + )} ) }) @@ -785,6 +796,7 @@ const DataRow = memo(function DataRow({ content={cell?.content} editing={cell?.editing} pinned={cell?.pinned} + locked={cell?.locked} highlight={cell?.highlight} /> {index === 0 && onRowContextMenu && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index 5b737844816..c49f4e2afcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -2,7 +2,8 @@ import type { MouseEvent } from 'react' import { cn } from '@sim/emcn' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' -import { useParams, useRouter } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { useResourceWorkspaceId } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' import { BrandIcon, type StyleableIcon } from '@/blocks/brand-icon' import { mentionIcon } from './mention-icon' import { MarkdownMention, type MentionAttrs } from './mention-node' @@ -34,11 +35,11 @@ const CHIP_CLASS = */ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const router = useRouter() - const params = useParams() + const resourceWorkspaceId = useResourceWorkspaceId() const { kind, id, label } = node.attrs as MentionAttrs const Icon = mentionIcon(kind, id, label) as StyleableIcon | undefined const navigable = editor.storage.mentionMenu?.navigable === true - const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined + const workspaceId = resourceWorkspaceId || undefined const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null const handleClick = (event: MouseEvent) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index b2e8d34cd92..d078534229c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -22,7 +22,7 @@ import { import { Check, Download, Link, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' @@ -101,6 +101,11 @@ import { EMPTY_CELL_PLACEHOLDER, Resource, } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { ResourceListHeader } from '@/app/workspace/[workspaceId]/components/resource/resource-list-header' +import { + useResourceRouter, + useResourceWorkspaceId, +} from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' import { selectionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label' import { useResourceRowSelection } from '@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' @@ -117,6 +122,7 @@ import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/f import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' +import { useDeriveTitleFromHeading } from '@/app/workspace/[workspaceId]/files/hooks/use-derive-title-from-heading' import { useWorkspaceFilesRoom } from '@/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room' import FilesLoading from '@/app/workspace/[workspaceId]/files/loading' import { @@ -129,8 +135,6 @@ import { } from '@/app/workspace/[workspaceId]/files/search-params' import { DEFAULT_UNTITLED_NAME, - deriveMarkdownFileName, - isUntitledName, uniqueMarkdownName, } from '@/app/workspace/[workspaceId]/files/untitled-title' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' @@ -286,10 +290,10 @@ function FilesContent() { const discardRef = useRef<(() => void) | null>(null) const params = useParams() - const router = useRouter() + const router = useResourceRouter() const [{ folderId: currentFolderId, new: isNewFile, shareFileId }, setFilesParams] = useQueryStates(filesParsers, filesUrlKeys) - const workspaceId = params?.workspaceId as string + const workspaceId = useResourceWorkspaceId() const posthog = usePostHog() const posthogRef = useRef(posthog) @@ -548,35 +552,7 @@ function FilesContent() { const selectedFileRef = useRef(selectedFile) selectedFileRef.current = selectedFile - /** - * While a file is still untitled, name it after the leading heading the user types in its editor. The - * editor reports the heading once the user finishes it — possibly on unmount, after the route has - * already moved to another file, so the file is looked up by the id it reports rather than read from - * the current selection. Re-checks the file is still untitled, derives a unique `.md` name among its - * folder siblings, and renames. A no-op once the file has a real name. - */ - const handleDeriveTitleFromHeading = useCallback( - (fileId: string, headingText: string) => { - const currentFile = filesRef.current.find((f) => f.id === fileId) - if (!currentFile || !isUntitledName(currentFile.name)) return - const derived = deriveMarkdownFileName(headingText) - if (!derived) return - const siblingNames = new Set( - filesRef.current - .filter( - (f) => - (f.folderId ?? null) === (currentFile.folderId ?? null) && f.id !== currentFile.id - ) - .map((f) => f.name) - ) - const name = uniqueMarkdownName(derived, siblingNames) - if (name === currentFile.name) return - renameFile - .mutateAsync({ workspaceId, fileId: currentFile.id, name }) - .catch((err) => logger.error('Failed to auto-name file from heading:', err)) - }, - [workspaceId] - ) + const handleDeriveTitleFromHeading = useDeriveTitleFromHeading(workspaceId, files) const shareFile = shareFileId ? (files.find((f) => f.id === shareFileId) ?? null) : null const shareModal = shareFile ? ( @@ -2146,7 +2122,7 @@ function FilesContent() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( - +
@@ -2162,7 +2138,7 @@ function FilesContent() { header's FileDocAvatars reads it — both must be descendants. */} - - { + const currentFile = filesRef.current.find((f) => f.id === fileId) + if (!currentFile || !isUntitledName(currentFile.name)) return + const derived = deriveMarkdownFileName(headingText) + if (!derived) return + const siblingNames = new Set( + filesRef.current + .filter( + (f) => + (f.folderId ?? null) === (currentFile.folderId ?? null) && f.id !== currentFile.id + ) + .map((f) => f.name) + ) + const name = uniqueMarkdownName(derived, siblingNames) + if (name === currentFile.name) return + renameFile + .mutateAsync({ workspaceId, fileId: currentFile.id, name }) + .catch((err) => logger.error('Failed to auto-name file from heading:', err)) + }, + // Mutation objects are unstable; mutateAsync is stable in v5. + [workspaceId] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index c47b5077f43..06157b92c52 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -1,20 +1,28 @@ 'use client' import type { + CSSProperties, FocusEventHandler, KeyboardEventHandler, PointerEventHandler, ReactNode, Ref, } from 'react' -import { Button, cn } from '@sim/emcn' -import { ArrowLeft, PanelLeft } from '@sim/emcn/icons' +import { Button, cn, Tooltip } from '@sim/emcn' +import { ArrowLeft, Expand, Minimize, PanelLeft } from '@sim/emcn/icons' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' interface ChatPanelLayoutProps { + ref?: Ref children: ReactNode panel: ReactNode collapsed: boolean + chatHidden?: boolean + /** Px the chat leaves for the panel while the panel overlays it (expanded or settling). */ + chatReserve?: number + /** The panel collapses through its swell (`ChatPanelSwell`) rather than the header toggle. */ + swell?: boolean + onToggleChat?: () => void label: string activityCount?: number onToggle: () => void @@ -26,9 +34,14 @@ interface ChatPanelLayoutProps { /** Shared resize handle and collapse control for resources and Search results. */ export function ChatPanelLayout({ + ref, children, panel, collapsed, + chatHidden = false, + chatReserve, + swell = false, + onToggleChat, label, activityCount = 0, onToggle, @@ -41,15 +54,37 @@ export function ChatPanelLayout({ ? `, ${activityCount} resource${activityCount === 1 ? '' : 's'} updated` : '' }` + const canToggleChat = Boolean(onToggleChat) && !collapsed + const overlaid = chatReserve !== undefined return (
-
{children}
- {!collapsed && ( +
*]:mr-[var(--chat-panel-reserve)]', + swell && chatHidden && 'md:[&>*]:invisible', + !collapsed && 'max-md:hidden' + )} + style={ + overlaid ? ({ '--chat-panel-reserve': `${chatReserve}px` } as CSSProperties) : undefined + } + inert={chatHidden} + > + {children} +
+ {!collapsed && !overlaid && (
+ {canToggleChat && ( +
+ + + + + + {chatHidden ? 'Restore split view' : 'Expand pane'} + + +
+ )}
) } @@ -107,15 +177,21 @@ interface ChatPanelContentProps { children: ReactNode ref?: Ref collapsed: boolean + /** Draws the panel as a card under a transparent tab band, for `ChatPanelSwell`. */ + swell?: boolean className?: string onInteraction?: () => void } -/** The width element measured by the shared divider and native resource surfaces. */ +/** + * The width element measured by the shared divider and native resource surfaces. Its content keeps + * the open width, so opening and closing slide it under the clipped edge instead of reflowing it. + */ export function ChatPanelContent({ children, ref, collapsed, + swell = false, className, onInteraction, }: ChatPanelContentProps) { @@ -130,6 +206,10 @@ export function ChatPanelContent({ 'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none max-md:absolute max-md:inset-0 max-md:w-full! max-md:min-w-0! max-md:border-l-0 max-md:transition-none', collapsed ? 'w-0 min-w-0 border-l-0 max-md:hidden' : 'w-1/2 border-l', '[--workspace-content-title-bar-inset:0px]', + '[&>*]:min-w-[var(--chat-panel-width)] max-md:[&>*]:min-w-0', + swell && + 'group/swell md:w-[calc(var(--chat-panel-width)*var(--swell-progress))]! md:min-w-0! md:border-l-0 md:bg-transparent md:transition-none', + swell && (collapsed ? '[--swell-progress:0]' : '[--swell-progress:1]'), className )} > diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-swell.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-swell.tsx new file mode 100644 index 00000000000..e622b3fae98 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-swell.tsx @@ -0,0 +1,379 @@ +'use client' + +import { useLayoutEffect, useRef } from 'react' +import { cn } from '@sim/emcn' +import { PanelLeft } from '@sim/emcn/icons' + +const R_MAX = 12 +const PEAK = 28 +const SHOULDER = (PEAK * 20) / 24 +/** Steepest side, reached when the control is narrow, such as just its icon. */ +const SHOULDER_STEEP = 10 +/** A narrow swell's sides steepen with its width, so an icon-sized swell rises sharply. */ +const SHOULDER_PER_PLATEAU = 0.5 +const PEAK_COLLAPSED = 24 +const SHOULDER_COLLAPSED = 14 +/** How far below the card's top the collapsed swell rests on its left edge. */ +const COLLAPSED_DROP = 56 +const CORNER_SMOOTHING = 0.25 +/** The control's slot diagonals span its 24px height, 20px across at the full shoulder. */ +const SLOT_RUN = 20 +/** So the slot diagonals sit this far inside the taper. */ +const SLOT_GAP = 2.6 +const PLATEAU_INSET = (SLOT_RUN * (PEAK - 2)) / 24 - SLOT_GAP +/** The icon's centre from the collapsed label's leading edge: its padding plus half the glyph. */ +const LABEL_ICON_CENTER = 18 +/** The open swell's shoulder for a control this wide: steeper as the control narrows. */ +function openShoulder(controlWidth: number) { + const slotPlateau = controlWidth - 2 * PLATEAU_INSET + return Math.min(SHOULDER, Math.max(SHOULDER_STEEP, slotPlateau * SHOULDER_PER_PLATEAU)) +} + +/** How far across its height a slot's diagonal runs, parallel to the swell's sides. */ +export function swellSlotRun(controlWidth: number) { + return (SLOT_RUN * openShoulder(controlWidth)) / SHOULDER +} + +/** Samples sit on a fixed grid along the perimeter, so a moving swell never reshuffles its vertices. */ +const SAMPLE_STEP = 1 +const SETTLED_FRAMES = 3 +/** The prototype's spring: near-critically damped, so it eases in and settles without overshoot. */ +const SPRING_STIFFNESS = 300 +const SPRING_DAMPING = 32 +const SPRING_REST = 1e-4 +const MAX_FRAME_SECONDS = 1 / 30 +/** On collapse the swell rides the shrinking card this long before sliding toward the corner. */ +const COLLAPSE_TRAVEL_DELAY_MS = 60 + +interface Spring { + x: number + v: number +} + +/** Advances the spring one frame toward `target`; returns whether it is still moving. */ +function stepSpring(spring: Spring, target: number, dt: number): boolean { + spring.v += (SPRING_STIFFNESS * (target - spring.x) - SPRING_DAMPING * spring.v) * dt + spring.x += spring.v * dt + if (Math.abs(target - spring.x) < SPRING_REST && Math.abs(spring.v) < SPRING_REST) { + spring.x = target + spring.v = 0 + return false + } + return true +} + +interface PerimeterPoint { + x: number + y: number + nx: number + ny: number +} + +interface PerimeterSegment { + len: number + at: (u: number) => PerimeterPoint +} + +/** Rounded-rect perimeter, clockwise from the bottom of the left edge. */ +function perimeter(x0: number, y0: number, x1: number, y1: number, r: number) { + const h = y1 - y0 + const w = x1 - x0 + const arc = (cx: number, cy: number, th0: number): PerimeterSegment => ({ + len: (Math.PI * r) / 2, + at: (u) => { + const th = th0 + (r > 0 ? u / r : 0) + const c = Math.cos(th) + const s = Math.sin(th) + return { x: cx + r * c, y: cy + r * s, nx: c, ny: s } + }, + }) + const segments: PerimeterSegment[] = [ + { len: h - 2 * r, at: (u) => ({ x: x0, y: y1 - r - u, nx: -1, ny: 0 }) }, + arc(x0 + r, y0 + r, Math.PI), + { len: w - 2 * r, at: (u) => ({ x: x0 + r + u, y: y0, nx: 0, ny: -1 }) }, + arc(x1 - r, y0 + r, 1.5 * Math.PI), + { len: h - 2 * r, at: (u) => ({ x: x1, y: y0 + r + u, nx: 1, ny: 0 }) }, + arc(x1 - r, y1 - r, 0), + { len: w - 2 * r, at: (u) => ({ x: x1 - r - u, y: y1, nx: 0, ny: 1 }) }, + arc(x0 + r, y1 - r, 0.5 * Math.PI), + ] + return (distance: number): PerimeterPoint => { + let s = distance + for (const segment of segments) { + if (s <= segment.len) return segment.at(Math.max(0, s)) + s -= segment.len + } + const last = segments[segments.length - 1] + return last.at(last.len) + } +} + +const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) +const lerp = (a: number, b: number, t: number) => a + (b - a) * t +const smoothstep = (n: number) => n * n * (3 - 2 * n) + +function smoothMin(a: number, b: number, k: number) { + const h = Math.max(k - Math.abs(a - b), 0) / k + return Math.min(a, b) - h * h * k * 0.25 +} + +/** A flat plateau with straight shoulders at the slots' own slope, its corners smoothed. */ +function bump(distance: number, plateau: number, shoulder: number) { + const v = 1 - (Math.abs(distance) - plateau / 2) / shoulder + return -smoothMin(-smoothMin(v, 1, CORNER_SMOOTHING), 0, CORNER_SMOOTHING) +} + +function relativeRect(el: Element, origin: DOMRect) { + const rect = el.getBoundingClientRect() + return { + left: rect.left - origin.left, + right: rect.right - origin.left, + top: rect.top - origin.top, + bottom: rect.bottom - origin.top, + width: rect.width, + } +} + +function requireElement(root: ParentNode, selector: string): T { + const el = root.querySelector(selector) + if (!el) throw new Error(`Swell is missing ${selector}`) + return el +} + +interface SwellElements { + layout: HTMLElement + panel: HTMLElement + path: SVGPathElement + label: HTMLElement + icon: HTMLElement +} + +/** + * Opens the panel to `progress` and lays the swell out against the card that results, `travel` of + * the way from its collapsed rest to the control. The panel and card size themselves from + * `--swell-progress`, so one write moves both, then the swell is measured from the laid-out card + * and can never drift from it. + */ +function drawSwell( + { layout, panel, path, label, icon }: SwellElements, + progress: number, + travel: number +): string { + const card = requireElement(panel, '[data-swell-card]') + const control = requireElement(panel, '[data-swell-control]') + const dock = requireElement(panel, '[data-swell-dock]') + const t = clamp01(travel) + panel.style.setProperty('--swell-progress', String(progress)) + panel.style.setProperty('--swell-edge', String(clamp01(progress * 8))) + + const origin = layout.getBoundingClientRect() + const cardRect = relativeRect(card, origin) + const controlRect = relativeRect(control, origin) + const dockRect = relativeRect(dock, origin) + const x0 = cardRect.left + const x1 = Math.max(x0, cardRect.right) + const y0 = cardRect.top + const y1 = cardRect.bottom + const r = Math.min(R_MAX, (x1 - x0) / 2, (y1 - y0) / 2) + const at = perimeter(x0, y0, x1, y1, r) + + const leftLength = y1 - y0 - 2 * r + // The base stays as wide as the slots' own taper; steeper sides widen the plateau instead. + const slotPlateau = controlRect.width - 2 * PLATEAU_INSET + const sideShoulder = openShoulder(controlRect.width) + const plateau = lerp(label.offsetWidth + 4, slotPlateau + 2 * (SHOULDER - sideShoulder), t) + const peak = lerp(PEAK_COLLAPSED, PEAK, t) + const shoulder = lerp(SHOULDER_COLLAPSED, sideShoulder, t) + const restingCenter = Math.max(0, leftLength - COLLAPSED_DROP) + const topStart = leftLength + (Math.PI * r) / 2 + // Kept on the top edge while the swell lingers on a card that is shrinking out from under it. + const controlCenter = + topStart + + Math.min((controlRect.left + controlRect.right) / 2 - (x0 + r), Math.max(0, x1 - x0 - 2 * r)) + const center = lerp(restingCenter, controlCenter, t) + + const start = Math.floor((center - plateau / 2 - shoulder - 1) / SAMPLE_STEP) * SAMPLE_STEP + const end = Math.ceil((center + plateau / 2 + shoulder + 1) / SAMPLE_STEP) * SAMPLE_STEP + const outer: string[] = [] + const inner: string[] = [] + for (let step = 0; start + step * SAMPLE_STEP <= end; step++) { + const distance = start + step * SAMPLE_STEP + const point = at(distance) + // One pixel inside the card's edge, so its border hides the fill's antialiasing. + const offset = (peak + 1) * bump(distance - center, plateau, shoulder) - 1 + outer.push( + `${(point.x + point.nx * offset).toFixed(2)},${(point.y + point.ny * offset).toFixed(2)}` + ) + inner.push(`${(point.x - point.nx).toFixed(2)},${(point.y - point.ny).toFixed(2)}`) + } + const outline = `M${outer.join('L')}L${inner.reverse().join('L')}Z` + path.setAttribute('d', outline) + + const base = at(center) + let rotation = (Math.atan2(base.ny, base.nx) * 180) / Math.PI + 90 + if (rotation > 180) rotation -= 360 + const labelX = base.x + (base.nx * peak) / 2 + const labelY = base.y + (base.ny * peak) / 2 + label.style.transform = `translate(${labelX}px,${labelY}px) translate(-50%,-50%) rotate(${rotation}deg)` + label.toggleAttribute('data-hidden', t > 0.2) + label.inert = t > 0.2 + + // Carried at the swell's trailing end, where it sits in the collapsed label, then docked. + const carry = at(center - plateau / 2 + LABEL_ICON_CENTER) + const drop = smoothstep(clamp01((t - 0.6) / 0.4)) + const iconX = lerp(carry.x + (carry.nx * peak) / 2, (dockRect.left + dockRect.right) / 2, drop) + const iconY = lerp(carry.y + (carry.ny * peak) / 2, (dockRect.top + dockRect.bottom) / 2, drop) + icon.style.transform = `translate(${iconX}px,${iconY}px) translate(-50%,-50%) rotate(${rotation * (1 - drop)}deg)` + icon.toggleAttribute('data-docked', drop > 0.5) + icon.toggleAttribute('data-hidden', t === 1) + + panel.toggleAttribute('data-swell-hidden', panel.inert || t <= 0.9) + panel.toggleAttribute('data-swell-docked', t === 1) + panel.style.setProperty('--swell-content-opacity', String(clamp01((progress - 0.6) * 2.5))) + return outline +} + +interface ChatPanelSwellProps { + label: string + toggleLabel: string + /** Whether a tab, rather than the control's own view, fills the panel. */ + idle: boolean + collapsed: boolean + /** Jumps to the collapsed state instead of animating, such as when the chat changes. */ + instant: boolean + activityCount: number + onExpand: () => void +} + +/** + * The swell that carries the panel's navigation control on the card's top edge and, collapsed, + * rests on its left edge as the expand tab. Rendered as a sibling of `[data-mothership-panel]`, + * which must contain `[data-swell-card]`, `[data-swell-control]`, and `[data-swell-dock]`. + */ +export function ChatPanelSwell({ + label, + toggleLabel, + idle, + collapsed, + instant, + activityCount, + onExpand, +}: ChatPanelSwellProps) { + const layerRef = useRef(null) + const pathRef = useRef(null) + const labelRef = useRef(null) + const iconRef = useRef(null) + + const springRef = useRef({ x: collapsed ? 0 : 1, v: 0 }) + const travelRef = useRef({ x: collapsed ? 0 : 1, v: 0 }) + const targetRef = useRef(collapsed ? 0 : 1) + const collapsedAtRef = useRef(0) + const kickRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + const layout = layerRef.current?.parentElement + const path = pathRef.current + const label = labelRef.current + const icon = iconRef.current + if (!layout || !path || !label || !icon) throw new Error('Swell layer is not mounted') + const panel = requireElement(layout, '[data-mothership-panel]') + const elements = { layout, panel, path, label, icon } + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + const spring = springRef.current + const travel = travelRef.current + + let frame = 0 + let still = 0 + let lastTime = 0 + let lastOutline = '' + const tick = (time: number) => { + frame = 0 + const dt = lastTime ? Math.min(MAX_FRAME_SECONDS, (time - lastTime) / 1000) : 1 / 60 + lastTime = time + const target = targetRef.current + const holding = target === 0 && time - collapsedAtRef.current < COLLAPSE_TRAVEL_DELAY_MS + let moving = false + if (reducedMotion.matches) { + spring.x = travel.x = target + spring.v = travel.v = 0 + } else { + const panelMoving = stepSpring(spring, target, dt) + const travelMoving = stepSpring(travel, holding ? 1 : target, dt) + moving = panelMoving || travelMoving || holding + } + const outline = drawSwell(elements, spring.x, travel.x) + still = moving || outline !== lastOutline ? 0 : still + 1 + lastOutline = outline + if (still < SETTLED_FRAMES) frame = requestAnimationFrame(tick) + else lastTime = 0 + } + const kick = () => { + still = 0 + frame ||= requestAnimationFrame(tick) + } + kickRef.current = kick + + drawSwell(elements, spring.x, travel.x) + // Observers run after layout and before paint, so drawing here keeps the swell on the card + // in the same frame as a resize instead of one behind it. + const observer = new ResizeObserver(() => { + drawSwell(elements, spring.x, travel.x) + kick() + }) + observer.observe(layout) + observer.observe(panel) + observer.observe(label) + observer.observe(requireElement(panel, '[data-swell-control]')) + return () => { + kickRef.current = null + observer.disconnect() + if (frame) cancelAnimationFrame(frame) + } + }, []) + + useLayoutEffect(() => { + targetRef.current = collapsed ? 0 : 1 + if (collapsed) collapsedAtRef.current = performance.now() + if (instant) { + for (const spring of [springRef.current, travelRef.current]) { + spring.x = targetRef.current + spring.v = 0 + } + } + kickRef.current?.() + }, [collapsed, instant]) + + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx index 0b93d1793e3..2cd19b1fa8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx @@ -1,14 +1,34 @@ 'use client' -import { lazy, type ReactNode, Suspense, useCallback } from 'react' +import { lazy, type ReactNode, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { cn } from '@sim/emcn' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import { ChatPanelLayout } from '@/app/workspace/[workspaceId]/home/components/chat-panel-layout' import { MothershipResourcesProvider } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import type { ResourcePanelNavigation } from '@/app/workspace/[workspaceId]/home/components/resource-panel-navigation' import { useBrowserTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources' import type { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { useChatResourcePanel } from '@/app/workspace/[workspaceId]/home/hooks/use-resource-panel' import { useTerminalTabResources } from '@/app/workspace/[workspaceId]/home/hooks/use-terminal-tab-resources' +/** Matches the panel's width transition in `ChatPanelContent`. */ +const PANEL_TRANSITION_MS = 200 + +/** + * While expanded, the panel overlays the chat instead of displacing it, so neither side reflows + * mid-animation. `reserve` keeps the chat at the width it has when the overlay settles. + */ +interface PanelOverlay { + mode: 'expanded' | 'settling' + reserve: number +} + +/** The lazy panel takes no width until its chunk loads, so an unloaded panel reserves nothing. */ +function measurePanelWidth(layout: HTMLElement | null): number { + if (!layout) throw new Error('Chat panel layout is not mounted') + return layout.querySelector('[data-mothership-panel]')?.offsetWidth ?? 0 +} + const MothershipView = lazy(() => import('@/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view').then( (m) => ({ default: m.MothershipView }) @@ -16,6 +36,7 @@ const MothershipView = lazy(() => ) interface ChatResourcePanelProps { + navigation?: ResourcePanelNavigation workspaceId?: string organizationId?: string allowBuildControls?: boolean @@ -27,6 +48,7 @@ interface ChatResourcePanelProps { /** Shared resizable resource chrome for workspace and organization chat. */ export function ChatResourcePanel({ + navigation, workspaceId, organizationId, allowBuildControls, @@ -35,6 +57,21 @@ export function ChatResourcePanel({ children, onSummarize, }: ChatResourcePanelProps) { + const layoutRef = useRef(null) + const [overlay, setOverlay] = useState(null) + const settle = (reserve: number) => setOverlay({ mode: 'settling', reserve }) + useEffect(() => { + if (chat.isSending) + setOverlay((current) => + current?.mode === 'expanded' ? { mode: 'settling', reserve: current.reserve } : current + ) + }, [chat.isSending]) + useEffect(() => { + if (overlay?.mode !== 'settling') return + const timer = setTimeout(() => setOverlay(null), PANEL_TRANSITION_MS) + return () => clearTimeout(timer) + }, [overlay]) + const isChatHidden = overlay?.mode === 'expanded' useBrowserTabResources(panel.desktopTabResourceOptions) useTerminalTabResources(panel.desktopTabResourceOptions) const { @@ -62,6 +99,14 @@ export function ChatResourcePanel({ handleResourceResizeFocus, handleResourceInteraction, } = panel + const togglePanel = () => { + if (isResourceCollapsed) { + expandResource() + return + } + if (overlay) settle(0) + collapseResource() + } const summarize = useCallback( (message: string, filters: WorkspaceSearchFilters) => { if (onSummarize) onSummarize(message, filters) @@ -71,10 +116,26 @@ export function ChatResourcePanel({ ) return ( { + if (overlay?.mode === 'expanded') settle(overlay.reserve) + else + setOverlay({ + mode: 'expanded', + reserve: measurePanelWidth(layoutRef.current), + }) + } + : undefined + } label='resource view' + swell={Boolean(navigation?.control)} activityCount={resourceActivityIds.size} - onToggle={isResourceCollapsed ? expandResource : collapseResource} + onToggle={togglePanel} onResize={handleResourceResizePointerDown} onResizeKeyDown={handleResourceResizeKeyDown} onResizeFocus={handleResourceResizeFocus} @@ -88,6 +149,7 @@ export function ChatResourcePanel({ > diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/pane-file-header/pane-file-header.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/pane-file-header/pane-file-header.tsx new file mode 100644 index 00000000000..41913224a0a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/pane-file-header/pane-file-header.tsx @@ -0,0 +1,114 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { ChipConfirmModal, toast, useCopyToClipboard } from '@sim/emcn' +import { Check, Link, Pencil, Trash } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + useDeleteWorkspaceFile, + useRenameWorkspaceFile, + useWorkspaceFiles, +} from '@/hooks/queries/workspace-files' +import { useInlineRename } from '@/hooks/use-inline-rename' + +interface PaneFileHeaderProps { + workspaceId: string + resource: MothershipResource + onBrowse?: () => void + aside: ReactNode +} + +/** A file tab's header in a pane: the Files page's name menu, acting on this tab's file. */ +export function PaneFileHeader({ workspaceId, resource, onBrowse, aside }: PaneFileHeaderProps) { + const { canEdit } = useUserPermissionsContext() + const { removeResource } = useMothershipResources() + const { data: files = [] } = useWorkspaceFiles(workspaceId) + const file = files.find((candidate) => candidate.id === resource.id) + const renameFile = useRenameWorkspaceFile() + const deleteFile = useDeleteWorkspaceFile() + const rename = useInlineRename({ + onSave: (fileId, name) => renameFile.mutateAsync({ workspaceId, fileId, name }), + }) + const { copied, copy } = useCopyToClipboard({ resetMs: 1500 }) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const editable = Boolean(file) && canEdit && file?.vfsNamespace !== 'uploads' + + const handleDelete = async () => { + try { + await deleteFile.mutateAsync({ workspaceId, fileId: resource.id }) + setConfirmingDelete(false) + removeResource(resource.type, resource.id, resource.workspaceId) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to delete file')) + } + } + + return ( + <> + + void copy( + `${window.location.origin}/workspace/${workspaceId}/files/${file.id}` + ), + }, + ...(editable + ? [ + { + label: 'Rename', + icon: Pencil, + onClick: () => rename.startRename(file.id, file.name), + }, + { label: 'Delete', icon: Trash, onClick: () => setConfirmingDelete(true) }, + ] + : []), + ] + : undefined, + }, + ]} + aside={aside} + /> + void handleDelete(), + pending: deleteFile.isPending, + pendingLabel: 'Deleting...', + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 4b700b7863c..0f1c0f5a97e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,21 +1,13 @@ 'use client' import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - Button, - OverflowText, - PlayOutline, - Skeleton, - TabStripAction, - Tooltip, - toast, -} from '@sim/emcn' +import { Button, PlayOutline, Skeleton, TabStripAction, Tooltip, toast } from '@sim/emcn' import { Download, FileX, - Folder as FolderIcon, Library, Loader, + Redo, Send, Square, SquareArrowUpRight, @@ -36,12 +28,15 @@ import { stopRunToolForExecution } from '@/lib/mothership/tools/client/run-tool- import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { useResourceListOpen } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' import { FileViewer, type PreviewMode, resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' +import { useDeriveTitleFromHeading } from '@/app/workspace/[workspaceId]/files/hooks/use-derive-title-from-heading' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' @@ -65,8 +60,8 @@ import { } from '@/app/workspace/[workspaceId]/tests/components' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' -import { useFolders } from '@/hooks/queries/folders' -import { useLogByExecutionId, useLogDetail } from '@/hooks/queries/logs' +import type { WorkflowPaneHost } from '@/app/workspace/[workspaceId]/w/[workflowId]/pane-host' +import { useLogByExecutionId, useLogDetail, useRetryExecution } from '@/hooks/queries/logs' import { exportTable } from '@/hooks/queries/tables' import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' @@ -137,6 +132,10 @@ interface ResourceContentProps { visible?: boolean /** Registers the active browser's targeted renderer-overlay handshake. */ onBrowserOverlayControllerChange?: (controller: BrowserPanelOverlayController | null) => void + /** Set when a pane hosts the full editor in place of the reduced canvas. */ + workflowHost?: WorkflowPaneHost + /** Set when the resource renders its own page header in a pane. */ + pageHeader?: boolean } /** @@ -203,6 +202,8 @@ export const ResourceContent = memo(function ResourceContent({ onNotFound, visible = true, onBrowserOverlayControllerChange, + workflowHost, + pageHeader, }: ResourceContentProps) { const observedTableViewRef = useRef<{ tableId: string; viewId?: string } | null>(null) @@ -307,6 +308,7 @@ export const ResourceContent = memo(function ResourceContent({ embedded initialViewId={resource.viewId} onViewContextChange={reportTableView} + pageHeader={pageHeader} /> ) @@ -347,7 +349,12 @@ export const ResourceContent = memo(function ResourceContent({ case 'workflow': return ( - + ) case 'knowledgebase': @@ -360,9 +367,6 @@ export const ResourceContent = memo(function ResourceContent({ /> ) - case 'folder': - return - case 'log': return ( @@ -410,10 +415,17 @@ export function ResourceActions({ workspaceId, resource, downloadSourceRef, + allowWorkspaceNavigation = true, }: ResourceActionsProps) { switch (resource.type) { case 'workflow': - return + return ( + + ) case 'file': return ( ) case 'knowledgebase': + if (!allowWorkspaceNavigation) return null return ( ) case 'dashboard': + if (!allowWorkspaceNavigation) return null return case 'test': - return + return ( + + ) case 'table': - return + return ( + + ) case 'log': return ( ) case 'folder': @@ -453,11 +481,16 @@ export function ResourceActions({ } interface EmbeddedWorkflowActionsProps { + allowWorkspaceNavigation?: boolean workspaceId: string workflowId: string } -export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { +export function EmbeddedWorkflowActions({ + workspaceId, + workflowId, + allowWorkspaceNavigation = true, +}: EmbeddedWorkflowActionsProps) { const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() @@ -516,16 +549,22 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor return ( <> - - - - - - - -

Open workflow

-
-
+ {allowWorkspaceNavigation && ( + + + + + + + +

Open workflow

+
+
+ )} {run.text}

- - - openInternalLink(`/workspace/${workspaceId}/tests/${name}`)} - aria-label='Open test' - > - - - - -

Open test

-
-
+ {allowWorkspaceNavigation && ( + + + openInternalLink(`/workspace/${workspaceId}/tests/${name}`)} + aria-label='Open test' + > + + + + +

Open test

+
+
+ )} ) } @@ -662,11 +708,16 @@ export function EmbeddedKnowledgeBaseActions({ const tableLogger = createLogger('EmbeddedTableActions') interface EmbeddedTableActionsProps { + allowWorkspaceNavigation?: boolean workspaceId: string tableId: string } -function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProps) { +function EmbeddedTableActions({ + workspaceId, + tableId, + allowWorkspaceNavigation = true, +}: EmbeddedTableActionsProps) { const router = useRouter() const handleOpenTable = () => { @@ -683,16 +734,18 @@ function EmbeddedTableActions({ workspaceId, tableId }: EmbeddedTableActionsProp return ( <> - - - - - - - -

Open table

-
-
+ {allowWorkspaceNavigation && ( + + + + + + + +

Open table

+
+
+ )} - {file && !isUpload && ( + {allowWorkspaceNavigation && file && !isUpload && ( @@ -822,7 +877,11 @@ interface EmbeddedWorkflowProps { workflowId: string } -function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { +function EmbeddedWorkflow({ + workspaceId, + workflowId, + host, +}: EmbeddedWorkflowProps & { host?: WorkflowPaneHost }) { const { data: workflowList } = useWorkflows(workspaceId) const workflowExists = (workflowList ?? []).some((workflow) => workflow.id === workflowId) @@ -832,7 +891,7 @@ function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { return ( - + ) } @@ -844,6 +903,8 @@ function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { function ResolveEmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { const queryClient = useQueryClient() const openInternalLink = useOpenInternalLink() + const { removeResource } = useMothershipResources() + const openInPane = useResourceListOpen() const { data: canonical, isPending: isCanonicalPending } = useQuery({ queryKey: workflowKeys.state(workflowId), queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal), @@ -870,12 +931,30 @@ function ResolveEmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowPr

{canonical.name}

- + {openInPane ? ( + + ) : ( + + )}
) } @@ -935,6 +1014,7 @@ function EmbeddedFile({ }) const file = listedFile ?? detail.data const isUpload = file?.vfsNamespace === 'uploads' + const deriveTitleFromHeading = useDeriveTitleFromHeading(workspaceId, files) const contentSource = useMemo( () => isUpload @@ -977,65 +1057,13 @@ function EmbeddedFile({ disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} collaborative={!isUpload} + onDeriveTitleFromHeading={deriveTitleFromHeading} enableFind />
) } -interface EmbeddedFolderProps { - workspaceId: string - folderId: string -} - -function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { - const openInternalLink = useOpenInternalLink() - const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) - const { data: workflowList = [] } = useWorkflows(workspaceId) - - const folder = (folderList ?? []).find((f) => f.id === folderId) - const folderWorkflows = workflowList.filter((w) => w.folderId === folderId) - - if (isFoldersPending) return LOADING_SKELETON - - if (!folder) { - return ( -
- -
-

Folder not found

-

- This folder may have been deleted or moved -

-
-
- ) - } - - return ( -
-

{folder.name}

- {folderWorkflows.length === 0 ? ( -

No workflows in this folder

- ) : ( -
- {folderWorkflows.map((w) => ( - - ))} -
- )} -
- ) -} - interface EmbeddedLogProps { workspaceId: string logId: string @@ -1089,11 +1117,34 @@ interface EmbeddedLogActionsProps { workspaceId: string logId: string executionId?: string + allowWorkspaceNavigation?: boolean } -export function EmbeddedLogActions({ workspaceId, logId, executionId }: EmbeddedLogActionsProps) { +export function EmbeddedLogActions({ + workspaceId, + logId, + executionId, + allowWorkspaceNavigation = true, +}: EmbeddedLogActionsProps) { const router = useRouter() const { data: log } = useEmbeddedLog(workspaceId, logId, executionId) + const retryExecution = useRetryExecution() + const retryWorkflowId = log?.workflow?.id || log?.workflowId + const canRetry = + log?.status === 'failed' && Boolean(retryWorkflowId) && log.trigger !== 'mothership' + + const handleRetry = async () => { + if (!retryWorkflowId || !log?.executionId) return + try { + await retryExecution.mutateAsync({ + workflowId: retryWorkflowId, + executionId: log.executionId, + }) + toast.success('Retry started') + } catch { + toast.error('Failed to retry execution') + } + } const handleOpenInLogs = () => { const param = log?.executionId ? `?executionId=${log.executionId}` : '' @@ -1101,15 +1152,36 @@ export function EmbeddedLogActions({ workspaceId, logId, executionId }: Embedded } return ( - - - - - - - -

Open in logs

-
-
+ <> + {canRetry && ( + + + void handleRetry()} + disabled={retryExecution.isPending} + aria-label='Retry execution' + > + + + + +

Retry

+
+
+ )} + {allowWorkspaceNavigation && ( + + + + + + + +

Open in logs

+
+
+ )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index ae635b51111..5f3d610a5a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -309,13 +309,13 @@ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ 'integration', 'task', 'dashboard', - 'test', 'table', 'file', 'filefolder', 'knowledgebase', 'workflow', 'log', + 'test', 'folder', 'browser', 'terminal', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/preview-mode-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/preview-mode-toggle.tsx new file mode 100644 index 00000000000..f822032f172 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/preview-mode-toggle.tsx @@ -0,0 +1,38 @@ +import { TabStripAction, Tooltip } from '@sim/emcn' +import { Columns3, Eye, Pencil } from '@sim/emcn/icons' +import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { RESOURCE_TAB_ICON_CLASS } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' + +/** Each icon shows the mode a click switches to. */ +const PREVIEW_MODE_ICONS = { + editor: Columns3, + split: Eye, + preview: Pencil, +} as const + +const PREVIEW_MODE_LABELS: Record = { + editor: 'Split Mode', + split: 'Preview Mode', + preview: 'Edit Mode', +} + +interface PreviewModeToggleProps { + mode: PreviewMode + onCycle: () => void +} + +export function PreviewModeToggle({ mode, onCycle }: PreviewModeToggleProps) { + const Icon = PREVIEW_MODE_ICONS[mode] + return ( + + + + + + + +

{PREVIEW_MODE_LABELS[mode]}

+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts index 8474cfdf504..8f3f479a909 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts @@ -39,5 +39,8 @@ export const RESOURCE_HEADER_CLASSES = { */ adjacentEndPosition: 'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-hit-size)_+_1px)]', + /** The swell's band: its controls centre on the swell, below the band's top. */ + swellEndPosition: + 'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-hit-size)_+_1px)] md:top-3 md:right-[var(--resource-header-end-inset)] md:h-7', emptyAddOffset: '-translate-x-1.5', } as const diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index e2cb65ed72d..6e1cff27b3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -1,5 +1,4 @@ import { - type ComponentProps, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, @@ -20,7 +19,7 @@ import { tabStripItemSelector, toast, } from '@sim/emcn' -import { Columns3, Eye, Pencil } from '@sim/emcn/icons' +import { PanelLeft, Plus } from '@sim/emcn/icons' import type { TerminalTabState } from '@sim/terminal-protocol' import { useQueries } from '@tanstack/react-query' import { requestBrowserOmniboxFocus } from '@/lib/browser-agent/omnibox-focus' @@ -41,20 +40,17 @@ import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/compo import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown' import { useTerminalCloseConfirmation } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/use-terminal-close-confirmation' import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' -import { - RESOURCE_HEADER_CLASSES, - RESOURCE_TAB_ICON_CLASS, -} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import { PreviewModeToggle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/preview-mode-toggle' +import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import type { ResourcePanelNavigation } from '@/app/workspace/[workspaceId]/home/components/resource-panel-navigation' import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { useFolders } from '@/hooks/queries/folders' -import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' -import { useTablesList } from '@/hooks/queries/tables' +import { getKnowledgeBasesQueryOptions } from '@/hooks/queries/kb/knowledge' +import { getTablesListQueryOptions } from '@/hooks/queries/tables' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' -import { useWorkflows } from '@/hooks/queries/workflows' -import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files' import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' @@ -132,18 +128,6 @@ function buildMultiDragImage( return container } -const PREVIEW_MODE_ICONS = { - editor: Columns3, - split: Eye, - preview: Pencil, -} satisfies Record) => ReactNode> - -const PREVIEW_MODE_LABELS: Record = { - editor: 'Split Mode', - split: 'Preview Mode', - preview: 'Edit Mode', -} - /** * Stable identity for the empty lookup across `enabled` toggles. The tab list * memo below takes this map as a dependency, so a fresh empty map each time @@ -151,66 +135,68 @@ const PREVIEW_MODE_LABELS: Record = { */ const NO_RESOURCE_NAMES = new Map() +/** The workspaces whose lists hold this type's open tabs. */ +function ownersOf( + resources: MothershipResource[], + type: MothershipResource['type'], + workspaceId: string | undefined +): string[] { + const owners = new Set() + for (const resource of resources) { + const owner = resource.workspaceId ?? workspaceId + if (resource.type === type && owner) owners.add(owner) + } + return [...owners] +} + /** * Builds a `type:id` -> current name lookup from live query data so resource - * tabs always reflect the latest name even after a rename. Skipped entirely - * when there are no tabs to label — a chat with no open resources must not - * fetch five workspace-wide lists. + * tabs always reflect the latest name even after a rename. Each tab is looked + * up in its own workspace's list, so a pane holding several environments' + * resources stays current too. Only types with an open tab are fetched. */ function useResourceNameLookup( workspaceId: string | undefined, resources: MothershipResource[] ): Map { - const enabled = resources.length > 0 - const owners = [ - ...new Set( - resources - .filter( - (resource) => - resource.type === 'workflow' && - resource.workspaceId && - resource.workspaceId !== workspaceId - ) - .map((resource) => resource.workspaceId!) + const workflows = useQueries({ + queries: ownersOf(resources, 'workflow', workspaceId).map((owner) => + getWorkflowListQueryOptions(owner) ), - ] - const ownedWorkflows = useQueries({ - queries: owners.map((owner) => getWorkflowListQueryOptions(owner)), }) - const { data: workflows } = useWorkflows(workspaceId ?? '', { - enabled: enabled && Boolean(workspaceId), - }) - const { data: tables } = useTablesList(workspaceId ?? '', 'active', { - enabled: enabled && Boolean(workspaceId), - }) - const { data: files } = useWorkspaceFiles(workspaceId ?? '', 'active', { - enabled: enabled && Boolean(workspaceId), + const tables = useQueries({ + queries: ownersOf(resources, 'table', workspaceId).map((owner) => + getTablesListQueryOptions(owner) + ), }) - const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId ?? '', { - enabled: enabled && Boolean(workspaceId), + const files = useQueries({ + queries: ownersOf(resources, 'file', workspaceId).map((owner) => + getWorkspaceFilesQueryOptions(owner) + ), }) - const { data: folders } = useFolders(workspaceId ?? '', { - enabled: enabled && Boolean(workspaceId), + const knowledgeBases = useQueries({ + queries: ownersOf(resources, 'knowledgebase', workspaceId).map((owner) => + getKnowledgeBasesQueryOptions(owner) + ), }) return useMemo(() => { - if (!enabled) return NO_RESOURCE_NAMES + if (resources.length === 0) return NO_RESOURCE_NAMES const map = new Map() - for (const w of workflows ?? []) if (w.name.trim()) map.set(`workflow:${w.id}`, w.name) - for (const [index, result] of ownedWorkflows.entries()) { - for (const workflow of result.data ?? []) - if (workflow.name.trim() && workflow.workspaceId === owners[index]) - map.set(`workflow:${workflow.id}`, workflow.name) - } - for (const t of tables ?? []) map.set(`table:${t.id}`, t.name) - for (const file of files ?? []) map.set(`file:${file.id}`, file.name) - for (const kb of knowledgeBases ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) - for (const folder of folders ?? []) map.set(`folder:${folder.id}`, folder.name) + for (const { data } of workflows) + for (const workflow of data ?? []) + if (workflow.name.trim()) map.set(`workflow:${workflow.id}`, workflow.name) + for (const { data } of tables) for (const t of data ?? []) map.set(`table:${t.id}`, t.name) + for (const { data } of files) + for (const file of data ?? []) map.set(`file:${file.id}`, file.name) + for (const { data } of knowledgeBases) + for (const kb of data ?? []) map.set(`knowledgebase:${kb.id}`, kb.name) return map - }, [enabled, workflows, tables, files, knowledgeBases, folders, ownedWorkflows, owners]) + }, [resources.length, workflows, tables, files, knowledgeBases]) } interface ResourceTabsProps { + navigation?: ResourcePanelNavigation organizationId?: string allowBuildControls?: boolean workspaceId?: string @@ -224,6 +210,8 @@ interface ResourceTabsProps { actions?: ReactNode onRequestAddResourceOpen?: (open: () => void) => void onAddResourceClose?: () => Promise + /** Set when the panel collapses through its swell; docks the collapse button before the control. */ + onCollapse?: () => void } /** @@ -236,6 +224,7 @@ interface ResourceTabsProps { * component the browser and terminal panels nested inside this one use. */ export function ResourceTabs({ + navigation, organizationId, allowBuildControls = !organizationId, workspaceId, @@ -249,8 +238,8 @@ export function ResourceTabs({ actions, onRequestAddResourceOpen, onAddResourceClose, + onCollapse, }: ResourceTabsProps) { - const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split'] const nameLookup = useResourceNameLookup(workspaceId, resources) const { selectResource, @@ -577,52 +566,116 @@ export function ResourceTabs({ const previewToggle = previewMode && onCyclePreviewMode ? ( - - - - - - - -

{PREVIEW_MODE_LABELS[previewMode]}

-
-
+ ) : null return ( <> {confirmationDialog} + !resources.some( + (resource) => + resource.type === 'dashboard' && + getChatResourceSelectionId(resource) === tab.id + ) + ), + ...navigation.tabs, + ] + : tabs + } + onSelect={(id, source, event) => + navigation?.tabs.some((tab) => tab.id === id) + ? navigation.onSelect(id) + : handleSelect(id, source, event) + } + onClose={(id) => + navigation?.tabs.some((tab) => tab.id === id) + ? navigation.onClose(id) + : void handleClose(id) + } + onReorder={(id, index) => { + const visible = navigation + ? resources.filter((resource) => resource.type !== 'dashboard') + : resources + const target = visible[Math.min(index, visible.length - 1)] + if (target) handleReorder(id, resources.indexOf(target)) + }} onTabDragStart={handleTabDragStart} variant='floating' - className={RESOURCE_HEADER_CLASSES.stripGeometry} - newTabControl={ - // Offered before the chat exists too: a resource opened while composing - // the first prompt is context for that prompt, and gating on a chat id - // meant the panel could be opened but not filled. - allowBuildControls && (workspaceId || organizationId) ? ( -
- -
- ) : undefined - } + dividers={!onCollapse} + className={cn( + RESOURCE_HEADER_CLASSES.stripGeometry, + onCollapse && + 'transition-opacity duration-150 md:mt-3 md:group-data-[swell-hidden]/swell:pointer-events-none md:group-data-[swell-hidden]/swell:opacity-0 md:[--tab-strip-height:28px] md:[--tab-strip-inline-start:14px]' + )} + {...(navigation + ? { + newTabControl: ( + + + + + + + New tab + + ), + } + : { + newTabControl: + // Offered before the chat exists too: a resource opened while composing + // the first prompt is context for that prompt, and gating on a chat id + // meant the panel could be opened but not filled. + !navigation && allowBuildControls && (workspaceId || organizationId) ? ( +
+ +
+ ) : undefined, + })} // A bare fragment is always truthy, so the empty case has to be `null` or // the strip renders an empty trailing cluster. + startActions={ + onCollapse ? ( + <> + + + + + + + Collapse + + {navigation?.control} + + ) : ( + navigation?.control + ) + } endActions={ actions || previewToggle ? ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index dbcc1f2f202..20576fd5abc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -1,7 +1,17 @@ 'use client' -import { type ComponentProps, forwardRef, memo, useCallback, useRef, useState } from 'react' +import { + type ComponentProps, + forwardRef, + memo, + type ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react' import { cn } from '@sim/emcn' +import { useQueryClient } from '@tanstack/react-query' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import type { MothershipTableViewContext } from '@/lib/api/contracts/mothership-resources' import type { FilePreviewSession } from '@/lib/mothership/request/session' @@ -9,6 +19,11 @@ import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' import type { FileDownloadSource } from '@/lib/uploads/client/download' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { + ResourceNavigationProvider, + runResourceFromHref, +} from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { isCsvStreamOnly, @@ -17,13 +32,18 @@ import { RICH_PREVIEWABLE_EXTENSIONS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { ChatPanelContent } from '@/app/workspace/[workspaceId]/home/components/chat-panel-layout' +import { ChatPanelSwell } from '@/app/workspace/[workspaceId]/home/components/chat-panel-swell' import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { PaneFileHeader } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/pane-file-header/pane-file-header' import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' import { SearchResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/search-resource-content' import { SourcesResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/sources-resource-content' import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { PreviewModeToggle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/preview-mode-toggle' +import type { ResourcePanelNavigation } from '@/app/workspace/[workspaceId]/home/components/resource-panel-navigation' import { ResourceWorkspaceHost } from '@/app/workspace/[workspaceId]/home/components/resource-workspace-host' import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { @@ -31,9 +51,11 @@ import type { MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useUserPermissions } from '@/hooks/use-user-permissions' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { ResourceActions, ResourceContent, ResourceTabs } from './components' /** @@ -90,6 +112,7 @@ function shouldShowStreamingFilePanel( } interface MothershipViewProps { + navigation?: ResourcePanelNavigation workspaceId?: string organizationId?: string allowBuildControls?: boolean @@ -106,11 +129,16 @@ interface MothershipViewProps { onSummarize: (message: string, filters: WorkspaceSearchFilters) => void /** Claims the current resource selection after direct panel interaction. */ onUserInteraction?: () => void + /** With a navigation control, the panel opens and closes through the control's swell. */ + onTogglePanel?: () => void + /** Moves the panel to its open or closed state without animating. */ + skipTransition?: boolean } export const MothershipView = memo( forwardRef(function MothershipView( { + navigation, workspaceId, organizationId, allowBuildControls, @@ -126,10 +154,14 @@ export const MothershipView = memo( isAgentResponding, onSummarize, onUserInteraction, + onTogglePanel, + skipTransition = false, }: MothershipViewProps, ref ) { - const active = resources.find((r) => getChatResourceSelectionId(r) === activeResourceId) ?? null + const active = navigation?.active + ? null + : (resources.find((r) => getChatResourceSelectionId(r) === activeResourceId) ?? null) const activeWorkspaceId = active?.workspaceId ?? workspaceId const inheritedPermissions = useUserPermissionsContext() const permissions = useWorkspacePermissionsQuery(organizationId ? activeWorkspaceId : undefined) @@ -223,47 +255,77 @@ export const MothershipView = memo( activeFile?.type !== SIM_PAGE_CONTENT_TYPE // A test shows its source, its results, or both, like an HTML file; the viewer enforces edits. const isActivePreviewable = isActiveFilePreviewable || active?.type === 'test' + const swell = navigation?.control && onTogglePanel ? onTogglePanel : undefined return ( - -
- - - - ) : null - } - previewMode={isActivePreviewable ? previewMode : undefined} - onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} - onRequestAddResourceOpen={requestAddResourceOpen} - onAddResourceClose={closeAddResource} + <> + {swell && navigation && ( + -
- {/* + )} + +
+ + + + ) : null + } + previewMode={!navigation && isActivePreviewable ? previewMode : undefined} + onCyclePreviewMode={ + !navigation && isActivePreviewable ? handleCyclePreview : undefined + } + onRequestAddResourceOpen={requestAddResourceOpen} + onAddResourceClose={closeAddResource} + onCollapse={swell} + /> +
*]:min-w-[calc(var(--chat-panel-width)-18px)] md:[&>*]:opacity-[var(--swell-content-opacity,1)]' + )} + > + {/* The browser and terminal panels stay mounted while another resource is showing. Both are backed by state the renderer cannot cheaply rebuild — a live native view positioned from a @@ -274,73 +336,190 @@ export const MothershipView = memo( screen": the browser reports no bounds and its native view hides itself, and the terminals stop being measured. */} - {persistentResources.map((resource) => { - const panelVisible = active?.type === resource.type - return ( -
- {/* + {persistentResources.map((resource) => { + const panelVisible = active?.type === resource.type + return ( +
+ {/* A hidden persistent panel can otherwise only INFER it is off screen by measuring itself, which is enough to pause xterm and hide the native view but not to switch off document-wide observers the panel installs. The explicit flag lets it stand those down while hidden. */} + +
+ ) + })} + {active?.type === 'sources' && ( + + )} + {active && active.type !== 'sources' && !isPersistentPanel(active) && ( + navigation.onBrowseResource(active) : undefined} + onCyclePreviewMode={isActivePreviewable ? handleCyclePreview : undefined} + workspaceId={activeWorkspaceId} organizationId={organizationId} desktopScopeId={desktopScopeId} - resource={resource} + resource={active} onSummarize={onSummarize} - visible={panelVisible} - onBrowserOverlayControllerChange={registerBrowserOverlayController} + downloadSourceRef={fileDownloadSourceRef} + onTableViewContextChange={onTableViewContextChange} + previewMode={isActivePreviewable ? previewMode : undefined} + previewSession={previewForActive} + isAgentResponding={isAgentResponding} + previewContextKey={chatId} + onNotFound={(resourceId) => + removeResource('log', resourceId, active.workspaceId) + } /> + + )} + {navigation?.active && navigation.content} + {!active && !navigation?.active && ( +
+ {workspaceId + ? 'Click "+" above to add a resource' + : 'Open a resource from the conversation'}
- ) - })} - {active?.type === 'sources' && ( - - )} - {active && active.type !== 'sources' && !isPersistentPanel(active) && ( - removeResource('log', resourceId, active.workspaceId)} - /> - )} - {!active && ( -
- {workspaceId - ? 'Click "+" above to add a resource' - : 'Open a resource from the conversation'} -
- )} + )} +
-
-
+ + ) }) ) +/** Resources whose own page header (crumbs, rename, delete) serves the pane tab. */ +const OWNS_PAGE_HEADER: ReadonlySet = new Set([ + 'table', + 'knowledgebase', +]) + +interface PaneResourceNavigationProps { + navigation: ResourcePanelNavigation + resource: MothershipResource + children: ReactNode +} + +/** + * Keeps a resource's own navigation inside the pane. A replace means this tab's view is gone + * (its resource was deleted or never existed), so the tab closes; a workflow the editor pushes + * (a duplicate) opens as a tab; lists and other destinations go to the pane's navigation. + */ +function PaneResourceNavigation({ navigation, resource, children }: PaneResourceNavigationProps) { + const { addResource, removeResource } = useMothershipResources() + const queryClient = useQueryClient() + const navigate = useCallback( + (href: string, replace = false) => { + const url = new URL(href, window.location.origin) + const [, scope, workspaceId, section, workflowId] = url.pathname.split('/') + const workflowArea = + url.origin === window.location.origin && + scope === 'workspace' && + Boolean(workspaceId) && + (!section || section === 'w') + const run = + url.origin === window.location.origin && scope === 'workspace' && section === 'logs' + ? runResourceFromHref(url, workspaceId) + : null + if (run) { + addResource(run) + return + } + if (!workflowArea) { + if (replace) removeResource(resource.type, resource.id, resource.workspaceId) + navigation.navigate(href) + return + } + if (replace) { + removeResource(resource.type, resource.id, resource.workspaceId) + return + } + if (!workflowId) { + navigation.navigate(href) + return + } + const workflow = queryClient + .getQueryData(workflowKeys.list(workspaceId)) + ?.find((candidate) => candidate.id === workflowId) + addResource({ + type: 'workflow', + id: workflowId, + workspaceId, + title: workflow?.name ?? 'Workflow', + }) + }, + [navigation, resource, addResource, removeResource, queryClient] + ) + return ( + navigation.onBrowseResource(resource)} + > + {children} + + ) +} + +interface ActiveResourceFrameProps { + navigation?: ResourcePanelNavigation + resource: MothershipResource + children: ReactNode +} + +function ActiveResourceFrame({ navigation, resource, children }: ActiveResourceFrameProps) { + if (!navigation) return children + return ( + + {children} + + ) +} + +/** The full editor under the pane's tab strip, rendering its own header bar into the slot. */ +function PaneHostedWorkflow(props: ComponentProps) { + const [header, setHeader] = useState(null) + const host = useMemo(() => ({ header }), [header]) + return ( +
+
+
+ +
+
+ ) +} + function ScopedResourceContent({ workspaceId, organizationId, onSummarize, + showHeader, + onBrowse, + onCyclePreviewMode, ...props }: Omit, 'workspaceId'> & { workspaceId?: string organizationId?: string + showHeader?: boolean + onBrowse?: () => void + onCyclePreviewMode?: () => void onSummarize: (message: string, filters: WorkspaceSearchFilters) => void }) { if (props.resource.type === 'search') @@ -364,6 +543,33 @@ function ScopedResourceContent({ ) } if (!organizationId) return + const headerAside = ( + <> + {props.resource.type !== 'workflow' && ( + + )} + {onCyclePreviewMode && props.previewMode && ( + + )} + + ) + if (showHeader && props.resource.type === 'workflow') { + return ( + + + + ) + } return ( - + {showHeader && OWNS_PAGE_HEADER.has(props.resource.type) ? ( +
+ +
+ ) : showHeader ? ( +
+ {props.resource.type === 'file' ? ( + + ) : ( + + )} +
+ +
+
+ ) : ( + + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/resource-panel-navigation.ts b/apps/sim/app/workspace/[workspaceId]/home/components/resource-panel-navigation.ts new file mode 100644 index 00000000000..c9ef9dfa37d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/resource-panel-navigation.ts @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' +import type { TabStripItem } from '@sim/emcn' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' + +/** Navigation-only tabs share the strip without becoming agent resources. */ +export interface ResourcePanelNavigation { + control?: ReactNode + /** Whether the control's own view, rather than a tab, fills the panel. */ + controlActive: boolean + tabs: TabStripItem[] + active: boolean + content: ReactNode + onSelect: (id: string) => void + onClose: (id: string) => void + onNew: () => void + onBrowseResource: (resource: MothershipResource) => void + /** Shows a workspace destination a resource navigates to, such as a workflow's logs. */ + navigate: (href: string) => void +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/resource-workspace-host.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/resource-workspace-host.tsx index b0bc5736d1b..8d3718bfcbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/resource-workspace-host.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/resource-workspace-host.tsx @@ -5,6 +5,7 @@ import { Skeleton } from '@sim/emcn' import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader' import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader' import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader' +import { SearchDataLoader } from '@/app/workspace/[workspaceId]/providers/search-data-loader' import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader' import { useOptionalWorkspaceHostContext, @@ -75,6 +76,7 @@ function LoadedResourceWorkspaceHost({ + {children} ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector.tsx index 0052c01aad5..ed850fe25b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/conversation-mode-selector.tsx @@ -8,6 +8,8 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature- interface ConversationModeSelectorProps { value: ChatRequestMode + lookupSelected?: boolean + onSearch?: () => void searchEnabled?: boolean onChange?: (mode: ChatRequestMode) => void } @@ -16,11 +18,24 @@ interface ConversationModeSelectorProps { export function ConversationModeSelector({ value, searchEnabled = false, + lookupSelected = false, + onSearch, onChange, }: ConversationModeSelectorProps) { const planEnabled = useFeatureFlag('mothership-plan-mode') const [open, setOpen] = useState(false) - const options = getConversationModes(searchEnabled, planEnabled) + const options = onSearch + ? [ + { value: 'agent', label: 'Build' }, + ...(planEnabled ? [{ value: 'plan', label: 'Plan' }] : []), + ...(searchEnabled + ? [ + { value: 'assistant', label: 'Ask' }, + { value: 'search', label: 'Search' }, + ] + : []), + ] + : getConversationModes(searchEnabled, planEnabled) if (options.length < 2) return null return ( @@ -31,14 +46,18 @@ export function ConversationModeSelector({ shape='round' aria-label='Conversation mode' options={options} - value={value} + value={lookupSelected ? 'search' : value} disabled={!onChange} align='start' matchTriggerWidth={false} showSelectedCheck={false} onOpenChange={setOpen} onChange={(mode) => { - if (mode === value) return + if (mode === 'search' && searchEnabled && onSearch) { + onSearch() + return + } + if (mode === value && !lookupSelected) return if ( mode === 'agent' || (mode === 'assistant' && searchEnabled) || diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 56e54e485b4..6a8059de916 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -8,6 +8,9 @@ import { DropdownMenuItemLabel, DropdownMenuLabel, DropdownMenuSearchInput, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, dropdownMenuRowClass, OverflowText, @@ -45,6 +48,8 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { useOrganizationProjects } from '@/hooks/queries/projects' import { useOrderedWorkspacesQuery, type Workspace } from '@/hooks/queries/workspace' import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands' import { useBrowserSessionStore } from '@/stores/browser-session/store' @@ -119,6 +124,28 @@ interface PlusMenuDropdownProps { mentionQuery?: string } +interface ProjectGroup { + id: string + name: string + /** Root first, in pipeline order. */ + workspaces: T[] +} + +/** The listed workspaces grouped into their projects, in project order. */ +function groupWorkspacesByProject( + projects: readonly { id: string; name: string; environments: readonly { id: string }[] }[], + workspaces: readonly T[] +): ProjectGroup[] { + const byId = new Map(workspaces.map((workspace) => [workspace.id, workspace])) + return projects.flatMap((project) => { + const members = project.environments.flatMap((member) => { + const workspace = byId.get(member.id) + return workspace ? [workspace] : [] + }) + return members.length ? [{ id: project.id, name: project.name, workspaces: members }] : [] + }) +} + export const PlusMenuDropdown = React.memo( React.forwardRef(function PlusMenuDropdown( { @@ -158,8 +185,9 @@ export const PlusMenuDropdown = React.memo( includeFolderMentions: true, excludeTypes: COMPOSER_EXCLUDED_TYPES, }) + /** Every workspace's lists is a burst no focus should trigger, so org scope loads on open. */ const { data: allWorkspaces, isPending: workspacesPending } = useOrderedWorkspacesQuery( - Boolean(organizationId) && inventoryEnabled + Boolean(organizationId) && open ) const workspaces = useMemo( () => @@ -168,6 +196,15 @@ export const PlusMenuDropdown = React.memo( : [], [allWorkspaces, organizationId] ) + /** With the org project view, the menu opens on projects, each revealing its workspaces. */ + const projectViewEnabled = useFeatureFlag('org-project-view') + const { data: projects = [] } = useOrganizationProjects(organizationId ?? '', { + enabled: projectViewEnabled, + }) + const projectGroups = useMemo( + () => groupWorkspacesByProject(projects, workspaces), + [projects, workspaces] + ) const [inventories, setInventories] = useState>({}) const receiveInventory = useCallback((workspaceId: string, inventory: AvailableResources) => { setInventories((current) => @@ -409,7 +446,7 @@ export const PlusMenuDropdown = React.memo( return ( {organizationId && - inventoryEnabled && + open && workspaces.map((workspace) => (
{/* Tabs */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx new file mode 100644 index 00000000000..d504eaeb49b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx @@ -0,0 +1,69 @@ +'use client' + +import { Search } from '@sim/emcn/icons' +import { useQuery } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { + breadcrumbFolderChain, + folderBreadcrumbItems, +} from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { useFolderMap } from '@/hooks/queries/folders' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' + +interface WorkflowLocationBarProps { + workspaceId: string + workflowId: string +} + +/** Keeps the open workflow identifiable when Build uses section-level navigation. */ +export function WorkflowLocationBar(props: WorkflowLocationBarProps) { + const enabled = useFeatureFlag('org-project-view') + return enabled ? : null +} + +function WorkflowLocationBarContent({ workspaceId, workflowId }: WorkflowLocationBarProps) { + const router = useRouter() + /** The list's query options, not the workflows hooks: that module carries the trigger registry. */ + const { data: workflow } = useQuery({ + ...getWorkflowListQueryOptions(workspaceId), + select: (list) => list.find((candidate) => candidate.id === workflowId), + }) + const { data: folders } = useFolderMap(workspaceId) + /** The search modal's command, not its store: the store carries the block registry. */ + const invokeCommand = useInvokeGlobalCommand() + const openSearch = () => { + invokeCommand('open-search') + } + const ancestors = breadcrumbFolderChain( + workflow?.folderId, + new Map(Object.values(folders ?? {}).map((folder) => [folder.id, folder])) + ) + const breadcrumbs = folderBreadcrumbItems({ + rootLabel: 'Workflows', + breadcrumbs: ancestors, + onNavigate: (folderId) => { + const query = folderId ? `?folderId=${encodeURIComponent(folderId)}` : '' + router.push(`/workspace/${workspaceId}/w${query}`) + }, + trailing: [{ label: workflow?.name ?? 'Workflow', onClick: openSearch }], + }) + + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx index 9cec2d2449c..2e3473a44d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { WorkflowLocationBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar' import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow' export const metadata: Metadata = { @@ -9,7 +10,18 @@ interface WorkflowPageProps { params: Promise<{ workspaceId: string; workflowId: string }> } +/** + * The canvas, under the location bar the project Build view shows. The bar mounts here rather + * than in the layout, whose module graph stays free of the canvas's query and registry modules. + */ export default async function WorkflowPage({ params }: WorkflowPageProps) { const { workspaceId, workflowId } = await params - return + return ( + <> + +
+ +
+ + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/pane-host.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/pane-host.ts new file mode 100644 index 00000000000..eedecda6a71 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/pane-host.ts @@ -0,0 +1,8 @@ +/** + * A containing pane that hosts the full editor beside its own chat. The editor renders its + * header bar (workflow actions, Deploy, Run) into `header` and leaves out its Chat tab. + */ +export interface WorkflowPaneHost { + /** The pane's header slot; null until the pane has mounted it. */ + header: HTMLElement | null +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 686a7accb11..8b93f9aa690 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -57,6 +57,7 @@ import { requestNoteImage, requestNoteRename } from '@/lib/workflows/notes/canva import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { useResourceRouter } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' +import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { WorkflowScopeProvider } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -89,6 +90,7 @@ import { useShiftSelectionLock, useWorkflowExecution, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' +import type { WorkflowPaneHost } from '@/app/workspace/[workspaceId]/w/[workflowId]/pane-host' import { calculateContainerDimensions, clampPositionToContainer, @@ -317,10 +319,11 @@ interface WorkflowContentProps { workspaceId: string workflowId: string embedded?: boolean + host?: WorkflowPaneHost } const WorkflowContent = React.memo( - ({ workspaceId, workflowId: workflowIdParam, embedded }: WorkflowContentProps) => { + ({ workspaceId, workflowId: workflowIdParam, embedded, host }: WorkflowContentProps) => { const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) const [selectedEdges, setSelectedEdges] = useState(new Map()) @@ -1560,9 +1563,12 @@ const WorkflowContent = React.memo( return { canRun: true, reason: undefined } }, [contextMenuBlocks, edges, lastExecutionSnapshot, isExecuting]) + const invokeGlobalCommand = useInvokeGlobalCommand() + /** A hosting pane has no command palette; its panel's block search takes the request. */ const handleContextAddBlock = useCallback(() => { - useSearchModalStore.getState().open() - }, []) + if (host) invokeGlobalCommand('focus-toolbar-search') + else useSearchModalStore.getState().open() + }, [host, invokeGlobalCommand]) const handleContextOpenLogs = useCallback(() => { router.push(`/workspace/${workspaceId}/logs?workflowIds=${workflowIdParam}`) @@ -5313,7 +5319,7 @@ const WorkflowContent = React.memo(
- {!embedded && } + {!embedded && }
{!embedded && oauthModal && oauthModalRequiresDraft && ( @@ -5363,14 +5369,21 @@ interface WorkflowProps { workspaceId: string workflowId: string embedded?: boolean + /** Set when a pane hosts the full editor; see {@link WorkflowPaneHost}. */ + host?: WorkflowPaneHost } -const Workflow = React.memo(({ workspaceId, workflowId, embedded }: WorkflowProps) => { +const Workflow = React.memo(({ workspaceId, workflowId, embedded, host }: WorkflowProps) => { return ( - + diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx new file mode 100644 index 00000000000..90180487982 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useEffect } from 'react' +import { Chip } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { ReactFlowProvider } from '@xyflow/react' +import { useParams, useRouter } from 'next/navigation' +import { ApplicationLoading } from '@/app/_shell/application-loading' +import { WorkflowScopeProvider } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider' +import { useWorkflows } from '@/hooks/queries/workflows' + +const logger = createLogger('WorkflowsPage') + +/** + * The `/w` route's classic behavior: land on the workspace's first workflow. Renders the + * canvas chrome while the list resolves so the redirect never flashes an empty frame, and + * offers a way out when the workspace has no workflows at all. + */ +export function FirstWorkflowRedirect() { + const brand = useOrgBrandConfig() + const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string + + const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) + const { handleCreateWorkflow, isCreatingWorkflow } = useWorkflowOperations({ workspaceId }) + const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + + // An id rather than the filtered array: `data` defaults to a fresh `[]` while + // the query has no data, so an array dependency would re-fire this on every + // render — exactly during the load this page exists to cover. + const firstWorkflowId = workflows.find((w) => w.workspaceId === workspaceId)?.id + const isResolving = isLoading || isPlaceholderData + + useEffect(() => { + if (isResolving) return + + if (isError) { + logger.error('Failed to load workflows for workspace') + return + } + + if (firstWorkflowId) { + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) + } + }, [isResolving, isError, firstWorkflowId, workspaceId, router]) + + /** + * A workspace can legitimately reach zero workflows — deleting the last one, + * archiving them all, or creating a workspace with `skipDefaultWorkflow`. This + * is the terminal state for those paths now that the chat composer is no + * longer a landing option, so it has to offer a way out rather than spin. + */ + const isEmpty = !isResolving && !isError && !firstWorkflowId + const canCreate = !permissionsLoading && canEdit + + return ( +
+
+
+ {isError ? ( + // This is the landing route now, so a failed list fetch would + // otherwise spin forever with nothing but a log line. +
+
+

Couldn't load workflows

+

Check your connection and try again.

+
+ router.refresh()}> + Retry + +
+ ) : isEmpty ? ( +
+
+

No workflows yet

+

+ {canCreate + ? 'Create one to start building.' + : 'Ask a workspace admin to create one.'} +

+
+ {/* The create mutation navigates optimistically, so offering it + without write access would strand a read-only member on a + workflow the server declined to create. */} + {canCreate && ( + + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + + )} +
+ ) : ( + + )} +
+ + + + + +
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index e735e674e89..56cf307c647 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -19,3 +19,4 @@ export { SidebarTooltip } from './sidebar-tooltip' export { StatusNotice } from './status-notice' export { WorkflowList } from './workflow-list' export { WorkspaceHeader } from './workspace-header' +export { WorkspaceSidebarModals } from './workspace-sidebar-modals' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 20fa333126e..7878e50fdfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -6,12 +6,16 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' import { Download, Duplicate, Eye, + FolderInput, FolderPlus, ImageUp, Lock, @@ -26,9 +30,15 @@ import { Unlock, X, } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label' interface ContextMenuProps { + /** Files the item under another folder; the value is a folder id or the root sentinel. */ + onMove?: (optionValue: string) => void + /** Destinations for `onMove`; the submenu renders only when this is non-empty. */ + moveOptions?: MoveOptionNode[] isOpen: boolean position: { x: number; y: number } menuRef: React.RefObject @@ -131,6 +141,8 @@ export function ContextMenu({ onCreate, onCreateFolder, onDuplicate, + onMove, + moveOptions, onExport, onDelete, onCloseTab, @@ -170,6 +182,8 @@ export function ContextMenu({ disableUploadLogo = false, selectedCount = 1, }: ContextMenuProps) { + /** `Move to` needs a NON-EMPTY `moveOptions`, not just the handler, to earn its row. */ + const hasMoveAction = !!(onMove && moveOptions && moveOptions.length > 0) const hasActionsAboveDestructive = onCopyLink || (showOpenInNewTab && onOpenInNewTab) || @@ -182,6 +196,7 @@ export function ContextMenu({ (showLock && onToggleLock) || (showUploadLogo && onUploadLogo) || (showDuplicate && onDuplicate) || + hasMoveAction || (showExport && onExport) const hasDestructiveSection = (showLeave && onLeave) || @@ -366,6 +381,20 @@ export function ContextMenu({ {selectionActionLabel('Duplicate', selectedCount)} )} + {hasMoveAction && onMove && moveOptions && ( + + + + {selectionActionLabel('Move', selectedCount, 'Move to')} + + + {renderMoveOptions(moveOptions, (value) => { + onMove(value) + onClose() + })} + + + )} {showExport && onExport && ( void onConfirm: (name: string) => Promise isCreating: boolean + /** What the modal creates; organization Home creates projects. */ + resource?: 'workspace' | 'project' } /** - * Modal for naming a new workspace before creation. + * Modal for naming a new workspace or project before creation. */ export function CreateWorkspaceModal({ open, onOpenChange, onConfirm, isCreating, + resource = 'workspace', }: CreateWorkspaceModalProps) { + const title = `Create ${resource}` const [name, setName] = useState('') const [error, setError] = useState(null) @@ -47,7 +49,7 @@ export function CreateWorkspaceModal({ try { await onConfirm(trimmed) } catch (err) { - setError(getErrorMessage(err, 'Failed to create workspace')) + setError(getErrorMessage(err, `Failed to create ${resource}`)) } } @@ -57,15 +59,15 @@ export function CreateWorkspaceModal({ } return ( - - onOpenChange(false)}>{TITLE} + + onOpenChange(false)}>{title}
+ {entry.kind === 'env-var' && ( + controller.setTarget(entry, name)} + /> + )} {canEditConfigured ? (
setShowConfigured((value) => !value)}> @@ -812,9 +835,41 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) { ) } +interface ForkCopyResourcesProps { + controller: ForkSyncController +} + +/** Unmapped source resources the sync can copy into the target, used ones first. */ +export function ForkCopyResources({ controller }: ForkCopyResourcesProps) { + return ( +
+ {controller.referencedByKind.size > 0 ? ( + + ) : null} + {controller.unreferencedByKind.size > 0 ? ( + <> + {controller.referencedByKind.size > 0 ? ( +
+ Not used by any workflow +
+ ) : null} + + + ) : null} +
+ ) +} + interface ForkSyncViewProps { controller: ForkSyncController - onDirectionChange: (direction: ForkDirection) => void + /** Resource mappings are edited by the parent surface. */ + externalMappings?: boolean + /** The parent surface lists the deployed-workflow changes, opens their comparison, and shows load errors. */ + externalWorkflows?: boolean + /** The parent surface renders the copy checklist ({@link ForkCopyResources}) as its own step. */ + externalCopy?: boolean + /** Omit when the parent owns the direction control. */ + onDirectionChange?: (direction: ForkDirection) => void } /** @@ -823,7 +878,13 @@ interface ForkSyncViewProps { * 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) { +export function ForkSyncView({ + controller, + onDirectionChange, + externalMappings = false, + externalWorkflows = false, + externalCopy = false, +}: ForkSyncViewProps) { const detailsError = controller.errorMessage ?? controller.diffErrorMessage const headsUp = controller.mcpReauthCount > 0 || @@ -849,7 +910,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp return (
- {controller.comparisonSelection ? ( + {controller.comparisonSelection && !externalWorkflows ? ( ) : null} - -
- -

- {controller.direction === 'push' - ? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.` - : `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`} -

-
-
+ {onDirectionChange && ( + +
+ +

+ {controller.direction === 'push' + ? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.` + : `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`} +

+
+
+ )} {/* Surface a failed/pending fetch so the page never renders blank below the direction. */} - {detailsError ? ( + {externalWorkflows ? null : detailsError ? (
{detailsError}
@@ -891,7 +954,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp deployed workflow appears here, changed or not), so the muted state nudges a deploy. Unsynced workflows list greyed at the end, with a tooltip naming which workspace they are unsynced in - the sync will not touch them. */} - {controller.hasDiff ? ( + {controller.hasDiff && !externalWorkflows ? ( {controller.workflowChanges.length + excludedRows.length > 0 ? ( @@ -998,7 +1061,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp ) : null} - {controller.hasMapping ? ( + {controller.hasMapping && !externalMappings ? ( {controller.groups.length > 0 ? (
@@ -1037,23 +1100,9 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp ) : null} - {controller.hasVisibleCopyables ? ( + {controller.hasVisibleCopyables && !externalCopy ? ( -
- {controller.referencedByKind.size > 0 ? ( - - ) : null} - {controller.unreferencedByKind.size > 0 ? ( - <> - {controller.referencedByKind.size > 0 ? ( -
- Not used by any workflow -
- ) : null} - - - ) : null} -
+
) : null} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index c6775a5481f..e25d03ba6b9 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -8,6 +8,7 @@ import type { ForkCopyableUnmapped, ForkDependentReconfig, ForkMappingEntry, + ForkMappingScope, ForkResourceUsage, ForkTriggerMapping, ForkTriggerUrlChange, @@ -61,7 +62,8 @@ import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/syn export type MappableMappingKind = Exclude /** Section label + display order per mapping kind (one mapping group per kind). */ -const MAPPING_SECTION: Record = { +/** Label and display order per mapping kind; the project Environments tab reads the same order. */ +export const MAPPING_SECTION: Record = { sandbox: { label: 'Sandboxes', order: 12 }, credential: { label: 'Credentials', order: 0 }, 'env-var': { label: 'Secrets', order: 1 }, @@ -122,6 +124,11 @@ export interface ForkSyncController { diffErrorMessage: string | null /** True once the diff payload for ANY direction is present (placeholder included). */ hasDiff: boolean + /** + * The diff on screen is still the previous direction's placeholder, so its rows name + * workflows that are not part of the sync now selected. + */ + diffIsStale: boolean /** True once the mapping payload is present (placeholder included), gating the Mappings section. */ hasMapping: boolean groups: ForkMappingGroup[] @@ -196,6 +203,9 @@ export interface ForkSyncController { comparisonSelection: ForkComparisonSelection | null openComparison: (change: Exclude) => void closeComparison: () => void + /** The workspace the sync page is open in, and its partner on this edge. */ + workspaceId: string + otherWorkspaceId?: string /** Names of target workflows this sync archives, for the confirm modal. */ archivedWorkflowNames: string[] /** @@ -299,6 +309,7 @@ function takenTargetOwners( * and blockers all depend on the direction. Placeholder copy controls remain read-only. */ export function useForkSync(params: { + mappingScope?: ForkMappingScope workspaceId: string /** This workspace's name, for copy that must say which side a pull overwrites. */ workspaceName?: string @@ -349,7 +360,13 @@ export function useForkSync(params: { setReviewedSourceVersions({}) } - const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled }) + const mapping = useForkMapping({ + workspaceId, + otherWorkspaceId, + direction, + enabled, + scope: params.mappingScope, + }) const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled }) const updateMapping = useUpdateForkMapping() const promote = usePromoteFork() @@ -1076,6 +1093,7 @@ export function useForkSync(params: { : null, hasDiff: Boolean(diff.data), comparisonReady, + diffIsStale: diff.isPlaceholderData, hasMapping: Boolean(mapping.data), groups, kindSummaries, @@ -1108,6 +1126,8 @@ export function useForkSync(params: { comparisonSelection, openComparison, closeComparison, + workspaceId, + otherWorkspaceId, archivedWorkflowNames, triggerUrlChanges, triggerMappings, diff --git a/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx b/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx index 4d6fe855875..40ae91a166a 100644 --- a/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal.tsx @@ -9,6 +9,7 @@ import { ChipModalError, ChipModalFooter, ChipModalHeader, + ChipSelect, toast, } from '@sim/emcn' import { TriangleAlert } from '@sim/emcn/icons' @@ -26,6 +27,11 @@ interface ForkWorkspaceModalProps { onOpenChange: (open: boolean) => void sourceWorkspaceId: string sourceWorkspaceName: string + /** + * Other workspaces the fork may be taken from, such as the environments of a project. With + * more than one, "Forking from" becomes a picker that starts on `sourceWorkspaceId`. + */ + sources?: readonly { id: string; name: 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. */ @@ -75,12 +81,15 @@ export function ForkWorkspaceModal({ onOpenChange, sourceWorkspaceId, sourceWorkspaceName, + sources, canFork, onUpgrade, }: ForkWorkspaceModalProps) { const router = useRouter() const forkWorkspace = useForkWorkspace() - const resources = useForkResources(sourceWorkspaceId, open) + const [sourceId, setSourceId] = useState(sourceWorkspaceId) + const sourceName = sources?.find((source) => source.id === sourceId)?.name ?? sourceWorkspaceName + const resources = useForkResources(sourceId, open) const [name, setName] = useState('') const [selected, setSelected] = useState(emptySelection) const [defaulted, setDefaulted] = useState(false) @@ -88,12 +97,23 @@ export function ForkWorkspaceModal({ useEffect(() => { if (open) { - setName(`${sourceWorkspaceName} (fork)`) + setSourceId(sourceWorkspaceId) setSelected(emptySelection()) setDefaulted(false) setError(null) } - }, [open, sourceWorkspaceName]) + }, [open, sourceWorkspaceId]) + + /** The default name follows the chosen source until the user types one of their own. */ + useEffect(() => { + if (open) setName(`${sourceName} (fork)`) + }, [open, sourceName]) + + const handleSourceChange = (nextSourceId: string) => { + setSourceId(nextSourceId) + setSelected(emptySelection()) + setDefaulted(false) + } useEffect(() => { if (!open || !resources.data || defaulted) return @@ -141,7 +161,7 @@ export function ForkWorkspaceModal({ RESOURCE_KINDS.map((kind) => [kind.key, Array.from(selected[kind.key])]) ) forkWorkspace.mutate( - { workspaceId: sourceWorkspaceId, body: { name: trimmed, copy } }, + { workspaceId: sourceId, body: { name: trimmed, copy } }, { onSuccess: (result) => { // The copy job's progress lands in the page's Activity log; the toast action @@ -165,7 +185,20 @@ export function ForkWorkspaceModal({
- + {sources && sources.length > 1 ? ( + ({ value: source.id, label: source.name }))} + /> + ) : ( + + )} [...forkKeys.all, 'lineage'] as const, lineage: (workspaceId?: string) => [...forkKeys.lineages(), workspaceId ?? ''] as const, mappings: () => [...forkKeys.all, 'mapping'] as const, - mapping: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) => - [...forkKeys.mappings(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const, + mapping: ( + workspaceId?: string, + otherWorkspaceId?: string, + direction?: ForkDirection, + scope: ForkMappingScope = 'sync' + ) => + [ + ...forkKeys.mappings(), + workspaceId ?? '', + otherWorkspaceId ?? '', + direction ?? '', + scope, + ] as const, diffs: () => [...forkKeys.all, 'diff'] as const, diff: (workspaceId?: string, otherWorkspaceId?: string, direction?: ForkDirection) => [...forkKeys.diffs(), workspaceId ?? '', otherWorkspaceId ?? '', direction ?? ''] as const, @@ -52,9 +69,9 @@ export const forkKeys = { } export const WORKSPACE_FORK_RESOURCES_STALE_TIME = 30 * 1000 -export const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000 -export const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000 -export const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000 +const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000 +const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000 +const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000 export function useForkResources(workspaceId?: string, enabled = true) { return useQuery({ @@ -90,6 +107,8 @@ export function useForkWorkspace() { mutationFn: (vars: { workspaceId: string; body: ForkWorkspaceBody }) => requestJson(forkWorkspaceContract, { params: { id: vars.workspaceId }, body: vars.body }), onSuccess: (data) => { + /** A fork joins its parent's project. */ + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) // Merge the new fork into the active list cache before invalidation so the // immediate navigation into it can't race a stale list and trip the // not-in-workspaces redirect (mirrors useCreateWorkspace). @@ -110,6 +129,7 @@ export function useForkWorkspace() { }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() }) + queryClient.invalidateQueries({ queryKey: projectKeys.all }) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) @@ -122,14 +142,19 @@ export function useForkMapping(args: { workspaceId?: string otherWorkspaceId?: string direction: ForkDirection + scope?: ForkMappingScope enabled?: boolean }) { return useQuery({ - queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction), + queryKey: forkKeys.mapping(args.workspaceId, args.otherWorkspaceId, args.direction, args.scope), queryFn: ({ signal }) => requestJson(getForkMappingContract, { params: { id: args.workspaceId as string }, - query: { otherWorkspaceId: args.otherWorkspaceId as string, direction: args.direction }, + query: { + otherWorkspaceId: args.otherWorkspaceId as string, + direction: args.direction, + scope: args.scope, + }, signal, }), enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true), @@ -199,8 +224,9 @@ export function useUnlinkFork() { requestJson(unlinkForkContract, { params: { id: vars.workspaceId }, body: vars.body }), onSettled: () => { // Unlink dissolves the edge: lineage loses the row, and the edge's mappings/diff - // no longer exist. Workflows and deployments are untouched. + // no longer exist, and the fork's subtree leaves for a Project of its own. queryClient.invalidateQueries({ queryKey: forkKeys.lineages() }) + queryClient.invalidateQueries({ queryKey: projectKeys.all }) queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }) queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }) queryClient.invalidateQueries({ queryKey: backgroundWorkKeys.lists() }) @@ -311,3 +337,27 @@ export function useUpdateForkSyncDefault() { }, }) } + +/** Creates the secret and mapping together, then refreshes both environments' views. */ +export function useCreateForkSecretMapping() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (variables: { workspaceId: string; body: CreateForkSecretMappingBody }) => + requestJson(createForkSecretMappingContract, { + params: { id: variables.workspaceId }, + body: variables.body, + }), + onSettled: async (_data, _error, variables) => { + const targetId = + variables.body.direction === 'push' + ? variables.body.otherWorkspaceId + : variables.workspaceId + await Promise.all([ + queryClient.invalidateQueries({ queryKey: forkKeys.mappings() }), + queryClient.invalidateQueries({ queryKey: forkKeys.diffs() }), + queryClient.invalidateQueries({ queryKey: environmentKeys.workspace(targetId) }), + queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() }), + ]) + }, + }) +} diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-browser.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-browser.test.ts new file mode 100644 index 00000000000..76f5428bde2 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-browser.test.ts @@ -0,0 +1,76 @@ +/** @vitest-environment node */ +import { + workspaceForkingMappingStoreMock, + workspaceForkingMappingStoreMockFns, +} from '@sim/testing/mocks/workspace-forking-mapping-store.mock' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ deployed: vi.fn(), candidates: vi.fn() })) +vi.mock('@/lib/workflows/references/resources', () => ({ + CANDIDATE_LIMIT: 1000, + getWorkspaceEnvKeys: async () => new Set(['PARENT_TOKEN', 'CHILD_TOKEN']), + listForkResourceCandidates: mocks.candidates, + loadForkResourceLabels: async () => ({}), + getCredentialProvidersByIds: async () => new Map(), + filterExistingForkTargets: async () => ({}), + classifyCredentialResourceType: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + listDeployedWorkflows: mocks.deployed, + readDeployedState: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/cascade', () => ({ + detectForkCascadeReferences: async () => ({ references: [] }), +})) +vi.mock('@/lib/workflows/references/reference-scan', () => ({ toScannerBlocks: vi.fn() })) +vi.mock('@/lib/workflows/references/remap-references', () => ({ scanWorkflowReferences: vi.fn() })) +vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ + resolveForkExcludedTargetId: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => workspaceForkingMappingStoreMock) + +import { getForkMappingView } from '@/ee/workspace-forking/lib/mapping/mapping-service' + +const edge = { parentWorkspaceId: 'parent', childWorkspaceId: 'child' } +beforeEach(() => { + mocks.deployed.mockResolvedValue([]) + workspaceForkingMappingStoreMockFns.mockGetEdgeMappingRows.mockResolvedValue([ + { resourceType: 'env_var', parentResourceId: 'PARENT_TOKEN', childResourceId: 'CHILD_TOKEN' }, + { resourceType: 'workflow', parentResourceId: 'wf-parent', childResourceId: 'wf-child' }, + ]) + mocks.candidates.mockResolvedValue({ + 'env-var': [ + { id: 'PARENT_TOKEN', label: 'PARENT_TOKEN' }, + { id: 'CHILD_TOKEN', label: 'CHILD_TOKEN' }, + ], + }) +}) +it('keeps the default sync view empty without deployed references', async () => { + const result = await getForkMappingView({ + edge, + sourceWorkspaceId: 'parent', + targetWorkspaceId: 'child', + }) + expect(result.entries).toEqual([]) +}) +it.each([ + ['parent', 'child', 'PARENT_TOKEN', 'CHILD_TOKEN'], + ['child', 'parent', 'CHILD_TOKEN', 'PARENT_TOKEN'], +])( + 'shows saved mappings with no deployments from %s to %s', + async (sourceWorkspaceId, targetWorkspaceId, sourceId, targetId) => { + const result = await getForkMappingView({ + edge, + sourceWorkspaceId, + targetWorkspaceId, + scope: 'all', + }) + expect(result.entries).toHaveLength(1) + expect(result.entries[0]).toMatchObject({ + sourceId, + targetId, + required: false, + suggested: false, + }) + } +) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts index 89dd63725aa..eaa7b4596f1 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts @@ -1,7 +1,11 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' +import type { + ForkMappableResourceType, + ForkMappingEntry, + ForkMappingScope, +} from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { toScannerBlocks } from '@/lib/workflows/references/reference-scan' import { @@ -39,6 +43,7 @@ import { import { resolveForkExcludedTargetId } from '@/ee/workspace-forking/lib/promote/promote-plan' interface ForkMappingViewParams { + scope?: ForkMappingScope edge: ForkEdge sourceWorkspaceId: string targetWorkspaceId: string @@ -145,6 +150,25 @@ export async function getForkMappingView( for (const reference of cascade.references) { referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference) } + const syncReferenceKeys = new Set(referenceByKey.keys()) + /** Stored mappings remain inspectable even when no current deployment references them. */ + if (params.scope === 'all') { + for (const row of mappingRows) { + if ( + row.resourceType === 'workflow' || + row.resourceType === 'workflow_mcp_server' || + row.resourceType === 'knowledge_document' + ) + continue + const sourceId = sourceIsParent ? row.parentResourceId : row.childResourceId + if (!sourceId) continue + const kind = resourceTypeToForkKind(row.resourceType) + if (!kind) continue + const key = `${kind}:${sourceId}` + if (!referenceByKey.has(key)) + referenceByKey.set(key, { kind, sourceId, subBlockKey: '', required: false }) + } + } const references: ForkReference[] = Array.from(referenceByKey.values()) // Source-side labels and credential providers, both looked up by EXACT ID (never the capped @@ -184,7 +208,12 @@ export async function getForkMappingView( for (const reference of references) { // Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source // workspace env var is a personal (user-scoped) secret - leave it as-is. - if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue + if ( + reference.kind === 'env-var' && + !sourceEnvKeys.has(reference.sourceId) && + params.scope !== 'all' + ) + continue // Knowledge documents are not a standalone mappable kind: a document is a dependent field // of its knowledge base (the `document-selector` dependsOn the KB selector), re-picked in // that KB's reconfigure flow and auto-remapped when the KB is copied. So a document never @@ -205,8 +234,9 @@ export async function getForkMappingView( ? reference.sourceId : (sourceLabels[reference.kind]?.get(reference.sourceId) ?? reference.sourceId) const sourceDeleted = - reference.kind !== 'env-var' && - !(sourceLabels[reference.kind]?.has(reference.sourceId) ?? false) + reference.kind === 'env-var' + ? !sourceEnvKeys.has(reference.sourceId) + : !(sourceLabels[reference.kind]?.has(reference.sourceId) ?? false) const sourceProviderId = sourceProviders.get(reference.sourceId) ?? undefined // A credential reference only maps to a target credential of the SAME OAuth // provider - a Gmail (google-email) reference must never offer a Google Calendar @@ -278,12 +308,8 @@ export async function getForkMappingView( sourceDeleted: p.sourceDeleted, targetId, suggested, - // Every entry here is a reference a synced workflow actually carries, and a sync is - // blocked while ANY reference would clear - so every entry is required. Copyable kinds - // (table / KB / file / custom tool / skill) also satisfy the gate by being selected for - // copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources - // (no copy candidate) must be mapped. - required: true, + /** Saved-only mappings do not become requirements of the current sync. */ + required: syncReferenceKeys.has(`${p.reference.kind}:${p.reference.sourceId}`), candidates, // The full (unfiltered) target list for this kind hit the cap, so the picker is // showing a partial list - the UI tells the user to refine. diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 754f8df5236..55a499cc362 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -234,6 +234,16 @@ async function fetchKnowledgeChunks( return { chunks, pagination } } +/** Shared list options, for callers that read several workspaces' lists at once. */ +export function getKnowledgeBasesQueryOptions(workspaceId: string) { + return { + queryKey: knowledgeKeys.list(workspaceId, 'active'), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchKnowledgeBases(workspaceId, 'active', signal, false), + staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, + } +} + export function useKnowledgeBasesQuery( workspaceId?: string, options?: { diff --git a/apps/sim/hooks/queries/projects.ts b/apps/sim/hooks/queries/projects.ts index 97397ee4c04..2823973f50a 100644 --- a/apps/sim/hooks/queries/projects.ts +++ b/apps/sim/hooks/queries/projects.ts @@ -1,14 +1,20 @@ -import { useInfiniteQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' -import { listProjectsContract } from '@/lib/api/contracts/projects' +import { + archiveProjectContract, + createProjectContract, + getWorkspaceProjectContract, + listProjectsContract, + renameProjectContract, +} from '@/lib/api/contracts/projects' +import { projectKeys } from '@/hooks/queries/utils/project-keys' +import { workspaceKeys } from '@/hooks/queries/workspace' const PROJECT_LIST_STALE_TIME = 30_000 -const projectKeys = { - all: ['projects'] as const, - lists: () => [...projectKeys.all, 'list'] as const, - list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const, -} +const PROJECT_DETAIL_STALE_TIME = 30_000 +/** The list endpoint's page ceiling; reading every project walks the cursor in pages this size. */ +const PROJECT_PAGE_LIMIT = 100 export function useProjects(organizationId: string) { return useInfiniteQuery({ @@ -16,7 +22,7 @@ export function useProjects(organizationId: string) { initialPageParam: null as string | null, queryFn: ({ signal, pageParam }) => requestJson(listProjectsContract, { - query: { organizationId, cursor: pageParam ?? undefined, limit: 100 }, + query: { organizationId, cursor: pageParam ?? undefined, limit: PROJECT_PAGE_LIMIT }, signal, }), getNextPageParam: (page) => page.nextCursor, @@ -30,3 +36,87 @@ export function useProjects(organizationId: string) { enabled: Boolean(organizationId), }) } + +async function fetchAllProjects(organizationId: string, signal: AbortSignal) { + const projects = [] + let cursor: string | undefined + do { + const page = await requestJson(listProjectsContract, { + query: { organizationId, cursor, limit: PROJECT_PAGE_LIMIT }, + signal, + }) + projects.push(...page.projects) + cursor = page.nextCursor ?? undefined + } while (cursor) + return projects +} + +/** Every active project in the organization the viewer can see, for surfaces that list them all. */ +export function useOrganizationProjects(organizationId: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: projectKeys.complete(organizationId), + queryFn: ({ signal }) => fetchAllProjects(organizationId, signal), + staleTime: PROJECT_LIST_STALE_TIME, + enabled: Boolean(organizationId) && (options?.enabled ?? true), + }) +} + +/** The project a workspace is an environment of. */ +export function useWorkspaceProject(workspaceId: string) { + return useQuery({ + queryKey: projectKeys.byWorkspace(workspaceId), + queryFn: async ({ signal }) => { + const { project } = await requestJson(getWorkspaceProjectContract, { + params: { workspaceId }, + signal, + }) + return project + }, + staleTime: PROJECT_DETAIL_STALE_TIME, + enabled: Boolean(workspaceId), + }) +} + +/** Creates a project with its first environment, `main`. */ +export function useCreateProject() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars: { organizationId: string; name: string }) => + requestJson(createProjectContract, { + body: { + organizationId: vars.organizationId, + name: vars.name, + initialEnvironment: { name: 'main' }, + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) + queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + }, + }) +} + +export function useRenameProject() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars: { projectId: string; name: string }) => + requestJson(renameProjectContract, { + params: { id: vars.projectId }, + body: { name: vars.name }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: projectKeys.all }), + }) +} + +/** Archives the project with every environment in it. */ +export function useArchiveProject() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (vars: { projectId: string }) => + requestJson(archiveProjectContract, { params: { id: vars.projectId } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }) + queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + }, + }) +} diff --git a/apps/sim/hooks/queries/resource-search.ts b/apps/sim/hooks/queries/resource-search.ts new file mode 100644 index 00000000000..c00578bc8be --- /dev/null +++ b/apps/sim/hooks/queries/resource-search.ts @@ -0,0 +1,61 @@ +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ResourceSearchType, + searchOrganizationResourcesContract, +} from '@/lib/api/contracts/resource-search' + +const RESOURCE_SEARCH_STALE_TIME = 30_000 + +interface OrganizationResourceSearch { + query: string + types: readonly ResourceSearchType[] + workspaceIds: readonly string[] +} + +const resourceSearchKeys = { + all: ['resource-search'] as const, + organizations: () => [...resourceSearchKeys.all, 'organization'] as const, + organization: ( + organizationId: string, + { query, types, workspaceIds }: OrganizationResourceSearch + ) => + [ + ...resourceSearchKeys.organizations(), + organizationId, + query, + [...types].sort().join(','), + [...workspaceIds].sort().join(','), + ] as const, +} + +/** + * Resources named like `query`, narrowed by type and workspace, across the organization's + * workspaces the viewer can open, a page at a time. Keeps the previous results while the next + * search loads, so typing does not flash an empty list. + */ +export function useOrganizationResourceSearch( + organizationId: string, + search: OrganizationResourceSearch, + options: { enabled: boolean } +) { + return useInfiniteQuery({ + queryKey: resourceSearchKeys.organization(organizationId, search), + queryFn: ({ pageParam, signal }) => + requestJson(searchOrganizationResourcesContract, { + params: { id: organizationId }, + query: { + q: search.query || undefined, + types: search.types.length ? [...search.types] : undefined, + workspaceIds: search.workspaceIds.length ? [...search.workspaceIds] : undefined, + cursor: pageParam, + }, + signal, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.nextCursor ?? undefined, + enabled: Boolean(organizationId) && options.enabled, + staleTime: RESOURCE_SEARCH_STALE_TIME, + placeholderData: keepPreviousData, + }) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 2852fa81bb7..e9105f1441b 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -277,6 +277,21 @@ function invalidateTableSchemaOnly( /** * Fetch all tables for a workspace. */ +/** Shared list options, for callers that read several workspaces' lists at once. */ +export function getTablesListQueryOptions(workspaceId: string, scope: TableQueryScope = 'active') { + return { + queryKey: tableKeys.list(workspaceId, scope), + queryFn: async ({ signal }: { signal?: AbortSignal }) => { + const response = await requestJson(listTablesContract, { + query: { workspaceId, scope }, + signal, + }) + return response.data.tables + }, + staleTime: TABLE_LIST_STALE_TIME, + } +} + export function useTablesList( workspaceId?: string, scope: TableQueryScope = 'active', diff --git a/apps/sim/hooks/queries/utils/project-keys.ts b/apps/sim/hooks/queries/utils/project-keys.ts new file mode 100644 index 00000000000..1ae7e512ff8 --- /dev/null +++ b/apps/sim/hooks/queries/utils/project-keys.ts @@ -0,0 +1,8 @@ +export const projectKeys = { + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const, + complete: (organizationId: string) => + [...projectKeys.lists(), 'complete', organizationId] as const, + byWorkspace: (workspaceId: string) => [...projectKeys.all, 'by-workspace', workspaceId] as const, +} diff --git a/apps/sim/hooks/queries/workspace-host.ts b/apps/sim/hooks/queries/workspace-host.ts index 2a7e5d39f3f..a13474cb8a4 100644 --- a/apps/sim/hooks/queries/workspace-host.ts +++ b/apps/sim/hooks/queries/workspace-host.ts @@ -29,9 +29,16 @@ async function fetchWorkspaceHostContext( */ export function useWorkspaceHostContextQuery(workspaceId: string) { return useQuery({ - queryKey: workspaceHostKeys.detail(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceHostContext(workspaceId, signal), + ...workspaceHostContextQueryOptions(workspaceId), enabled: Boolean(workspaceId), - staleTime: WORKSPACE_HOST_CONTEXT_STALE_TIME, }) } + +export function workspaceHostContextQueryOptions(workspaceId: string) { + return { + queryKey: workspaceHostKeys.detail(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceHostContext(workspaceId, signal), + staleTime: WORKSPACE_HOST_CONTEXT_STALE_TIME, + } +} diff --git a/apps/sim/hooks/queries/workspace-settings.ts b/apps/sim/hooks/queries/workspace-settings.ts new file mode 100644 index 00000000000..36184f0fc0a --- /dev/null +++ b/apps/sim/hooks/queries/workspace-settings.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getWorkspaceSettingsNavigationContract } from '@/lib/api/contracts/workspace-settings' + +const WORKSPACE_SETTINGS_ACCESS_STALE_TIME = 0 +const workspaceSettingsKeys = { + all: ['workspace-settings-navigation'] as const, + lists: () => [...workspaceSettingsKeys.all, 'list'] as const, + list: (workspaceId: string) => [...workspaceSettingsKeys.lists(), workspaceId] as const, +} +export function useWorkspaceSettingsNavigation(workspaceId: string) { + return useQuery({ + queryKey: workspaceSettingsKeys.list(workspaceId), + queryFn: ({ signal }) => + requestJson(getWorkspaceSettingsNavigationContract, { params: { id: workspaceId }, signal }), + staleTime: WORKSPACE_SETTINGS_ACCESS_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 33328bb6919..3294c03f2e5 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -27,6 +27,7 @@ import { type WorkspaceQueryScope, type WorkspacesResponse, } from '@/lib/api/contracts' +import { projectKeys } from '@/hooks/queries/utils/project-keys' import { normalizeWorkspace, normalizeWorkspacesResponse, @@ -311,6 +312,7 @@ export function useCreateWorkspace() { }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.adminLists() }) + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }) }, }) } @@ -336,6 +338,7 @@ export function useDeleteWorkspace() { onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: projectKeys.all }) }, }) } @@ -360,6 +363,7 @@ export function useUpdateWorkspace() { onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: projectKeys.all }) }, }) } diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index da04b0b4bbf..241d7b48dbb 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -31,6 +31,7 @@ import { SETTINGS_RETURN_URL_KEY } from '@/lib/navigation/settings-return' import { stripMicrosoftDataverseEnvironmentFromOAuthCallback } from '@/lib/oauth/microsoft-dataverse' import { searchSetupAccessParam } from '@/lib/sim-search/search-params' import { organizationSearchSetupPath } from '@/lib/sim-search/setup-navigation' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/connections' import { organizationCredentialKeys, @@ -275,9 +276,14 @@ export function useOAuthReturnRouter() { const router = useRouter() const params = useParams() const queryClient = useQueryClient() - const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : undefined - const organizationId = - typeof params.organizationId === 'string' ? params.organizationId : undefined + const host = useOptionalWorkspaceHostContext() + const workspaceId = + host?.workspace.id ?? (typeof params.workspaceId === 'string' ? params.workspaceId : undefined) + const organizationId = workspaceId + ? undefined + : typeof params.organizationId === 'string' + ? params.organizationId + : undefined const handledRef = useRef(false) const chatAttemptHandledRef = useRef(false) diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 1ea386a0d11..1faecafef7d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -57,9 +57,11 @@ export interface PermissionConfigResult { export function usePermissionConfig(): PermissionConfigResult { const params = useParams() - const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined const blockOverlayVersion = useCustomBlockOverlayVersion() const hostContext = useOptionalWorkspaceHostContext() + const workspaceId = + hostContext?.workspace.id ?? + (typeof params?.workspaceId === 'string' ? params.workspaceId : undefined) const { data: permissionData, diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index 534309ab677..b16545cdb6d 100644 --- a/apps/sim/hooks/use-settings-navigation.ts +++ b/apps/sim/hooks/use-settings-navigation.ts @@ -1,17 +1,20 @@ 'use client' import { useCallback } from 'react' -import { useParams, useRouter } from 'next/navigation' +import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation' +import { WORKSPACE_SETTINGS_ITEMS } from '@/components/settings/navigation' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { popSettingsReturnUrl, rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' +import { useOptionalFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' interface SettingsNavigationOptions { section?: SettingsSection + credentialId?: string mcpServerId?: string browserView?: 'passwords' browserImport?: boolean @@ -53,28 +56,54 @@ export function resolveSettingsHref({ if (options?.browserImport) searchParams.set('browserImport', '1') if (options?.browserClear) searchParams.set('browserClear', '1') const query = searchParams.toString() - const pathname = `/workspace/${workspaceId}/settings/${section}` + const pathname = `/workspace/${workspaceId}/settings/${section}${section === 'secrets' && options?.credentialId ? `/${options.credentialId}` : ''}` return query ? `${pathname}?${query}` : pathname } export function useSettingsNavigation(): UseSettingsNavigationReturn { const router = useRouter() const params = useParams<{ workspaceId?: string }>() - const workspaceId = params.workspaceId const hostContext = useOptionalWorkspaceHostContext() + const workspaceId = hostContext?.workspace.id ?? params.workspaceId + const pathname = usePathname() + const searchParams = useSearchParams() const { data: session } = useSession() + const projectViewEnabled = useOptionalFeatureFlag('org-project-view') const settingsPrefix = `/workspace/${workspaceId}/settings/` const getSettingsHref = useCallback( - (options?: SettingsNavigationOptions): string => - resolveSettingsHref({ + (options?: SettingsNavigationOptions): string => { + const section = options?.section ?? 'general' + if ( + projectViewEnabled && + pathname.startsWith('/o/') && + workspaceId && + (section === 'general' || + WORKSPACE_SETTINGS_ITEMS.some( + (item) => (item.id === 'api-keys' ? 'apikeys' : item.id) === section + )) + ) { + const query = new URLSearchParams(searchParams.toString()) + query.set('project', workspaceId) + query.set('pane', 'project') + query.set('section', 'settings') + query.set('setting', section === 'apikeys' ? 'api-keys' : section) + query.delete('credential') + query.delete('secret-view') + query.delete('usage-tab') + if (options?.credentialId) query.set('credential', options.credentialId) + if (options?.mcpServerId) query.set('mcpServerId', options.mcpServerId) + return `${pathname}?${query}` + } + return resolveSettingsHref({ options, workspaceId, hostContext: hostContext ?? undefined, viewerUserId: session?.user?.id, - }), - [hostContext, session?.user?.id, workspaceId] + }) + }, + [hostContext, session?.user?.id, workspaceId, pathname, searchParams, projectViewEnabled] ) const navigateToSettings = useCallback( diff --git a/apps/sim/hooks/use-settings-workspace-id.ts b/apps/sim/hooks/use-settings-workspace-id.ts new file mode 100644 index 00000000000..b877361efa0 --- /dev/null +++ b/apps/sim/hooks/use-settings-workspace-id.ts @@ -0,0 +1,9 @@ +import { useParams } from 'next/navigation' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' + +/** The pane's canonical environment wins over a containing route's workspace. */ +export function useSettingsWorkspaceId(): string { + const host = useOptionalWorkspaceHostContext() + const params = useParams<{ workspaceId?: string }>() + return host?.workspace.id ?? params?.workspaceId ?? '' +} diff --git a/apps/sim/lib/api/contracts/projects.ts b/apps/sim/lib/api/contracts/projects.ts index 0339cd07738..3009f6b4166 100644 --- a/apps/sim/lib/api/contracts/projects.ts +++ b/apps/sim/lib/api/contracts/projects.ts @@ -20,9 +20,11 @@ const projectSchema = z.object({ archivedAt: z.coerce.date().nullable(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), + /** Roots first, each fork right after the environment it was forked from, siblings by name. */ environments: z.array(projectEnvironmentSchema), capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }), }) +export type ProjectRecord = z.output const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) const projectQuerySchema = z.object({ organizationId: organizationIdSchema.optional(), diff --git a/apps/sim/lib/api/contracts/resource-search.ts b/apps/sim/lib/api/contracts/resource-search.ts new file mode 100644 index 00000000000..3b797ef4939 --- /dev/null +++ b/apps/sim/lib/api/contracts/resource-search.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** The resource families a name search covers, by their resource-tab type. */ +export const RESOURCE_SEARCH_TYPES = ['workflow', 'table', 'file', 'knowledgebase', 'test'] as const + +export type ResourceSearchType = (typeof RESOURCE_SEARCH_TYPES)[number] + +const RESOURCE_SEARCH_MAX_RESULTS = 100 + +const resourceSearchItemSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(RESOURCE_SEARCH_TYPES), + workspaceId: z.string(), + workspaceName: z.string(), +}) + +export type ResourceSearchItem = z.output + +/** A filter list: one value arrives as a scalar, several as repeated keys. */ +function repeatedQuerySchema>(item: T, max: number) { + return z + .union([z.string(), z.array(z.string())]) + .transform((value) => (Array.isArray(value) ? value : [value])) + .pipe(z.array(item).max(max)) + .optional() +} + +export const searchOrganizationResourcesContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/resources/search', + params: z.object({ id: organizationIdSchema }), + query: z.object({ + q: z + .string() + .trim() + .max(200, 'q must be at most 200 characters') + .optional() + .transform((value) => value || undefined), + types: repeatedQuerySchema(z.enum(RESOURCE_SEARCH_TYPES), RESOURCE_SEARCH_TYPES.length), + workspaceIds: repeatedQuerySchema(z.string().min(1), 200), + limit: z.coerce.number().int().min(1).max(RESOURCE_SEARCH_MAX_RESULTS).default(50), + cursor: z.string().min(1, 'cursor must be a non-empty token').max(2048).optional(), + }), + response: { + mode: 'json', + schema: z.object({ + items: z.array(resourceSearchItemSchema).max(RESOURCE_SEARCH_MAX_RESULTS), + nextCursor: z.string().nullable(), + }), + }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 14a5ad93f9c..bd9d3262801 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -241,7 +241,11 @@ export const forkMappingEntrySchema = z.object({ }) export type ForkMappingEntry = z.output +const forkMappingScopeSchema = z.enum(['sync', 'all']) +export type ForkMappingScope = z.output + export const getForkMappingQuerySchema = z.object({ + scope: forkMappingScopeSchema.default('sync'), otherWorkspaceId: workspaceIdSchema, direction: forkDirectionSchema.default('push'), }) @@ -1041,3 +1045,27 @@ export type UpdateForkExcludedWorkflowsBody = z.input + +const createForkSecretMappingBodySchema = z.object({ + otherWorkspaceId: workspaceIdSchema, + direction: forkDirectionSchema, + sourceId: z.string().min(1).max(256), + name: z + .string() + .trim() + .min(1, 'Secret name is required') + .max(256) + .regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + 'Use letters, numbers, and underscores; start with a letter or underscore' + ), + value: z.string().min(1, 'Secret value is required').max(65536), +}) +export type CreateForkSecretMappingBody = z.input +export const createForkSecretMappingContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/fork/mapping', + params: workspaceIdParamsSchema, + body: createForkSecretMappingBodySchema, + response: { mode: 'json', schema: z.object({ name: z.string() }) }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-settings.ts b/apps/sim/lib/api/contracts/workspace-settings.ts new file mode 100644 index 00000000000..0e35ea38703 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspace-settings.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' + +const workspaceSettingsNavigationSchema = z.object({ + sections: z + .array( + z.object({ + id: z.enum([ + 'teammates', + 'secrets', + 'byok', + 'sandboxes', + 'custom-tools', + 'mcp', + 'workflow-mcp-servers', + 'api-keys', + 'inbox', + 'recently-deleted', + 'forks', + 'custom-blocks', + 'requests', + 'self-host', + ]), + access: z.enum(['allowed', 'request-access']), + }) + ) + .max(32), +}) + +export const getWorkspaceSettingsNavigationContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/settings-navigation', + params: z.object({ id: workspaceIdSchema }), + response: { mode: 'json', schema: workspaceSettingsNavigationSchema }, +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8edac360a93..6ab940ba1ca 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -594,6 +594,7 @@ export const env = createEnv({ AGENTMAIL_API_KEY: z.string().min(1).optional(), // AgentMail API key for mothership email inbox AGENTMAIL_DOMAIN: z.string().optional(), // Custom domain for AgentMail inboxes (default: agentmail.to) DASHBOARDS: z.boolean().optional(), + ORG_PROJECT_VIEW: z.boolean().optional(), PROJECTS: z.boolean().optional(), // Fallback for the `projects` feature flag off AppConfig MSHIP_COMPUTER_USE: z.boolean().optional(), WORKFLOW_TESTS: z.boolean().optional(), // Fallback for the `workflow-tests` feature flag off AppConfig diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 3bf0e51e3a9..d8c338be55b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -120,6 +120,13 @@ const FEATURE_FLAGS = { 'targeting. Off-AppConfig falls back to KNOWLEDGE_MEMBER_ACCESS.', fallback: 'KNOWLEDGE_MEMBER_ACCESS', }, + 'org-project-view': { + description: + 'Show the organization surface as projects: the workspace layout mounts the project Build ' + + 'sidebar and the organization surface lists projects. Reads the Project APIs, so it needs ' + + '`projects` on as well. Supports orgId targeting; off-AppConfig falls back to ORG_PROJECT_VIEW.', + fallback: 'ORG_PROJECT_VIEW', + }, } satisfies Record /** diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index ae99a166a4a..ee8cf64ee4a 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -50,6 +50,9 @@ export function organizationRoutes(organizationId: string) { `${root}/settings/integrations/sources/${encodeURIComponent(connectorId)}`, settingsSection: (section: string) => `${root}/settings/${section}`, chat: (chatId: string) => `${root}/chat/${chatId}`, + /** One section of a project's main view; a project is the fork lineage of a workspace. */ + project: (workspaceId: string, section = 'dashboard') => + `${root}/home?project=${encodeURIComponent(workspaceId)}§ion=${encodeURIComponent(section)}&pane=project`, } as const } diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index e69f1107753..0c2c970be24 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -681,6 +681,57 @@ describe('Project foundation at the database and application boundary', () => { } ) + check( + 'orders environments roots first, each fork after its parent, siblings by name', + async () => { + const f = await fixture(true, 1) + const [root] = f.ids + /** Ascending ids, assigned so id order disagrees with fork order. */ + const [staging, sandbox, dev] = Array.from({ length: 3 }, () => generateId()).sort() + await db.transaction(async (tx) => { + for (const [id, name, parent] of [ + [staging, 'Staging', root], + [dev, 'Dev', root], + [sandbox, 'Sandbox', dev], + ] as const) { + await tx.insert(workspace).values({ + id, + projectId: f.projectId, + name, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + forkedFromWorkspaceId: parent, + }) + await tx.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: id, + userId: f.ownerId, + permissionType: 'admin', + }) + } + }) + const result = await getProject.execute({ + principal: f.owner, + input: { projectId: f.projectId }, + request, + }) + expect( + result.project.environments.map(({ id, forkedFromWorkspaceId }) => [ + id, + forkedFromWorkspaceId, + ]) + ).toEqual([ + [root, null], + [dev, root], + [sandbox, dev], + [staging, root], + ]) + } + ) + check( 'hides inaccessible environments and denies mismatched scopes and workspace keys', async () => { diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index cc8c193b35c..8b4583b0104 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -2,6 +2,7 @@ import type { Principal, SessionPrincipal } from '@sim/auth/principal' import { member, permissionGroup, permissions, project, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { compareStrings } from '@sim/utils/string' import { and, asc, eq, inArray, sql } from 'drizzle-orm' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -142,6 +143,42 @@ async function loadProjectAccess( } /** Applies the access rules to one loaded Project; hidden environments never enter the result. */ +interface ProjectEnvironment { + id: string + name: string + forkedFromWorkspaceId: string | null +} + +/** + * Roots first, each fork right after the environment it was forked from, siblings by name. A + * fork whose parent the viewer cannot see is presented as a root. + */ +function orderEnvironments( + rows: readonly { id: string; name: string; parentId: string | null }[] +): ProjectEnvironment[] { + const ids = new Set(rows.map((row) => row.id)) + const children = new Map() + for (const row of rows) { + const parent = row.parentId && ids.has(row.parentId) ? row.parentId : null + const siblings = children.get(parent) ?? [] + siblings.push({ id: row.id, name: row.name, forkedFromWorkspaceId: parent }) + children.set(parent, siblings) + } + const ordered: ProjectEnvironment[] = [] + const visit = (parent: string | null) => { + const siblings = (children.get(parent) ?? []).sort( + (a, b) => compareStrings(a.name, b.name) || compareStrings(a.id, b.id) + ) + for (const environment of siblings) { + ordered.push(environment) + visit(environment.id) + } + } + visit(null) + if (ordered.length !== rows.length) throw new Error('Project environments contain a fork cycle') + return ordered +} + async function evaluateProjectAccess( tx: DbTransaction, principal: SessionPrincipal, @@ -195,17 +232,7 @@ async function evaluateProjectAccess( if (record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') refuseCapability('project_issues.use') } - const visibleIds = new Set(visible.map((row) => row.id)) - return { - record, - canAdminister, - canUseIssues, - environments: visible.map((row) => ({ - id: row.id, - name: row.name, - forkedFromWorkspaceId: row.parentId && visibleIds.has(row.parentId) ? row.parentId : null, - })), - } + return { record, canAdminister, canUseIssues, environments: orderEnvironments(visible) } } export type AuthorizedProject = Awaited> diff --git a/apps/sim/lib/projects/feature-flag.ts b/apps/sim/lib/projects/feature-flag.ts new file mode 100644 index 00000000000..49c13e932f4 --- /dev/null +++ b/apps/sim/lib/projects/feature-flag.ts @@ -0,0 +1,6 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** The project view rollout follows the canonical organization; personal workspaces use the global switch. */ +export function isOrgProjectViewEnabled(orgId?: string | null): Promise { + return isFeatureEnabled('org-project-view', orgId ? { orgId } : {}) +} diff --git a/apps/sim/lib/settings/application/workspace-navigation.ts b/apps/sim/lib/settings/application/workspace-navigation.ts new file mode 100644 index 00000000000..a40aec92a70 --- /dev/null +++ b/apps/sim/lib/settings/application/workspace-navigation.ts @@ -0,0 +1,37 @@ +import { WORKSPACE_SETTINGS_ITEMS } from '@/components/settings/navigation' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/workspace-section-access' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +// permission-group-exempt: Navigation describes section access; each section retains its own capability gate. +export const readWorkspaceSettingsNavigationOperation = defineWorkspaceOperation({ + id: 'settings.workspace_navigation.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + principalKinds: ['session'], +}) + +/** Reuses the routed settings gate before the pane mounts any protected section. */ +export const readWorkspaceSettingsNavigation = defineAuthorizedWorkspaceUseCase({ + operation: readWorkspaceSettingsNavigationOperation, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + authorizationOptions: {}, + async execute({ principal, context }) { + const sections = await Promise.all( + WORKSPACE_SETTINGS_ITEMS.map(async ({ id }) => { + const access = await authorizeWorkspaceSettingsSection({ + workspaceId: context.workspaceId, + userId: principal.userId, + section: id === 'api-keys' ? 'apikeys' : id, + }) + if (access.allowed) return { id, access: 'allowed' as const } + if (access.disposition === 'request-access') + return { id, access: 'request-access' as const } + return null + }) + ) + return { sections: sections.filter((section) => section !== null) } + }, +}) diff --git a/apps/sim/lib/workspaces/application/search-organization-resources.ts b/apps/sim/lib/workspaces/application/search-organization-resources.ts new file mode 100644 index 00000000000..f5a8b005df5 --- /dev/null +++ b/apps/sim/lib/workspaces/application/search-organization-resources.ts @@ -0,0 +1,117 @@ +import { RESOURCE_SEARCH_TYPES, type ResourceSearchType } from '@/lib/api/contracts/resource-search' +import type { CursorKey } from '@/lib/api/list-query' +import { + defineAuthorizedOrganizationUseCase, + type OrganizationUseCaseContext, +} from '@/lib/core/application/authorized-organization-use-case' +import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' +import type { WorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { capabilityDeniedBy } from '@/lib/permission-groups/capability-assertions' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { tableOperations } from '@/lib/table/application/operations' +import { workflowTestOperations } from '@/lib/workflow-tests/application/operations' +import { + isWorkflowTestsEnabled, + requireWorkflowTestsEnabled, +} from '@/lib/workflow-tests/feature-flag' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { searchResourceNames } from '@/lib/workspaces/resource-search' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' + +export const organizationResourceOperations = { + // permission-group-exempt: spans several resource types, so the use case applies each type's own capability per workspace instead + search: defineOrganizationOperation({ + id: 'organization.resources.search', + minimumRole: 'member', + capability: 'none', + principalKinds: ['session'], + }), +} as const + +/** Each type's own list operation, whose capability gates the type in each workspace here too. */ +const LIST_OPERATION_BY_TYPE: Record = { + workflow: workflowOperations.list, + table: tableOperations.list, + file: fileOperations.list, + knowledgebase: knowledgeOperations.list, + test: workflowTestOperations.read, +} + +/** + * The requested types, or every type when none are. Tests are an organization-level feature: left + * out of an unscoped search when off, and refused, as their own list refuses, when asked for. + */ +async function searchableTypes( + requested: readonly ResourceSearchType[] | undefined, + organizationId: string +): Promise { + const types = requested?.length ? requested : RESOURCE_SEARCH_TYPES + if (!types.includes('test')) return types + if (requested?.includes('test')) { + await requireWorkflowTestsEnabled(organizationId) + return types + } + return (await isWorkflowTestsEnabled(organizationId)) + ? types + : types.filter((type) => type !== 'test') +} + +export interface SearchOrganizationResourcesInput { + organizationId: string + search?: string + types?: readonly ResourceSearchType[] + /** Narrows the search to these workspaces; any the viewer cannot open are ignored. */ + workspaceIds?: readonly string[] + limit: number + cursorKeys?: CursorKey[] +} + +/** + * Active resources named like `search` across the organization's workspaces the viewer can + * open: the same workspaces the organization workspace list returns, each type limited to the + * workspaces whose permission group grants that type's own list capability. + */ +export const searchOrganizationResources = defineAuthorizedOrganizationUseCase({ + operation: organizationResourceOperations.search, + async execute({ input, context }: OrganizationUseCaseContext) { + const rows = await listAccessibleWorkspaceRowsForUser( + context.userId, + 'active', + context.organizationId + ) + const workspaceIds = rows + .filter( + ({ workspace }) => + workspace.organizationId === context.organizationId && + (!input.workspaceIds?.length || input.workspaceIds.includes(workspace.id)) + ) + .map(({ workspace }) => workspace.id) + const types = await searchableTypes(input.types, context.organizationId) + const allowedTypes = await mapWithConcurrency(workspaceIds, 8, async (workspaceId) => { + const config = await resolvePermissionGroupConfig( + context.userId, + workspaceId, + context.organizationId + ) + return types.filter((type) => { + const { capability } = LIST_OPERATION_BY_TYPE[type] + return capability === 'none' || !capabilityDeniedBy(capability, config) + }) + }) + const workspaceIdsByType = new Map( + types.map((type) => [type, []]) + ) + workspaceIds.forEach((workspaceId, index) => { + for (const type of allowedTypes[index]) workspaceIdsByType.get(type)?.push(workspaceId) + }) + return searchResourceNames({ + workspaceIdsByType, + search: input.search, + limit: input.limit, + cursorKeys: input.cursorKeys, + }) + }, +}) diff --git a/apps/sim/lib/workspaces/resource-search.ts b/apps/sim/lib/workspaces/resource-search.ts new file mode 100644 index 00000000000..8364d462a3a --- /dev/null +++ b/apps/sim/lib/workspaces/resource-search.ts @@ -0,0 +1,218 @@ +import { db } from '@sim/db' +import { + knowledgeBase, + userTableDefinitions, + workflow, + workflowTest, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import type { ResourceSearchItem, ResourceSearchType } from '@/lib/api/contracts/resource-search' +import { + type CursorKey, + type KeysetKey, + keysetOrderBy, + keysetPage, + numberKey, + resumeKeyset, + searchFilter, + textKey, +} from '@/lib/api/list-query' + +export interface SearchResourceNamesOptions { + /** The workspaces each type may be searched in; a type with none is skipped. */ + workspaceIdsByType: ReadonlyMap + /** Omitted lists every row of the given types and workspaces, A→Z. */ + search?: string + limit: number + cursorKeys?: CursorKey[] +} + +export interface ResourceSearchPage { + items: ResourceSearchItem[] + nextCursorKeys: CursorKey[] | null +} + +interface ResourceSearchRow extends Record { + id: string + name: string + type: ResourceSearchType + workspace_id: string + workspace_name: string + match_rank: number + name_key: string +} + +/** The columns every source projects, as the expressions the order and cursor read. */ +interface RowExpressions { + id: SQL + name: SQL + type: SQL + workspaceId: SQL +} + +interface SearchSource { + table: SQL + row: RowExpressions + where: SQL | undefined +} + +function source( + type: ResourceSearchType, + table: SQL, + row: Omit, + where: SQL | undefined +): SearchSource { + return { table, row: { ...row, type: sql`${type}::text` }, where } +} + +/** + * Each type's active rows in the given workspaces, with exactly the predicate its own active + * list applies (`listWorkflowsForUser`, `listTables`, `getWorkspaceKnowledgeBases`, + * `listWorkspaceFiles`, `listLiveWorkflowTests`), so a search never finds what those lists hide. + * A test is addressed by its name, as its resource tab is. + */ +function searchSource(type: ResourceSearchType, workspaceIds: readonly string[]): SearchSource { + const ids = [...workspaceIds] + switch (type) { + case 'workflow': + return source( + type, + sql`${workflow}`, + { + id: sql`${workflow.id}`, + name: sql`${workflow.name}`, + workspaceId: sql`${workflow.workspaceId}`, + }, + and(inArray(workflow.workspaceId, ids), isNull(workflow.archivedAt)) + ) + case 'table': + return source( + type, + sql`${userTableDefinitions}`, + { + id: sql`${userTableDefinitions.id}`, + name: sql`${userTableDefinitions.name}`, + workspaceId: sql`${userTableDefinitions.workspaceId}`, + }, + and(inArray(userTableDefinitions.workspaceId, ids), isNull(userTableDefinitions.archivedAt)) + ) + case 'knowledgebase': + return source( + type, + sql`${knowledgeBase}`, + { + id: sql`${knowledgeBase.id}`, + name: sql`${knowledgeBase.name}`, + workspaceId: sql`${knowledgeBase.workspaceId}`, + }, + and(inArray(knowledgeBase.workspaceId, ids), isNull(knowledgeBase.deletedAt)) + ) + case 'file': + return source( + type, + sql`${workspaceFiles}`, + { + id: sql`${workspaceFiles.id}`, + name: sql`${workspaceFiles.originalName}`, + workspaceId: sql`${workspaceFiles.workspaceId}`, + }, + and( + inArray(workspaceFiles.workspaceId, ids), + isNull(workspaceFiles.organizationId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.projectId), + isNull(workspaceFiles.deletedAt) + ) + ) + case 'test': + return source( + type, + sql`${workflowTest}`, + { + id: sql`${workflowTest.name}`, + name: sql`${workflowTest.title}`, + workspaceId: sql`${workflowTest.workspaceId}`, + }, + and(inArray(workflowTest.workspaceId, ids), isNull(workflowTest.deletedAt)) + ) + } +} + +/** Where `search` first appears in the name, so a prefix outranks a mid-word hit. */ +function matchRank(name: SQL, search: string | undefined): SQL { + // Cast, not a bare `0`: `ORDER BY 0` would read as a column position. + return search === undefined ? sql`0::integer` : sql`strpos(lower(${name}), lower(${search}))` +} + +/** + * Match position, then A→Z, then type, workspace, and id — together unique, since a resource + * id is unique within its type and a test name within its workspace. + */ +function sortKeys(row: RowExpressions & { rank: SQL }): KeysetKey[] { + return [ + numberKey(row.rank, (r: ResourceSearchRow) => Number(r.match_rank)), + textKey(sql`lower(${row.name})`, (r: ResourceSearchRow) => r.name_key), + textKey(row.type, (r: ResourceSearchRow) => r.type), + textKey(row.workspaceId, (r: ResourceSearchRow) => r.workspace_id), + textKey(row.id, (r: ResourceSearchRow) => r.id), + ] +} + +/** The union's output columns, which the outer `ORDER BY` reads. */ +const UNIONED_ROW = { + id: sql`matches.id`, + name: sql`matches.name`, + type: sql`matches.type`, + workspaceId: sql`matches.workspace_id`, + rank: sql`matches.match_rank`, +} + +/** + * One page of active resources whose names contain `search`, across every type and workspace + * given. Each branch contributes at most `limit + 1` rows already in page order, so a page reads + * a bounded number of rows however many resources the workspaces hold. + */ +export async function searchResourceNames( + options: SearchResourceNamesOptions +): Promise { + const outerKeys = sortKeys(UNIONED_ROW) + // A cursor that does not fit the sort is a 400 even when no type is searchable. + resumeKeyset(outerKeys, options.cursorKeys, 'asc') + const pageSize = options.limit + 1 + const branches = [...options.workspaceIdsByType] + .filter(([, workspaceIds]) => workspaceIds.length > 0) + .map(([type, workspaceIds]) => { + const { table, row, where } = searchSource(type, workspaceIds) + const rank = matchRank(row.name, options.search) + const keys = sortKeys({ ...row, rank }) + const filter = and( + where, + searchFilter(row.name, options.search), + resumeKeyset(keys, options.cursorKeys, 'asc') + ) + const order = sql.join(keysetOrderBy(keys, 'asc'), sql`, `) + return sql`(select ${row.id} as id, ${row.name} as name, ${row.type} as type, ${row.workspaceId} as workspace_id, ${rank} as match_rank, lower(${row.name}) as name_key from ${table} where ${filter} order by ${order} limit ${pageSize})` + }) + if (branches.length === 0) return { items: [], nextCursorKeys: null } + + const rows = await db.execute(sql` + select matches.*, ${workspace.name} as workspace_name + from (${sql.join(branches, sql` union all `)}) as matches + join ${workspace} on ${workspace.id} = matches.workspace_id + order by ${sql.join(keysetOrderBy(outerKeys, 'asc'), sql`, `)} + limit ${pageSize}`) + + const { data, nextCursorKeys } = keysetPage(outerKeys, [...rows], options.limit) + return { + items: data.map((row) => ({ + id: row.id, + name: row.name, + type: row.type, + workspaceId: row.workspace_id, + workspaceName: row.workspace_name, + })), + nextCursorKeys, + } +} diff --git a/apps/sim/stores/project-order/store.ts b/apps/sim/stores/project-order/store.ts new file mode 100644 index 00000000000..611902731fc --- /dev/null +++ b/apps/sim/stores/project-order/store.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface ProjectOrderState { + /** Project ids in the order the viewer arranged them; unknown ids sort after. */ + order: string[] + /** Places `projectId` before `beforeId`, or last when `beforeId` is null, over `visible`. */ + moveProject: (visible: readonly string[], projectId: string, beforeId: string | null) => void +} + +/** + * The viewer's manual order of projects in the organization sidebar. A per-browser preference + * for now; graduation moves it beside the workspace pins on the server. + */ +export const useProjectOrderStore = create()( + devtools( + persist( + (set) => ({ + order: [], + moveProject: (visible, projectId, beforeId) => + set((state) => { + const current = orderProjectIds(visible, state.order).filter((id) => id !== projectId) + const index = beforeId ? current.indexOf(beforeId) : -1 + if (index === -1) current.push(projectId) + else current.splice(index, 0, projectId) + return { order: current } + }), + }), + { + name: 'project-order', + partialize: (state) => ({ order: state.order }), + } + ), + { name: 'project-order-store' } + ) +) + +/** `ids` sorted by the stored order; ids the order does not know keep their given order after it. */ +export function orderProjectIds(ids: readonly string[], order: readonly string[]): string[] { + const rank = new Map(order.map((id, index) => [id, index])) + const known = ids + .flatMap((id) => { + const position = rank.get(id) + return position === undefined ? [] : [{ id, position }] + }) + .sort((a, b) => a.position - b.position) + .map(({ id }) => id) + const unknown = ids.filter((id) => !rank.has(id)) + return [...known, ...unknown] +} diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 412ba62f7c6..9be4949aa0c 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -229,6 +229,8 @@ interface TabStripBaseProps { id: string, drag: TabStripDragContext ) => void + /** In-flow controls before the first tab, in the tab band. */ + startActions?: ReactNode /** In-flow controls pinned to the far end; use `TabStripAction` for band-sized icon actions. */ endActions?: ReactNode /** @@ -519,6 +521,7 @@ export function TabStrip({ maxTabs, newTabLabel = 'New tab', newTabControl, + startActions, endActions, overlays, variant = 'attached', @@ -944,6 +947,16 @@ export function TabStrip({ className='sr-only' /> )} + {startActions && ( +
+ {startActions} +
+ )}
) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index ec59f6d3006..fa0220398e6 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -16,6 +16,7 @@ export { BookOpen } from './book-open' export { Box } from './box' export { Brain } from './brain' export { BrainCircuit } from './brain-circuit' +export { Branch } from './branch' export { BubbleChatClose } from './bubble-chat-close' export { BubbleChatDelay } from './bubble-chat-delay' export { BubbleChatPreview } from './bubble-chat-preview' @@ -107,6 +108,7 @@ export { MarkdownIcon } from './markdown-icon' export { Menu } from './menu' export { MessageSquareText } from './message-square-text' export { Mic } from './mic' +export { Minimize } from './minimize' export { Minus } from './minus' export { Moon } from './moon' export { MoreHorizontal } from './more-horizontal' @@ -149,6 +151,7 @@ export { Split } from './split' export { Sprout } from './sprout' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' +export { SquarePen } from './square-pen' export { StopFilled } from './stop-filled' export { Strikethrough } from './strikethrough' export { Sun } from './sun' diff --git a/packages/emcn/src/icons/minimize.tsx b/packages/emcn/src/icons/minimize.tsx new file mode 100644 index 00000000000..ec11d6ba4bc --- /dev/null +++ b/packages/emcn/src/icons/minimize.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +/** Inward arrows for restoring an expanded pane. */ +export function Minimize(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/square-pen.tsx b/packages/emcn/src/icons/square-pen.tsx new file mode 100644 index 00000000000..dd2ab4d57b5 --- /dev/null +++ b/packages/emcn/src/icons/square-pen.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * SquarePen icon component - compose a new chat or note + * @param props - SVG properties including className, fill, etc. + */ +export function SquarePen(props: SVGProps) { + return ( + + ) +} diff --git a/packages/workflow-renderer/src/edge/execution-pulse.tsx b/packages/workflow-renderer/src/edge/execution-pulse.tsx new file mode 100644 index 00000000000..0eb3ff71527 --- /dev/null +++ b/packages/workflow-renderer/src/edge/execution-pulse.tsx @@ -0,0 +1,115 @@ +const EXECUTION_PULSE_LENGTH = 0.32 +const EXECUTION_PULSE_CYCLE_LENGTH = 2.2 +const EXECUTION_PULSE_DURATION = '1100ms' +/** + * How far the glow reaches past the path, in user space. + * + * The pulse strokes are `non-scaling-stroke`, so the widest one (6px) covers + * `3 / zoom` user units — 30 at the canvas minimum of 0.1 — while the blur adds + * roughly 3σ more. Under-sizing the filter region does not shrink the glow, it + * clips it to a hard rectangle, so this is rounded up rather than tuned: the + * region only bounds the output, it costs nothing to be larger than needed. + */ +export const PULSE_GLOW_BLEED_PX = 40 + +const EXECUTION_PULSE_LAYERS = [ + { id: 'tail', length: EXECUTION_PULSE_LENGTH, opacity: 0.22, strokeWidth: 6 }, + { id: 'shoulder', length: 0.23, opacity: 0.32, strokeWidth: 3.5 }, + { id: 'body', length: 0.15, opacity: 0.6, strokeWidth: 2.5 }, + { id: 'core', length: 0.07, opacity: 1, strokeWidth: 2.5 }, +] as const + +function getExecutionPulseMotion(length: number) { + const centerOffset = (EXECUTION_PULSE_LENGTH - length) / 2 + + return { + dashArray: `${length} ${(EXECUTION_PULSE_CYCLE_LENGTH - length).toFixed(2)}`, + from: centerOffset === 0 ? '0' : `-${centerOffset.toFixed(3)}`, + to: `-${(EXECUTION_PULSE_CYCLE_LENGTH + centerOffset).toFixed(3)}`, + } +} + +/** A user-space rectangle the glow filter may paint in. */ +interface PulseGlowBounds { + x: number + y: number + width: number + height: number +} + +interface ExecutionPulseProps { + /** The SVG path the pulse travels, start to end. */ + path: string + /** A document-unique id for the glow filter. */ + glowId: string + glowBounds: PulseGlowBounds + /** The pulse's color; the canvas draws it white over the darkened edge. */ + color?: string +} + +/** + * The pulse a running workflow sends along an edge: four stacked strokes, a blurred wide tail + * up to a bright core, sliding from the path's start to its end. Shared by the canvas edge and + * anything else that shows work flowing from one place to another. + */ +export function ExecutionPulse({ + path, + glowId, + glowBounds, + color = 'var(--white)', +}: ExecutionPulseProps) { + return ( + <> + + {/* + `userSpaceOnUse` rather than the default `objectBoundingBox`: a + straight horizontal edge — what an auto-laid-out chain produces, + since handles sit at fixed Y offsets — has a zero-height + bounding box, which resolves the region to zero height and stops + the referencing element from rendering at all. + */} + + + + + {EXECUTION_PULSE_LAYERS.map((layer) => { + const motion = getExecutionPulseMotion(layer.length) + return ( + + + + ) + })} + + ) +} diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx index de6206e46a0..6381cba0c46 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -9,40 +9,10 @@ import { getSmoothStepPath, } from '@xyflow/react' import type { EdgeDiffStatus, EdgeRunStatus } from '../types' +import { ExecutionPulse, PULSE_GLOW_BLEED_PX } from './execution-pulse' -const EXECUTION_PULSE_LENGTH = 0.32 -const EXECUTION_PULSE_CYCLE_LENGTH = 2.2 -const EXECUTION_PULSE_DURATION = '1100ms' const DEFAULT_EDGE_LABEL_Z_INDEX = 1011 -/** - * How far the glow reaches past the path, in user space. - * - * The pulse strokes are `non-scaling-stroke`, so the widest one (6px) covers - * `3 / zoom` user units — 30 at the canvas minimum of 0.1 — while the blur adds - * roughly 3σ more. Under-sizing the filter region does not shrink the glow, it - * clips it to a hard rectangle, so this is rounded up rather than tuned: the - * region only bounds the output, it costs nothing to be larger than needed. - */ -const PULSE_GLOW_BLEED_PX = 40 - -const EXECUTION_PULSE_LAYERS = [ - { id: 'tail', length: EXECUTION_PULSE_LENGTH, opacity: 0.22, strokeWidth: 6 }, - { id: 'shoulder', length: 0.23, opacity: 0.32, strokeWidth: 3.5 }, - { id: 'body', length: 0.15, opacity: 0.6, strokeWidth: 2.5 }, - { id: 'core', length: 0.07, opacity: 1, strokeWidth: 2.5 }, -] as const - -function getExecutionPulseMotion(length: number) { - const centerOffset = (EXECUTION_PULSE_LENGTH - length) / 2 - - return { - dashArray: `${length} ${(EXECUTION_PULSE_CYCLE_LENGTH - length).toFixed(2)}`, - from: centerOffset === 0 ? '0' : `-${centerOffset.toFixed(3)}`, - to: `-${(EXECUTION_PULSE_CYCLE_LENGTH + centerOffset).toFixed(3)}`, - } -} - /** * Props for the pure workflow edge renderer. * @@ -221,59 +191,7 @@ export function WorkflowEdgeView({ {showsPulse && ( - <> - - {/* - `userSpaceOnUse` rather than the default `objectBoundingBox`: a - straight horizontal edge — what an auto-laid-out chain produces, - since handles sit at fixed Y offsets — has a zero-height - bounding box, which resolves the region to zero height and stops - the referencing element from rendering at all. - */} - - - - - {EXECUTION_PULSE_LAYERS.map((layer) => { - const motion = getExecutionPulseMotion(layer.length) - - return ( - - - - ) - })} - + )} diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 88560e61af0..323cd5193c4 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1198,7 +1198,6 @@ "apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx": 2, "apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts": 6, "apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts": 1, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx": 2, "apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts": 1, diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index 0c47556d457..a4bd7901436 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -545,7 +545,6 @@ "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx": 1, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 1, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx": 1, "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx": 1, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 1 }, diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index d0bac8d0d6f..7ac71a3e848 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -634,16 +634,16 @@ } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2413, + "modules": 2464, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1131, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 847, + "apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx": 1069, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1056, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 784, "apps/sim/triggers/registry.ts": 578, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 439, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 411, "apps/sim/blocks/registry.ts": 360, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 190, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 186, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 177 + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 194, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 190 } }, "app/workspace/layout.tsx": { diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 48f475d590b..d64a42fea8e 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -890,11 +890,7 @@ "apps/sim/ee/whitelabeling/index.ts#ThemeColors", "apps/sim/ee/whitelabeling/index.ts#generateOrgThemeCSS", "apps/sim/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker.tsx#ForkResourcePickerItem", - "apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts#MappableMappingKind", "apps/sim/ee/workspace-forking/hooks/background-work.ts#BACKGROUND_WORK_STALE_TIME", - "apps/sim/ee/workspace-forking/hooks/workspace-fork.ts#WORKSPACE_FORK_DIFF_STALE_TIME", - "apps/sim/ee/workspace-forking/hooks/workspace-fork.ts#WORKSPACE_FORK_LINEAGE_STALE_TIME", - "apps/sim/ee/workspace-forking/hooks/workspace-fork.ts#WORKSPACE_FORK_MAPPING_STALE_TIME", "apps/sim/ee/workspace-forking/hooks/workspace-fork.ts#WORKSPACE_FORK_RESOURCES_STALE_TIME", "apps/sim/ee/workspace-forking/lib/background-work/store.ts#BackgroundWorkRow", "apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts#ForkContentKbEntry",