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..f31b3549edc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/index.ts @@ -0,0 +1,4 @@ +export { ProjectRow } from './project-row' +export { ProjectsSection } from './projects-section' +export { useProjectActions } from './use-project-actions' +export { type ProjectDragProps, useProjectOrder } from './use-project-order' 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..da87ccf4fd3 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row.tsx @@ -0,0 +1,278 @@ +'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, + Plus, + SquareArrowUpRight, + Trash, +} from '@sim/emcn/icons' +import Link from 'next/link' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +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 { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +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 { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +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 + newChatHref: string + active: boolean + /** Whether the chats under the row are shown; undefined when there are none to show. */ + expanded?: boolean + railCollapsed: boolean + drag: ProjectDragProps + isAnyDragActive: boolean + onToggleExpand: () => void +} + +/** + * A project in the organization sidebar with the workspace sidebar's row ergonomics: hover + * reveals the options menu and a new-chat button, 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, + newChatHref, + active, + expanded, + railCollapsed, + drag, + isAnyDragActive, + onToggleExpand, +}: 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 + } + onToggleExpand() + }} + onDoubleClick={(event) => { + event.preventDefault() + rename.handleStartEdit() + }} + aria-expanded={expanded} + className='flex min-w-0 flex-1 items-center gap-2' + > + + + + )} + {!railCollapsed && !rename.isEditing && ( + + ) : undefined + } + > + + + + + + + + )} +
+
+ + !open && closeMenu()} modal={false}> + +
+ + event.preventDefault()} + > + + + Open in new tab + + + + Copy link + + rename.handleStartEdit()}> + + Rename + + actions.setPinned(project, !pinned)}> + {pinned ? : } + {pinned ? 'Unpin' : 'Pin'} + + + 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..d35b8f16712 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useState } from 'react' +import { chipVariants, cn, OverflowText } from '@sim/emcn' +import { formatRelativeTime } from '@sim/utils/formatting' +import Link from 'next/link' +import { useQueryStates } from 'nuqs' +import { organizationRoutes } from '@/lib/navigation/paths' +import { ProjectRow } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row' +import { + type ProjectDragProps, + useProjectOrder, +} from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order' +import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' +import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' + +/** Chats listed under an open project before "Show more". */ +const CHAT_PREVIEW = 3 + +interface ProjectsSectionProps { + organizationId: string + isCollapsed: boolean + pathname: string | null +} + +/** The organization's projects, one per fork lineage, each with its chats underneath. */ +export function ProjectsSection({ organizationId, isCollapsed, pathname }: ProjectsSectionProps) { + const { roots } = useProjects(organizationId) + const { projects, dragProps, isAnyDragActive } = useProjectOrder(roots) + const [{ chat: openChat }] = useQueryStates(projectParsers) + return ( + +
+ {projects.map((project) => ( + + ))} +
+
+ ) +} + +interface ProjectTreeProps { + organizationId: string + project: Project + pathname: string | null + openChat: string + railCollapsed: boolean + drag: ProjectDragProps + isAnyDragActive: boolean +} + +/** + * A project row with its chats listed underneath. A project spans every environment in its + * lineage; the chats shown belong to the environment you are in. + */ +function ProjectTree({ + organizationId, + project, + pathname, + openChat, + railCollapsed, + drag, + isAnyDragActive, +}: ProjectTreeProps) { + const routes = organizationRoutes(organizationId) + const [showAll, setShowAll] = useState(false) + const activeId = + project.environments.find((environment) => { + const base = `${routes.root}/p/${environment.workspaceId}` + return pathname === base || pathname?.startsWith(`${base}/`) + })?.workspaceId ?? null + const inProject = activeId !== null + /** null follows navigation (open while you're in the project); a click pins it open or shut. */ + const [override, setOverride] = useState(null) + const expanded = override ?? inProject + const { data: chats = [] } = useMothershipChats(activeId ?? project.id) + const visible = showAll ? chats : chats.slice(0, CHAT_PREVIEW) + const panelHref = (chatId: string) => + `${inProject && pathname ? pathname : routes.project(project.id)}?chat=${chatId}` + + return ( +
+ 0 ? expanded : undefined} + railCollapsed={railCollapsed} + drag={drag} + isAnyDragActive={isAnyDragActive} + onToggleExpand={() => setOverride(inProject ? !expanded : true)} + /> + {!railCollapsed && expanded && chats.length > 0 && ( +
+ {visible.map((chat) => ( + + + {chat.isActive ? ( + + + + ) : ( + + {formatRelativeTime(chat.updatedAt.toISOString()) + .replace(' ago', '') + .replace('just now', 'now')} + + )} + + ))} + {chats.length > CHAT_PREVIEW && ( + + )} +
+ )} +
+ ) +} 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..8c1af8884c9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts @@ -0,0 +1,67 @@ +'use client' + +import { useCallback } from 'react' +import { parseEnvironmentName } from '@/lib/projects' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + useDeleteWorkspace, + usePinnedWorkspaceIds, + useToggleWorkspacePin, + useUpdateWorkspace, + useWorkspacesQuery, +} from '@/hooks/queries/workspace' + +/** + * Rename, pin and delete for a project, applied to every workspace of its lineage: a rename + * keeps each environment's own suffix ("Staging", "Sandbox"), a delete removes forks before + * their parent, and the pin lives on the root. Mock projects have no workspace, so every + * action is a no-op for them. + */ +export function useProjectActions() { + const { data: workspaces } = useWorkspacesQuery() + const { data: pinnedIds } = usePinnedWorkspaceIds() + const { mutate: togglePin } = useToggleWorkspacePin() + const { mutateAsync: updateWorkspace, isPending: isRenaming } = useUpdateWorkspace() + const { mutateAsync: deleteWorkspace, isPending: isDeleting } = useDeleteWorkspace() + + 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 base = name.trim() + if (!base || base === project.name) return + for (const environment of project.environments) { + const workspace = workspaces?.find((candidate) => candidate.id === environment.workspaceId) + if (!workspace) continue + const { environment: suffix } = parseEnvironmentName(workspace.name) + await updateWorkspace({ + workspaceId: workspace.id, + name: suffix ? `${base} ${suffix}` : base, + }) + } + }, + [updateWorkspace, workspaces] + ) + + const remove = useCallback( + async (project: Project) => { + /** Forks first: the root is the last workspace to go, so a failure leaves a valid lineage. */ + for (const environment of [...project.environments].reverse()) { + await deleteWorkspace({ workspaceId: environment.workspaceId }) + } + }, + [deleteWorkspace] + ) + + 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..382ec282a46 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order.ts @@ -0,0 +1,86 @@ +'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(roots: 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(roots.map((project) => [project.id, project])) + const ordered = orderProjectIds( + roots.map((project) => project.id), + order + ).map((id) => byId.get(id)!) + 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/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts index 14bcd85a849..b995b8bcbed 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 { Home, Integration, Search } from '@sim/emcn/icons' +import { 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' @@ -21,16 +21,24 @@ const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [ { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' }, ] +/** 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 + mothershipAvailable: boolean, + projectViewEnabled = false ): SidebarNavItemData[] { const routes = organizationRoutes(organizationId) return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => route === 'home' ? mothershipAvailable : 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 98a56a4c27e..0cba6275b4b 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 { @@ -90,7 +92,13 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { const isMac = isMacPlatform() const canUseHome = mothershipAvailable && (canBuild || searchAccess.memberScoped) - const navItems = buildOrganizationNavItems(organization.id, searchAccess.memberScoped, canUseHome) + const projectViewEnabled = useFeatureFlag('org-project-view') + const navItems = buildOrganizationNavItems( + organization.id, + searchAccess.memberScoped, + canUseHome, + projectViewEnabled + ) const settingsPath = organizationRoutes(organization.id).settings const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) @@ -258,11 +266,19 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { {...scrollFadeAttributes(scrollEdges)} >
- + {projectViewEnabled ? ( + + ) : ( + + )} {canUseHome && ( @@ -309,6 +310,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => { 'table-row-ttl': 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/project-home/index.ts b/apps/sim/app/o/[organizationId]/home/components/project-home/index.ts new file mode 100644 index 00000000000..df927eb7a8c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-home/index.ts @@ -0,0 +1 @@ +export { ProjectHome } from './project-home' diff --git a/apps/sim/app/o/[organizationId]/home/components/project-home/project-home.tsx b/apps/sim/app/o/[organizationId]/home/components/project-home/project-home.tsx new file mode 100644 index 00000000000..da4cffab6ad --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-home/project-home.tsx @@ -0,0 +1,111 @@ +'use client' + +import { type ReactElement, useState } from 'react' +import { + Chip, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearchInput, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Check, ChevronDown, Folder, X } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { organizationRoutes } from '@/lib/navigation/paths' +import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' +import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +const NO_PROJECT = 'none' + +interface ProjectHomeProps { + userName?: string +} + +/** + * Home with a project picker. Build with a project picked opens that project's chat; every + * other mode, and Build with no project, is the organization chat Home already runs. + */ +export function ProjectHome({ userName }: ProjectHomeProps) { + const router = useRouter() + const { organization } = useOrganizationContext() + const { roots: projects } = useProjects(organization.id) + const [{ project }, setParams] = useQueryStates(projectParsers) + /** The first project is the default so a fresh visit lands in a project, not an org-wide chat. */ + const selected = + project === NO_PROJECT ? undefined : (projects.find((p) => p.id === project) ?? projects[0]) + const select = (id: string) => void setParams({ project: id }) + + return ( + + mode === 'agent' + ? { + heading: selected ? `What should we build in ${selected.name}?` : undefined, + accessory: ( +
+ + + {selected ? selected.name : 'No project'} + + +
+ ), + } + : undefined + } + onBeforeSend={(message, mode) => { + if (mode !== 'agent' || !selected) return false + router.push( + `${organizationRoutes(organization.id).project(selected.id)}?chat=new&q=${encodeURIComponent(message)}` + ) + return true + }} + /> + ) +} + +interface ProjectMenuProps { + projects: Project[] + value: string + onSelect: (id: string) => void + children: ReactElement +} + +function ProjectMenu({ projects, value, onSelect, children }: ProjectMenuProps) { + const [search, setSearch] = useState('') + const matches = projects.filter((p) => p.name.toLowerCase().includes(search.toLowerCase())) + return ( + !open && setSearch('')}> + {children} + + setSearch(event.target.value)} + /> + {matches.map((project) => ( + onSelect(project.id)}> + + {project.name} + + + ))} + + onSelect(NO_PROJECT)}> + + Don’t work in a project + + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 7baabe357df..3946c9c9d8d 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { type ReactNode, useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' @@ -52,10 +52,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 } const subscribeToClient = () => () => {} @@ -82,6 +98,9 @@ function OrganizationHomeContent({ userName, chatId, requestMode: savedMode, + landing, + onBeforeSend, + syncChatUrl, }: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() @@ -114,6 +133,7 @@ function OrganizationHomeContent({ requestMode, onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, + ...(syncChatUrl === false ? { syncChatUrl } : {}), }) ) const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` @@ -296,6 +316,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([]) @@ -334,6 +359,7 @@ function OrganizationHomeContent({ /> ) + const hostLanding = hasChat ? undefined : landing?.(requestMode) const content = (
{hasChat ? ( @@ -399,13 +425,15 @@ function OrganizationHomeContent({ ) : ( + {hostLanding?.accessory} {composer}
{requestMode === 'agent' ? ( diff --git a/apps/sim/app/o/[organizationId]/home/page.tsx b/apps/sim/app/o/[organizationId]/home/page.tsx index 29e8b0351ce..881807d0996 100644 --- a/apps/sim/app/o/[organizationId]/home/page.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' +import { ProjectHome } from '@/app/o/[organizationId]/home/components/project-home' import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect' import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' @@ -23,10 +25,15 @@ export default async function OrganizationHomePage({ if (!context) notFound() const homeRedirect = getOrganizationHomeRedirect(context, organizationId) if (homeRedirect) redirect(homeRedirect) + const projectViewEnabled = await isOrgProjectViewEnabled(organizationId) return ( }> - + {projectViewEnabled ? ( + + ) : ( + + )} ) } diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 069d1d1b14f..cebd2a1c4b1 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -7,6 +7,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } 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 { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -55,19 +56,26 @@ export default async function OrganizationLayout({ if (!context.mothershipAvailable && !context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) - const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] = - await Promise.all([ - prefetchOrganizationSidebar( - queryClient, - organizationId, - { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - getActiveOrganizationId(session) - ), - isTableRowTtlEnabled(), - isMothershipModelSelectorEnabled(), - isPlanModeEnabled(), - isDashboardsEnabled(organizationId), - ]) + const [ + , + tableRowTtlEnabled, + modelSelectorEnabled, + planModeEnabled, + dashboardsEnabled, + orgProjectViewEnabled, + ] = await Promise.all([ + prefetchOrganizationSidebar( + queryClient, + organizationId, + { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + getActiveOrganizationId(session) + ), + isTableRowTtlEnabled(), + isMothershipModelSelectorEnabled(), + isPlanModeEnabled(), + isDashboardsEnabled(organizationId), + isOrgProjectViewEnabled(organizationId), + ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( @@ -78,6 +86,7 @@ export default async function OrganizationLayout({ 'table-row-ttl': tableRowTtlEnabled, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, + '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..b5e076cd174 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/[section]/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { ProjectPage } from '@/app/o/[organizationId]/p/components' +import { isProjectSection } from '@/app/o/[organizationId]/p/routes' + +export const metadata: Metadata = { title: 'Project' } + +export default async function ProjectSectionPage({ + params, +}: { + params: Promise<{ workspaceId: string; section: string }> +}) { + const { workspaceId, section } = await params + if (!isProjectSection(section)) notFound() + return ( + Loading project…

} + > + +
+ ) +} 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/dashboard-file.tsx b/apps/sim/app/o/[organizationId]/p/components/dashboard-file.tsx new file mode 100644 index 00000000000..a1a601153e1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/dashboard-file.tsx @@ -0,0 +1,30 @@ +'use client' + +import type { ReactNode } from 'react' +import { DashboardPreview } from '@/components/dashboards/dashboard-preview' +import type { ProjectResources } from '@/app/o/[organizationId]/p/hooks/use-project-resources' +import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' + +interface DashboardFileProps { + workspaceId: string + file: ProjectResources['dashboardFiles'][number] + title: ReactNode +} + +/** A real `.dashboard` workspace file, rendered live against the workspace's tables. */ +export function DashboardFile({ workspaceId, file, title }: DashboardFileProps) { + const { + data: content, + isLoading, + error, + } = useWorkspaceFileContent(workspaceId, file.id, file.key) + if (error) + return ( +

{error.message}

+ ) + if (isLoading || content === undefined) + return

Loading…

+ return ( + + ) +} 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..d84144c2e73 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx @@ -0,0 +1,61 @@ +'use client' + +import { + Chip, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Check, ChevronDown, Server } from '@sim/emcn/icons' +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' + +interface EnvironmentSwitcherProps { + project: Project + section: ProjectSection +} + +/** + * 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 }: EnvironmentSwitcherProps) { + const router = useRouter() + const { organization } = useOrganizationContext() + if (project.environments.length <= 1) + return ( + + {project.environment} + + ) + return ( + + + + {project.environment} + + + + {project.environments.map((environment) => ( + + 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/environments-tab.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environments-tab.tsx new file mode 100644 index 00000000000..583bf6d28d9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environments-tab.tsx @@ -0,0 +1,125 @@ +'use client' + +import { Skeleton } from '@sim/emcn' +import { useQueryState } from 'nuqs' +import { LineageStrip } from '@/app/o/[organizationId]/p/components/environments/lineage-strip' +import { + EnvironmentTable, + MappingGrid, + SkeletonRows, + WorkflowGrid, +} from '@/app/o/[organizationId]/p/components/environments/mapping-grid' +import { + lineageEdges, + orderEnvironments, + RESOURCE_TABS, + type ResourceTabId, + WORKFLOW_TAB, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { ResourceTabs } from '@/app/o/[organizationId]/p/components/environments/resource-tabs' +import { useEnvironmentMappings } from '@/app/o/[organizationId]/p/components/environments/use-environment-mappings' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' +import { useOptionalWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +interface EnvironmentsTabProps { + project: Project +} + +/** + * The project's fork lineage and the resource mappings between its environments: one row per + * resource, one column per environment, a sub-tab per resource type. Managing the lineage + * needs workspace admin and the fork entitlement; otherwise the strip is read-only. + */ +export function EnvironmentsTab({ project }: EnvironmentsTabProps) { + const [resource, setResource] = useQueryState( + 'resource', + projectParsers.resource.withOptions({ history: 'replace', clearOnDefault: true }) + ) + const { data: workspaces } = useWorkspacesQuery() + const availability = useForkingAvailability(project.id) + const permissions = useOptionalWorkspacePermissionsContext()?.userPermissions + const canAdmin = permissions?.canAdmin ?? false + const gateLoading = availability.isLoading || (permissions?.isLoading ?? false) + const canManage = availability.available && canAdmin + + /** 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) + const data = useEnvironmentMappings({ + columns, + edges, + forksEnabled: canManage, + workflowsEnabled: true, + }) + const { rows, workflowRows } = data + + const counts = new Map([[WORKFLOW_TAB, workflowRows.length]]) + for (const row of rows) counts.set(row.kind, (counts.get(row.kind) ?? 0) + 1) + const tab = RESOURCE_TABS.find((candidate) => candidate.id === resource) ?? RESOURCE_TABS[0] + + const notice = gateLoading + ? null + : !availability.available + ? 'Forking is not available for this workspace.' + : !canAdmin + ? 'Only workspace admins can manage environments.' + : edges.length === 0 + ? 'This project has one environment. Create a fork to map resources between environments.' + : null + const showGrid = canManage && edges.length > 0 + + return ( +
+ + {gateLoading ? : null} + {notice ?

{notice}

: null} + {showGrid ? ( +
+ void setResource(id)} /> + {data.errors.map(({ edge, message }) => ( +

+ Couldn't load the mapping between {nameOf.get(edge.parentId) ?? edge.parentId} and{' '} + {nameOf.get(edge.childId) ?? edge.childId}: {message} +

+ ))} + {data.isPending ? ( + + + + ) : tab.id === WORKFLOW_TAB ? ( + + ) : ( + row.kind === tab.id)} + canEdit={canManage} + credentialsByEnv={data.credentialsByEnv} + empty={`No ${tab.label} referenced by deployed workflows.`} + /> + )} +
+ ) : 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..cf6440d38dc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/index.ts @@ -0,0 +1,37 @@ +export { EnvironmentsTab } from '@/app/o/[organizationId]/p/components/environments/environments-tab' +export { LineageStrip } from '@/app/o/[organizationId]/p/components/environments/lineage-strip' +export { + EnvironmentTable, + MappingGrid, + SkeletonRows, + StatusBadge, + WorkflowGrid, +} from '@/app/o/[organizationId]/p/components/environments/mapping-grid' +export { + buildMappingRows, + buildWorkflowRows, + type EdgeDiff, + type EdgeMapping, + type EnvironmentColumn, + entryStatus, + isCopyableKind, + type LineageEdge, + lineageEdges, + MAPPING_STATUS, + type MappingCell, + type MappingRow, + type MappingStatus, + orderEnvironments, + RESOURCE_TAB_IDS, + RESOURCE_TABS, + type ResourceTab, + type ResourceTabId, + WORKFLOW_TAB, + type WorkflowCell, + type WorkflowRow, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' +export { ResourceTabs } from '@/app/o/[organizationId]/p/components/environments/resource-tabs' +export { + type EdgeError, + useEnvironmentMappings, +} from '@/app/o/[organizationId]/p/components/environments/use-environment-mappings' 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..49ba82449a2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/lineage-strip.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipLink, + ChipTag, + cn, + OverflowText, + Tooltip, + toast, +} from '@sim/emcn' +import { ArrowRight, ArrowUpRight, Plus, RefreshCw, Server, TriangleAlert } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { getWorkspaceSettingsHref } from '@/components/settings/navigation' +import type { GetForkLineageResponse } from '@/lib/api/contracts/workspace-fork' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import type { EnvironmentColumn } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { workspaceRoutes } from '@/app/o/[organizationId]/p/routes' +import { + forkIdParam, + forkSyncDirectionParam, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' +import { useUnlinkFork } from '@/ee/workspace-forking/hooks/workspace-fork' +import { useWorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +/** Explains a disabled Sync whose parent workspace the viewer cannot open. */ +const NO_ACCESS_TOOLTIP = "You don't have access to the parent workspace" + +/** The fork's sync page for its parent edge, pulling the parent's changes in. */ +function syncHref(column: EnvironmentColumn, parentId: string): string { + return getWorkspaceSettingsHref( + column.id, + 'forks', + new URLSearchParams({ [forkIdParam.key]: parentId, [forkSyncDirectionParam.key]: 'pull' }) + ) +} + +interface UnlinkTarget { + column: EnvironmentColumn + parent: EnvironmentColumn +} + +interface EnvironmentCardProps { + column: EnvironmentColumn + parent?: EnvironmentColumn + current: boolean + lineage?: GetForkLineageResponse + canManage: boolean + onDisconnect: () => void +} + +function EnvironmentCard({ + column, + parent, + current, + lineage, + canManage, + onDisconnect, +}: EnvironmentCardProps) { + const parentAccessible = lineage?.parent?.viewerAccessible ?? true + const undoable = lineage?.undoableRun ?? null + const showSync = canManage && parent !== undefined + return ( +
+
+ + {column.label} + {current ? Current : null} + {canManage && parent ? ( + + ) : null} +
+ +

+ {parent ? `Forked from ${parent.label}` : 'Root of the lineage'} + {undoable ? ` · last sync from ${undoable.otherName} can be undone` : ''} +

+
+ + Open workspace + + {showSync && parent && parentAccessible ? ( + + Sync + + ) : null} + {showSync && !parentAccessible ? ( + + + + + Sync + + + + {NO_ACCESS_TOOLTIP} + + ) : null} +
+
+ ) +} + +interface LineageStripProps { + project: Project + /** Root first, each fork after its parent. */ + columns: readonly EnvironmentColumn[] + /** Each environment's lineage node, once loaded; empty until the fork gate passes. */ + lineageByEnv: ReadonlyMap + /** Admin with forking available: Create fork, Sync and Disconnect are offered. */ + canManage: boolean +} + +/** + * One card per environment of the project, in lineage order. Create fork opens the real fork + * modal on the current environment; a fork's card links to its parent edge's sync page and + * can disconnect from its parent. + */ +export function LineageStrip({ project, columns, lineageByEnv, canManage }: LineageStripProps) { + const { billingEnabled } = useDeploymentShape() + const { data: creationPolicy } = useWorkspaceCreationPolicy() + const { navigateToSettings } = useSettingsNavigation() + const unlink = useUnlinkFork() + const [forkModalOpen, setForkModalOpen] = useState(false) + const [confirmUnlink, setConfirmUnlink] = useState(null) + + const byId = new Map(columns.map((column) => [column.id, column])) + const current = byId.get(project.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 ( +
+
+

Environments

+ {canManage ? ( + setForkModalOpen(true)}> + Create fork + + ) : null} +
+
    + {columns.map((column, index) => { + const parent = column.parentId ? byId.get(column.parentId) : undefined + /** The arrow reads "forked from" only when the parent is the card before this one. */ + const chained = index > 0 && parent?.id === columns[index - 1].id + return ( +
  1. + {index > 0 ? ( + + ) : null} + { + if (parent) setConfirmUnlink({ column, parent }) + }} + /> +
  2. + ) + })} +
+ + ({ 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-grid.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/mapping-grid.tsx new file mode 100644 index 00000000000..54db28d3a25 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/mapping-grid.tsx @@ -0,0 +1,313 @@ +'use client' + +import type { ReactNode } from 'react' +import { Badge, ChipCombobox, OverflowText, Skeleton, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import { + type EnvironmentColumn, + isCopyableKind, + leafResourceId, + MAPPING_STATUS, + type MappingCell, + type MappingRow, + type MappingStatus, + type WorkflowCell, + type WorkflowRow, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { useUpdateForkMapping } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** Option that clears the target so a sync copies the resource instead; handled via onSelect, never sent. */ +const NEW_COPY_VALUE = '__new_copy__' + +const SKELETON_ROW_COUNT = 3 + +interface StatusBadgeProps { + status: MappingStatus +} + +/** One cell's mapping status in the sync view's vocabulary and colors. */ +export function StatusBadge({ status }: StatusBadgeProps) { + const { label, variant } = MAPPING_STATUS[status] + return ( + + {label} + + ) +} + +interface EnvironmentTableProps { + columns: readonly EnvironmentColumn[] + children: ReactNode +} + +/** The grid frame: a resource column, then one column per environment. */ +export function EnvironmentTable({ columns, children }: EnvironmentTableProps) { + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + + {columns.map((column) => ( + + ))} + + + {children} +
+ Resource + +
+ {column.label} + +
+
+
+ ) +} + +interface EmptyRowProps { + columns: readonly EnvironmentColumn[] + children: ReactNode +} + +function EmptyRow({ columns, children }: EmptyRowProps) { + return ( + + + {children} + + + ) +} + +interface SkeletonRowsProps { + columns: readonly EnvironmentColumn[] +} + +/** Placeholder rows while the edges' mappings load. */ +export function SkeletonRows({ columns }: SkeletonRowsProps) { + return Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + + + + + {columns.map((column) => ( + + + + ))} + + )) +} + +function AbsentCell() { + return — +} + +interface MappingCellViewProps { + cell: MappingCell + /** The viewer may change this edge's target; the cell renders the candidate picker. */ + editable: boolean + /** This cell's edit is in flight. */ + pending: boolean + onChange: (targetId: string | null) => void +} + +/** A resource on one environment's side: its name (or the target picker) and its status. */ +function MappingCellView({ cell, editable, pending, onChange }: MappingCellViewProps) { + const entry = cell.entry + if (!editable || !entry) { + return ( +
+ + {cell.status ? : null} +
+ ) + } + const copyable = isCopyableKind(entry.kind) && !entry.sourceDeleted + const targetListed = entry.candidates.some((candidate) => candidate.id === entry.targetId) + const options = [ + // A stored target past the capped candidate list still shows its name when closed. + ...(entry.targetId && !targetListed + ? [{ label: cell.label, value: entry.targetId, hidden: true }] + : []), + // The way back to the copy flow after mapping: clears the target via onSelect. + ...(copyable && entry.targetId + ? [{ label: 'New copy', value: NEW_COPY_VALUE, onSelect: () => onChange(null) }] + : []), + ...entry.candidates.map((candidate) => ({ label: candidate.label, value: candidate.id })), + ] + return ( +
+ onChange(value)} + placeholder={copyable ? 'Copy on sync' : 'Select target'} + searchable + searchPlaceholder='Search targets' + disabled={pending} + /> + {cell.status ? : null} +
+ ) +} + +interface MappingGridProps { + columns: readonly EnvironmentColumn[] + rows: readonly MappingRow[] + /** Admin with forking available: child cells become candidate pickers. */ + canEdit: boolean + empty: string + /** Credentials by environment id, for the provider mark on credential rows. */ + credentialsByEnv?: ReadonlyMap> +} + +interface ResourceLabelProps { + row: MappingRow + columns: readonly EnvironmentColumn[] + credentialsByEnv?: ReadonlyMap> +} + +/** The resource's name, led by its provider's mark for a credential the lineage still holds. */ +function ResourceLabel({ row, columns, credentialsByEnv }: ResourceLabelProps) { + const leaf = row.kind === 'credential' ? leafResourceId(row, columns) : null + const credential = leaf ? credentialsByEnv?.get(leaf.environmentId)?.get(leaf.id) : undefined + const display = credential ? resolveCredentialDisplay(credential) : null + return ( +
+ {display?.icon ? : null} + +
+ ) +} + +/** + * One row per resource, one cell per environment. A child cell's picker saves through the + * edge's mapping route at once, so the forks settings page of that fork shows the same target. + */ +export function MappingGrid({ columns, rows, canEdit, credentialsByEnv, empty }: MappingGridProps) { + const updateMapping = useUpdateForkMapping() + const pendingKey = + updateMapping.isPending && updateMapping.variables + ? `${updateMapping.variables.workspaceId}:${updateMapping.variables.body.entries[0]?.sourceId ?? ''}` + : null + + const saveTarget = (cell: MappingCell, targetId: string | null) => { + if (!cell.entry || !cell.edge) return + updateMapping.mutate( + { + workspaceId: cell.edge.childId, + body: { + otherWorkspaceId: cell.edge.parentId, + direction: 'pull', + entries: [ + { resourceType: cell.entry.resourceType, sourceId: cell.entry.sourceId, targetId }, + ], + }, + }, + { + onSuccess: () => toast.success('Mapping saved'), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')), + } + ) + } + + return ( + + {rows.length === 0 ? ( + {empty} + ) : ( + rows.map((row) => ( + + + + + {columns.map((column) => { + const cell = row.cells[column.id] + return ( + + {cell ? ( + saveTarget(cell, targetId)} + /> + ) : ( + + )} + + ) + })} + + )) + )} + + ) +} + +interface WorkflowCellViewProps { + cell: WorkflowCell +} + +function WorkflowCellView({ cell }: WorkflowCellViewProps) { + return ( +
+ +
+ {cell.status ? : null} + {cell.deployed !== null ? ( + + {cell.deployed ? 'Deployed' : 'Draft'} + + ) : null} +
+
+ ) +} + +interface WorkflowGridProps { + columns: readonly EnvironmentColumn[] + rows: readonly WorkflowRow[] + empty: string +} + +/** One row per deployed workflow, with what a sync would do on each edge and each side's deployed state. */ +export function WorkflowGrid({ columns, rows, empty }: WorkflowGridProps) { + return ( + + {rows.length === 0 ? ( + {empty} + ) : ( + rows.map((row) => ( + + + + + {columns.map((column) => { + const cell = row.cells[column.id] + return ( + + {cell ? : } + + ) + })} + + )) + )} + + ) +} 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..5ee2b296553 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/mapping-model.ts @@ -0,0 +1,305 @@ +import type { BadgeProps } from '@sim/emcn' +import { + type ForkMappingEntry, + type ForkWorkflowChange, + forkCopyableKindSchema, +} from '@/lib/api/contracts/workspace-fork' +import type { ProjectEnvironment } from '@/lib/projects' +import { + MAPPING_SECTION, + type MappableMappingKind, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + +/** The workflows sub-tab: fed by the sync diff rather than a mapping kind. */ +export const WORKFLOW_TAB = 'workflow' + +export type ResourceTabId = typeof WORKFLOW_TAB | MappableMappingKind + +export interface ResourceTab { + id: ResourceTabId + label: string +} + +/** Sub-tabs in the sync view's order: workflows first, then every mapping kind. */ +export const RESOURCE_TABS: readonly ResourceTab[] = [ + { id: WORKFLOW_TAB, label: 'Workflows' }, + ...(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 `?resource=` URL param accepts. */ +export const RESOURCE_TAB_IDS: readonly ResourceTabId[] = RESOURCE_TABS.map((tab) => tab.id) + +/** The sync view's mapping vocabulary, one status per cell. */ +export type MappingStatus = 'mapped' | 'suggested' | 'copy' | 'needs-setup' | 'source-deleted' + +/** Badge copy and color per status, matching the sync view's kind badges. */ +export const MAPPING_STATUS: Record< + MappingStatus, + { label: string; variant: NonNullable } +> = { + mapped: { label: 'Mapped', variant: 'green' }, + suggested: { label: 'Suggested', variant: 'amber' }, + copy: { label: 'Copy', variant: 'gray-secondary' }, + 'needs-setup': { label: 'Needs setup', variant: 'amber' }, + 'source-deleted': { label: 'Source deleted', variant: 'red' }, +} + +/** Kinds a sync copies into the target when they are left unmapped. */ +const COPYABLE_KINDS: ReadonlySet = new Set(forkCopyableKindSchema.options) + +export function isCopyableKind(kind: string): boolean { + return COPYABLE_KINDS.has(kind) +} + +/** The status of the child side of an edge entry. */ +export function entryStatus(entry: ForkMappingEntry): MappingStatus { + if (entry.targetId) return entry.suggested ? 'suggested' : 'mapped' + if (isCopyableKind(entry.kind) && !entry.sourceDeleted) return 'copy' + return 'needs-setup' +} + +/** 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: 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 +} + +/** One environment's side of a resource row. */ +export interface MappingCell { + /** The resource's id in this environment; null while the child side has no target yet. */ + id: string | null + label: string + /** Null on the origin side, where there is nothing to map. */ + status: MappingStatus | null + /** The edge entry this cell edits; present on the child side of an edge. */ + entry?: ForkMappingEntry + edge?: LineageEdge +} + +export interface MappingRow { + key: string + kind: MappableMappingKind + /** The root-most name of the resource. */ + label: string + /** By environment (workspace) id; an environment the resource never reached has no cell. */ + cells: Record +} + +export interface EdgeMapping { + edge: LineageEdge + entries: readonly ForkMappingEntry[] +} + +function targetLabel(entry: ForkMappingEntry): string { + if (!entry.targetId) return '' + return ( + entry.candidates.find((candidate) => candidate.id === entry.targetId)?.label ?? entry.targetId + ) +} + +/** + * Joins the per-edge mappings into one row per resource: an entry's source on the parent side + * joins the row that reached that resource through an earlier edge, else starts a row. Edges + * must arrive parent first (see {@link orderEnvironments}). + */ +export function buildMappingRows(edgeMappings: readonly EdgeMapping[]): MappingRow[] { + const rows: MappingRow[] = [] + /** Rows by `${workspaceId}:${kind}:${resourceId}` for every side they reached. */ + const byResource = new Map() + for (const { edge, entries } of edgeMappings) { + for (const entry of entries) { + if (entry.kind === 'knowledge-document') continue + const sourceKey = `${edge.parentId}:${entry.kind}:${entry.sourceId}` + let row = byResource.get(sourceKey) + if (!row) { + row = { + key: sourceKey, + kind: entry.kind, + label: entry.sourceLabel, + cells: { + [edge.parentId]: { + id: entry.sourceId, + label: entry.sourceLabel, + status: entry.sourceDeleted ? 'source-deleted' : null, + }, + }, + } + rows.push(row) + byResource.set(sourceKey, row) + } + row.cells[edge.childId] = { + id: entry.targetId, + label: targetLabel(entry), + status: entryStatus(entry), + entry, + edge, + } + if (entry.targetId) { + byResource.set(`${edge.childId}:${entry.kind}:${entry.targetId}`, row) + } + } + } + return rows.sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * The resource's id in the youngest environment that has it, so a row's icon follows the leaf + * credential (one Atlassian token serves Jira and Confluence; the fork's own pick decides). + */ +export function leafResourceId( + row: MappingRow, + columns: readonly EnvironmentColumn[] +): { environmentId: string; id: string } | null { + for (let index = columns.length - 1; index >= 0; index -= 1) { + const column = columns[index] + const id = row.cells[column.id]?.id + if (id) return { environmentId: column.id, id } + } + return null +} + +/** One environment's side of a workflow row. */ +export interface WorkflowCell { + /** Null when the workflow does not exist in this environment yet. */ + name: string | null + status: MappingStatus | null + /** Null when the environment's workflow list does not name it. */ + deployed: boolean | null +} + +export interface WorkflowRow { + key: string + label: string + cells: Record +} + +export interface EdgeDiff { + edge: LineageEdge + workflows: readonly ForkWorkflowChange[] +} + +/** + * Joins the per-edge sync previews into one row per deployed workflow, by name: a pull's + * `otherName` is the parent's workflow, `currentName` the child's. `update` means both sides + * have it, `create` that the child lacks it, `archive` that only the child has it. + */ +export function buildWorkflowRows( + edgeDiffs: readonly EdgeDiff[], + deployedByEnv: ReadonlyMap> +): WorkflowRow[] { + const rows: WorkflowRow[] = [] + const byName = new Map() + const deployed = (workspaceId: string, name: string): boolean | null => + deployedByEnv.get(workspaceId)?.get(name) ?? null + for (const { edge, workflows } of edgeDiffs) { + for (const change of workflows) { + if (change.action === 'archive') { + const key = `${edge.childId}:${change.currentName}` + if (byName.has(key)) continue + const row: WorkflowRow = { + key, + label: change.currentName, + cells: { + [edge.childId]: { + name: change.currentName, + status: 'source-deleted', + deployed: deployed(edge.childId, change.currentName), + }, + }, + } + rows.push(row) + byName.set(key, row) + continue + } + const sourceKey = `${edge.parentId}:${change.otherName}` + let row = byName.get(sourceKey) + if (!row) { + row = { + key: sourceKey, + label: change.otherName, + cells: { + [edge.parentId]: { + name: change.otherName, + status: null, + deployed: deployed(edge.parentId, change.otherName), + }, + }, + } + rows.push(row) + byName.set(sourceKey, row) + } + if (change.action === 'create') { + row.cells[edge.childId] = { name: null, status: 'copy', deployed: null } + continue + } + row.cells[edge.childId] = { + name: change.currentName, + status: 'mapped', + deployed: deployed(edge.childId, change.currentName), + } + byName.set(`${edge.childId}:${change.currentName}`, row) + } + } + return rows.sort((a, b) => a.label.localeCompare(b.label)) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/resource-tabs.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/resource-tabs.tsx new file mode 100644 index 00000000000..77cf715dce7 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/resource-tabs.tsx @@ -0,0 +1,37 @@ +import { Chip } from '@sim/emcn' +import { + RESOURCE_TABS, + type ResourceTabId, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' + +interface ResourceTabsProps { + value: ResourceTabId + /** Rows per sub-tab, shown beside the label when there are any. */ + counts: ReadonlyMap + onChange: (id: ResourceTabId) => void +} + +/** The resource types of the mapping grid, in the sync view's order. */ +export function ResourceTabs({ value, counts, onChange }: ResourceTabsProps) { + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/use-environment-mappings.ts b/apps/sim/app/o/[organizationId]/p/components/environments/use-environment-mappings.ts new file mode 100644 index 00000000000..50b96163ce0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/use-environment-mappings.ts @@ -0,0 +1,147 @@ +'use client' + +import { getErrorMessage } from '@sim/utils/errors' +import { type QueryFunctionContext, useQueries } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import { + type GetForkLineageResponse, + getForkDiffContract, + getForkLineageContract, + getForkMappingContract, +} from '@/lib/api/contracts/workspace-fork' +import { + buildMappingRows, + buildWorkflowRows, + type EnvironmentColumn, + type LineageEdge, + type MappingRow, + type WorkflowRow, +} from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { + forkKeys, + WORKSPACE_FORK_DIFF_STALE_TIME, + WORKSPACE_FORK_LINEAGE_STALE_TIME, + WORKSPACE_FORK_MAPPING_STALE_TIME, +} from '@/ee/workspace-forking/hooks/workspace-fork' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' + +/** A mapping or diff request for one edge that failed, phrased for the grid. */ +export interface EdgeError { + edge: LineageEdge + message: string +} + +interface UseEnvironmentMappingsProps { + columns: readonly EnvironmentColumn[] + edges: readonly LineageEdge[] + /** Admin with forking available; the fork routes 404 or 403 otherwise. */ + forksEnabled: boolean + /** Off for a mock project, whose environments are not workspaces. */ + workflowsEnabled: boolean +} + +/** + * The per-edge mapping and sync preview for every direct edge of the lineage (each fork + * pulling from its parent), each environment's lineage node, and each environment's workflow + * list, joined into one row per resource. Shares its cache entries with the forks settings + * page through the same key factory, so an edit here shows there without a refetch. + */ +export function useEnvironmentMappings({ + columns, + edges, + forksEnabled, + workflowsEnabled, +}: UseEnvironmentMappingsProps) { + const mappings = useQueries({ + queries: edges.map((edge) => ({ + queryKey: forkKeys.mapping(edge.childId, edge.parentId, 'pull'), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkMappingContract, { + params: { id: edge.childId }, + query: { otherWorkspaceId: edge.parentId, direction: 'pull' }, + signal, + }), + staleTime: WORKSPACE_FORK_MAPPING_STALE_TIME, + enabled: forksEnabled, + })), + }) + const diffs = useQueries({ + queries: edges.map((edge) => ({ + queryKey: forkKeys.diff(edge.childId, edge.parentId, 'pull'), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkDiffContract, { + params: { id: edge.childId }, + query: { otherWorkspaceId: edge.parentId, direction: 'pull' }, + signal, + }), + staleTime: WORKSPACE_FORK_DIFF_STALE_TIME, + enabled: forksEnabled, + })), + }) + const lineages = useQueries({ + queries: columns.map((column) => ({ + queryKey: forkKeys.lineage(column.id), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkLineageContract, { params: { id: column.id }, signal }), + staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, + enabled: forksEnabled, + })), + }) + const credentialLists = useQueries({ + queries: columns.map((column) => ({ + ...workspaceCredentialListQueryOptions(column.id), + enabled: forksEnabled, + })), + }) + const workflowLists = useQueries({ + queries: columns.map((column) => ({ + ...getWorkflowListQueryOptions(column.id), + enabled: workflowsEnabled, + })), + }) + + /** A handful of edges and rows, so deriving them each render is cheaper than memoizing. */ + const lineageByEnv = new Map() + columns.forEach((column, index) => lineageByEnv.set(column.id, lineages[index]?.data)) + + /** Credentials by environment, so a credential row can show its provider's mark. */ + const credentialsByEnv = new Map>() + columns.forEach((column, index) => { + const byId = new Map() + for (const credential of credentialLists[index]?.data ?? []) byId.set(credential.id, credential) + credentialsByEnv.set(column.id, byId) + }) + + const deployedByEnv = new Map>() + columns.forEach((column, index) => { + const byName = new Map() + for (const workflow of workflowLists[index]?.data ?? []) { + byName.set(workflow.name, workflow.isDeployed ?? false) + } + deployedByEnv.set(column.id, byName) + }) + + const errors: EdgeError[] = [] + edges.forEach((edge, index) => { + const mappingError = mappings[index]?.error + const diffError = diffs[index]?.error + if (mappingError) errors.push({ edge, message: getErrorMessage(mappingError) }) + else if (diffError) errors.push({ edge, message: getErrorMessage(diffError) }) + }) + + const rows: MappingRow[] = buildMappingRows( + edges.map((edge, index) => ({ edge, entries: mappings[index]?.data?.entries ?? [] })) + ) + const workflowRows: WorkflowRow[] = buildWorkflowRows( + edges.map((edge, index) => ({ edge, workflows: diffs[index]?.data?.workflows ?? [] })), + deployedByEnv + ) + + const isPending = + forksEnabled && + (mappings.some((query) => query.isPending) || diffs.some((query) => query.isPending)) + + return { rows, workflowRows, lineageByEnv, credentialsByEnv, errors, isPending } +} diff --git a/apps/sim/app/o/[organizationId]/p/components/index.ts b/apps/sim/app/o/[organizationId]/p/components/index.ts new file mode 100644 index 00000000000..60b9d97d8b9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/index.ts @@ -0,0 +1,5 @@ +export { EnvironmentSwitcher } from './environment-switcher' +export { ProjectChatPanel } from './project-chat-panel' +export { ProjectDashboard } from './project-dashboard' +export { ProjectPage } from './project-page' +export { ProjectView } from './project-view' diff --git a/apps/sim/app/o/[organizationId]/p/components/live-chat.tsx b/apps/sim/app/o/[organizationId]/p/components/live-chat.tsx new file mode 100644 index 00000000000..96be5b798e4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/live-chat.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useSession } from '@/lib/auth/auth-client' +import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' +import { + getMothershipUseChatOptions, + useChat, +} from '@/app/workspace/[workspaceId]/home/hooks/use-chat' + +interface LiveChatProps { + /** A workspace id, or the organization for an org-wide chat. */ + owner: string | { organizationId: string } + /** Existing chat to load; omit to start a new one on the first message. */ + chatId?: string + /** Sent once when the chat starts without an id: the message handed over from the home composer. */ + initialMessage?: string + onInitialMessageSent?: () => void + /** A new chat got its id from the server. */ + onChatCreated?: (chatId: string) => void + layout: 'mothership-view' | 'copilot-view' + className?: string +} + +/** The real Sim Chat over the shared streaming hook; the surface around it owns the URL. */ +export function LiveChat({ + owner, + chatId, + initialMessage, + onInitialMessageSent, + onChatCreated, + layout, + className, +}: LiveChatProps) { + const { data: session } = useSession() + const { + messages, + isChatHistoryPending, + isSending, + isReconnecting, + sendMessage, + stopGeneration, + resolvedChatId, + messageQueue, + removeFromQueue, + sendNow, + editQueuedMessage, + cancelQueueEdit, + editingQueuedId, + dispatchingHeadId, + } = useChat( + owner, + chatId, + getMothershipUseChatOptions({ requestMode: 'agent', syncChatUrl: false }) + ) + + const sentInitialRef = useRef(false) + useEffect(() => { + if (!initialMessage || chatId || sentInitialRef.current) return + /** + * Deferred one tick: a send started inside the mount effect is withdrawn by the hook's own + * unmount cleanup when React (StrictMode in dev) simulates a remount, and the cleanup here + * cancels the timer in that case so the second mount sends it exactly once. + */ + const timer = window.setTimeout(() => { + sentInitialRef.current = true + void sendMessage(initialMessage) + onInitialMessageSent?.() + }, 0) + return () => window.clearTimeout(timer) + }, [initialMessage, chatId, sendMessage, onInitialMessageSent]) + + const announcedRef = useRef(null) + useEffect(() => { + if (!resolvedChatId || chatId || announcedRef.current === resolvedChatId) return + announcedRef.current = resolvedChatId + onChatCreated?.(resolvedChatId) + }, [resolvedChatId, chatId, onChatCreated]) + + const ownerKey = typeof owner === 'string' ? owner : `org:${owner.organizationId}` + + return ( + + void sendMessage(text, fileAttachments, contexts) + } + onStopGeneration={() => void stopGeneration()} + messageQueue={messageQueue} + editingQueuedId={editingQueuedId} + dispatchingHeadId={dispatchingHeadId} + onRemoveQueuedMessage={removeFromQueue} + onSendQueuedMessage={sendNow} + onEditQueuedMessage={editQueuedMessage} + onCancelQueueEdit={cancelQueueEdit} + userId={session?.user?.id} + chatId={resolvedChatId} + draftScopeKey={`proto:${ownerKey}:${chatId ?? 'new'}`} + layout={layout} + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-chat-panel.tsx b/apps/sim/app/o/[organizationId]/p/components/project-chat-panel.tsx new file mode 100644 index 00000000000..aaab544329f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-chat-panel.tsx @@ -0,0 +1,111 @@ +'use client' + +import { type ReactNode, useCallback, useRef } from 'react' +import { cn } from '@sim/emcn' +import { X } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { LiveChat } from '@/app/o/[organizationId]/p/components/live-chat' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' + +const NEW_CHAT = 'new' + +interface ProjectChatPanelProps { + project: Project + children: ReactNode +} + +/** Project page with the chat you opened in a side panel on the left; the page stays usable beside it. */ +export function ProjectChatPanel({ project, children }: ProjectChatPanelProps) { + const [{ chat: chatId, q }, setParams] = useQueryStates(projectParsers) + /** The id a new chat resolved to; the panel keeps its key so the thread survives the URL change. */ + const createdFromNewRef = useRef(null) + const panelKey = chatId === NEW_CHAT || chatId === createdFromNewRef.current ? NEW_CHAT : chatId + + const onChatCreated = useCallback( + (id: string) => { + createdFromNewRef.current = id + void setParams({ chat: id }, { history: 'replace' }) + }, + [setParams] + ) + const onInitialMessageSent = useCallback( + () => void setParams({ q: null }, { history: 'replace' }), + [setParams] + ) + + return ( +
+
+ {chatId && ( + void setParams({ chat: null, q: null }, { history: 'replace' })} + /> + )} +
+
{children}
+
+ ) +} + +interface SideChatProps { + project: Project + chatId: string + initialMessage?: string + onChatCreated: (chatId: string) => void + onInitialMessageSent: () => void + onClose: () => void +} + +function SideChat({ + project, + chatId, + initialMessage, + onChatCreated, + onInitialMessageSent, + onClose, +}: SideChatProps) { + const { data: chats } = useMothershipChats(project.id) + const chat = chats?.find((candidate) => candidate.id === chatId) + const title = chat?.name ?? (chatId === NEW_CHAT ? `New chat in ${project.name}` : 'Chat') + + return ( +
+
+ {chat?.isActive && ( + + )} + {title} + +
+ +
+ ) +} 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..bcfeeeed968 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { ReactNode } from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn' +import { Check, ChevronDown } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { DashboardFile } from '@/app/o/[organizationId]/p/components/dashboard-file' +import { RunsDashboard } from '@/app/o/[organizationId]/p/components/runs-dashboard' +import { useProjectResources } from '@/app/o/[organizationId]/p/hooks/use-project-resources' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' + +const RUNS_ID = 'runs' + +interface DashboardOption { + id: string + title: string + kind: 'file' | 'runs' +} + +/** The project's dashboards: its `.dashboard` files rendered live, then a built-in Runs dashboard. */ +export function ProjectDashboard({ project }: { project: Project }) { + const [{ dashboard: selected }, setParams] = useQueryStates(projectParsers) + const { dashboardFiles, filesPending } = useProjectResources(project.id) + + if (filesPending) + return

Loading…

+ + const options: DashboardOption[] = [ + ...dashboardFiles.map((file) => ({ id: file.id, title: file.name, kind: 'file' as const })), + { id: RUNS_ID, title: 'Runs', kind: 'runs' }, + ] + const current = options.find((option) => option.id === selected) ?? options[0] + + const title = ( + + + + + + {options.map((option) => ( + void setParams({ dashboard: option.id }, { history: 'replace' })} + > + {option.title} + {option.id === current.id && } + + ))} + + + ) + + return +} + +interface DashboardBodyProps { + project: Project + option: DashboardOption + title: ReactNode +} + +function DashboardBody({ project, option, title }: DashboardBodyProps) { + const { dashboardFiles } = useProjectResources(project.id) + if (option.kind === 'runs') return + const file = dashboardFiles.find((candidate) => candidate.id === option.id) + if (!file) return null + return +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-page.tsx b/apps/sim/app/o/[organizationId]/p/components/project-page.tsx new file mode 100644 index 00000000000..a47a30b9fd4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-page.tsx @@ -0,0 +1,29 @@ +'use client' + +import { notFound } from 'next/navigation' +import { ProjectChatPanel } from '@/app/o/[organizationId]/p/components/project-chat-panel' +import { ProjectView } from '@/app/o/[organizationId]/p/components/project-view' +import { useProject } 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' + +interface ProjectPageProps { + workspaceId: string + section: ProjectSection +} + +/** Resolves the organization's workspace behind the route as a project, then renders its section. */ +export function ProjectPage({ workspaceId, section }: ProjectPageProps) { + const { organization } = useOrganizationContext() + const { project, isPending } = useProject(organization.id, workspaceId) + if (!project) { + if (isPending) + return

Loading project…

+ notFound() + } + 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..77e7354568c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx @@ -0,0 +1,99 @@ +'use client' + +import { ChipLink } from '@sim/emcn' +import { Expand } from '@sim/emcn/icons' +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 { useProjectDescription } from '@/app/o/[organizationId]/p/hooks/use-project-resources' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + PROJECT_SECTIONS, + type ProjectSection, + workspaceRoutes, +} from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectViewProps { + project: Project + section: ProjectSection +} + +/** A project's main view: its name, the environment, and a chip per section. Build opens the workspace. */ +export function ProjectView({ project, section }: ProjectViewProps) { + const { organization } = useOrganizationContext() + const routes = organizationRoutes(organization.id) + return ( +
+
+
+
+

{project.name}

+ +
+
+ + + Open full view + +
+
+ +
+
+ +
+
+ ) +} + +function ProjectDescription({ project }: { project: Project }) { + const text = useProjectDescription(project) + return

{text}

+} + +interface SectionBodyProps { + project: Project + section: ProjectSection +} + +function SectionBody({ project, section }: SectionBodyProps) { + switch (section) { + case 'dashboard': + return ( +
+ +
+ ) + case 'changelog': + return No releases yet. + case 'issues': + return No issues yet. + case 'environments': + return + } +} + +/** Changelog and Issues until their entities exist. */ +function EmptySection({ children }: { children: string }) { + return

{children}

+} diff --git a/apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx b/apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx new file mode 100644 index 00000000000..c6bce6e9e09 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx @@ -0,0 +1,194 @@ +'use client' + +import { type ReactNode, useMemo, useState } from 'react' +import { Chip, DashboardMetric, LineChart, Loader } from '@sim/emcn' +import Link from 'next/link' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { workspaceRoutes } from '@/app/o/[organizationId]/p/routes' +import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' +import { useDashboardStats } from '@/hooks/queries/logs' +import type { TimeRange } from '@/stores/logs/filters/types' + +const RANGES: { id: TimeRange; label: string }[] = [ + { id: 'Past 24 hours', label: '24h' }, + { id: 'Past 7 days', label: '7d' }, + { id: 'Past 30 days', label: '30d' }, +] + +interface RunsDashboardProps { + project: Project + title: ReactNode +} + +/** Runs, errors and latency for every workflow in the project, from the workspace's execution stats. */ +export function RunsDashboard({ project, title }: RunsDashboardProps) { + const [range, setRange] = useState('Past 7 days') + const { data, isPending, error } = useDashboardStats(project.id, { + timeRange: range, + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + searchQuery: '', + }) + + const series = useMemo(() => { + const segments = data?.aggregateSegments ?? [] + return { + runs: segments.map((segment) => ({ + timestamp: segment.timestamp, + value: segment.totalExecutions, + })), + errors: segments.map((segment) => ({ + timestamp: segment.timestamp, + value: segment.totalExecutions - segment.successfulExecutions, + })), + } + }, [data]) + + const successRate = + data && data.totalRuns > 0 + ? Math.round(((data.totalRuns - data.totalErrors) / data.totalRuns) * 100) + : null + const workflows = [...(data?.workflows ?? [])].sort( + (a, b) => b.totalExecutions - a.totalExecutions + ) + + return ( +
+
+
+ {title} +
+ {RANGES.map((option) => ( + setRange(option.id)} + > + {option.label} + + ))} +
+
+ + {error ? ( +

{error.message}

+ ) : ( + <> +
+ + + + +
+ +
+ + + + + + +
+ +
+
+ Workflows + {workflows.length} +
+ {isPending ? ( +
+ +
+ ) : workflows.length === 0 ? ( +

+ No runs in this period. +

+ ) : ( +
    + {workflows.map((workflow) => ( +
  • + + {workflow.workflowName} + + + {workflow.totalExecutions.toLocaleString()} runs + + + {(workflow.totalExecutions - workflow.totalSuccessful).toLocaleString()}{' '} + errors + + + {Math.round(workflow.overallSuccessRate)}% + +
  • + ))} +
+ )} +
+ + )} +
+
+ ) +} + +interface TileProps { + label: string + value: string | number + unit?: string + loading: boolean +} + +function Tile({ label, value, unit, loading }: TileProps) { + return ( +
+ +
+ ) +} + +interface ChartCardProps { + label: string + count?: number + loading: boolean + children: ReactNode +} + +function ChartCard({ label, count, loading, children }: ChartCardProps) { + return ( +
+
+ {label} + {count !== undefined && ( + {count.toLocaleString()} + )} +
+
+ {loading ? ( +
+ +
+ ) : ( + children + )} +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts new file mode 100644 index 00000000000..60fdebd60bf --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useFolderMap } from '@/hooks/queries/folders' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' + +/** Every resource list a project page reads, from the workspace's own React Query hooks. */ +export function useProjectResources(workspaceId: string) { + const enabled = Boolean(workspaceId) + const workflows = useWorkflows(workspaceId || undefined, { enabled }) + const folders = useFolderMap(workspaceId || undefined) + const tables = useTablesList(workspaceId || undefined, 'active', { enabled }) + const knowledgeBases = useKnowledgeBasesQuery(workspaceId || undefined, { + includeCounts: true, + enabled, + }) + const files = useWorkspaceFiles(workspaceId, 'active', { enabled }) + const members = useWorkspaceMembersQuery(workspaceId || undefined) + + const memberNames = useMemo(() => { + const names = new Map() + for (const member of members.data ?? []) names.set(member.userId, member.name) + return names + }, [members.data]) + + const fileLists = useMemo(() => { + const all = files.data ?? [] + return { + files: all, + dashboardFiles: all.filter((file) => file.type === DASHBOARD_CONTENT_TYPE), + } + }, [files.data]) + + return { + workflows: workflows.data ?? [], + folders: folders.data ?? {}, + tables: tables.data ?? [], + knowledgeBases: knowledgeBases.data ?? [], + files: fileLists.files, + dashboardFiles: fileLists.dashboardFiles, + memberNames, + isPending: + enabled && + (workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending), + filesPending: enabled && files.isPending, + } +} + +export type ProjectResources = ReturnType + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +/** What the project's workspace holds, as its description line. */ +export function useProjectDescription(project: Project): string { + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) + if (isPending) return '' + return [ + plural(workflows.length, 'workflow'), + plural(tables.length, 'table'), + plural(knowledgeBases.length, 'knowledge base'), + plural(files.length, 'file'), + ].join(' · ') +} 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..b1976ff77d0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts @@ -0,0 +1,46 @@ +import { type ProjectEnvironment, resolveProjectLineages } from '@/lib/projects' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +/** A workspace seen as a project: its fork lineage, with this workspace as one environment. */ +export interface Project { + /** The workspace this view is on; every route and hook uses it. */ + id: string + name: string + organizationId: string | null + /** The workspace at the top of the lineage; lists show one project per root. */ + rootId: string + /** Which environment this workspace is within its project: Prod, Staging, Sandbox. */ + environment: string + /** Every environment of the project, root first. */ + environments: ProjectEnvironment[] +} + +/** The organization's projects: every workspace it hosts, grouped by fork lineage. */ +export function useProjects(organizationId: string) { + const query = useWorkspacesQuery() + const workspaces = (query.data ?? []).filter( + (workspace) => workspace.organizationId === organizationId + ) + /** A handful of rows, so deriving them each render is cheaper than a memo keyed on the list. */ + const lineages = resolveProjectLineages(workspaces) + const projects: Project[] = workspaces.map((workspace) => { + const lineage = lineages.get(workspace.id) + if (!lineage) throw new Error(`No lineage for workspace ${workspace.id}`) + return { + id: workspace.id, + name: lineage.name, + organizationId: workspace.organizationId ?? null, + rootId: lineage.rootId, + environment: lineage.environment, + environments: lineage.environments, + } + }) + /** One entry per project for the sidebar and pickers: each lineage's root. */ + const roots = projects.filter((project) => project.rootId === project.id) + return { projects, roots, isPending: query.isPending, error: query.error } +} + +export function useProject(organizationId: string, workspaceId: string) { + const { projects, isPending, error } = useProjects(organizationId) + return { project: projects.find((project) => project.id === workspaceId), isPending, 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..e40e9c970ed --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/routes.ts @@ -0,0 +1,22 @@ +import { Layout, ListChecks, Rss, Server } from '@sim/emcn/icons' + +/** The project main view's sections, in the order its chips read. */ +export const PROJECT_SECTIONS = [ + { id: 'dashboard', label: 'Dashboard', icon: Layout }, + { id: 'changelog', label: 'Changelog', icon: Rss }, + { id: 'issues', label: 'Issues', icon: ListChecks }, + { id: 'environments', label: 'Environments', icon: Server }, +] 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) +} + +/** The workspace pages a project's resources open in. */ +export const workspaceRoutes = { + workflows: (workspaceId: string) => `/workspace/${workspaceId}/w`, + workflow: (workspaceId: string, workflowId: string) => + `/workspace/${workspaceId}/w/${workflowId}`, +} 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..919d46d3300 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/search-params.ts @@ -0,0 +1,15 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { RESOURCE_TAB_IDS } from '@/app/o/[organizationId]/p/components/environments/mapping-model' + +export const projectParsers = { + /** Chat open in the side panel next to the project; `new` before the first message. */ + chat: parseAsString.withDefault(''), + /** Project a new Build chat starts in from Home; `none` means an organization chat. */ + project: parseAsString.withDefault(''), + /** First message handed from Home to a project's new chat; sent once, then cleared. */ + q: parseAsString.withDefault(''), + /** Dashboard shown on the project page: a dashboard file id, or `runs`. */ + dashboard: parseAsString.withDefault(''), + /** Resource type shown in the Environments tab: workflows, then the mapping kinds. */ + resource: parseAsStringLiteral(RESOURCE_TAB_IDS).withDefault('workflow'), +} diff --git a/apps/sim/app/playground/org/[[...slug]]/page.tsx b/apps/sim/app/playground/org/[[...slug]]/page.tsx deleted file mode 100644 index a24a1d86368..00000000000 --- a/apps/sim/app/playground/org/[[...slug]]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Suspense } from 'react' -import { ProtoPage } from '@/app/playground/org/components/proto-page' - -export default async function OrgPrototypePage({ - params, -}: { - params: Promise<{ slug?: string[] }> -}) { - const { slug } = await params - return ( - Loading prototype…

} - > - -
- ) -} diff --git a/apps/sim/app/playground/org/components/agent-run.tsx b/apps/sim/app/playground/org/components/agent-run.tsx deleted file mode 100644 index 3dccf318f60..00000000000 --- a/apps/sim/app/playground/org/components/agent-run.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Avatar, cn } from '@sim/emcn' -import { CircleCheck, Mic, Plus } from '@sim/emcn/icons' -import Link from 'next/link' -import { ShimmerText } from '@/components/ui/shimmer-text' -import { LinkedTickets } from '@/app/playground/org/components/linked-tickets' -import type { RunningWork } from '@/app/playground/org/lib/changelog-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' - -const NARRATION_INTERVAL_S = 7 - -function formatElapsed(total: number): string { - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return minutes ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s` -} - -interface AgentRunProps { - work: RunningWork - workspaceId: string -} - -/** - * A background run seen from inside its chat: elapsed time, what the agent is saying now, - * a Thinking shimmer, and a composer to steer, auto-approve, or stop it. - */ -export function AgentRun({ work, workspaceId }: AgentRunProps) { - const [seconds, setSeconds] = useState(0) - const [autoApprove, setAutoApprove] = useState(false) - const [stopped, setStopped] = useState(false) - const [draft, setDraft] = useState('') - const [steer, setSteer] = useState(null) - - useEffect(() => { - if (stopped) return - const timer = window.setInterval(() => setSeconds((s) => s + 1), 1000) - return () => window.clearInterval(timer) - }, [stopped]) - - const elapsed = work.startedMinutesAgo * 60 + seconds - const shown = Math.min(work.narration.length, 1 + Math.floor(seconds / NARRATION_INTERVAL_S)) - const owner = work.chat.owner.split(' ')[0] - - return ( -
- - {stopped - ? `Stopped after ${formatElapsed(elapsed)} · ${work.task}` - : `Working for ${formatElapsed(elapsed)} · ${work.task}`} - -
- {work.narration.slice(0, shown).map((line, index) => ( -

- {line} -

- ))} - {steer && ( -

- {steer} -

- )} - {!stopped && Thinking} -
- -
- setDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && draft.trim()) { - setSteer(draft.trim()) - setDraft('') - } - }} - placeholder={stopped ? 'Stopped' : `Steer ${work.agent}…`} - className='w-full bg-transparent text-[var(--text-body)] text-md outline-none placeholder:text-[var(--text-muted)]' - /> -
- - - - - - {work.issue} - - - - - - {`${owner}’s chat`} - - - - -
-
-
- ) -} diff --git a/apps/sim/app/playground/org/components/changelog.tsx b/apps/sim/app/playground/org/components/changelog.tsx deleted file mode 100644 index 35fc176d62c..00000000000 --- a/apps/sim/app/playground/org/components/changelog.tsx +++ /dev/null @@ -1,421 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Avatar, Chip, ChipTag, cn, DashboardMetric, toast } from '@sim/emcn' -import { CircleAlert, CircleCheck, Rocket } from '@sim/emcn/icons' -import Link from 'next/link' -import { EChartsView } from '@/components/charts/echarts-view' -import { SlackMonoIcon } from '@/components/icons' -import { RunningDot } from '@/app/playground/org/components/glyphs' -import { - CHANGELOG, - type ChangelogChart, - type ChangelogEntry, - type ChangelogMetric, - DRAFTS, - type DraftRelease, -} from '@/app/playground/org/lib/changelog-data' -import type { Workspace } from '@/app/playground/org/lib/mock-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' - -const RELEASE_YEAR = 2026 -const TODAY = 'Sep 28' -const BEFORE_COLOR = '#A4A9B1' -const AFTER_COLOR = '#5C7399' -const H2 = - '@min-[1000px]/dashboard:text-[24px] text-[20px] text-[var(--text-primary)] leading-tight tracking-[-0.02em]' - -/** Next release on top (what's ready, what's still running), shipped releases below. */ -export function Changelog({ workspace }: { workspace: Workspace }) { - const draft = DRAFTS.find((d) => d.workspaceId === workspace.id) - const [released, setReleased] = useState(null) - const shipped = [ - ...(released ? [released] : []), - ...CHANGELOG.filter((entry) => entry.workspaceId === workspace.id), - ] - - const release = (draftRelease: DraftRelease) => { - const ready = draftRelease.changes.filter((change) => change.state === 'ready') - const workflowsShipped = [...new Set(ready.map((change) => change.workflow))] - setReleased({ - id: 'released-now', - workspaceId: draftRelease.workspaceId, - date: TODAY, - version: draftRelease.versions - .filter((v) => workflowsShipped.includes(v.workflow)) - .map((v) => v.to) - .join(' · '), - workflows: workflowsShipped, - title: draftRelease.release.title, - summary: draftRelease.release.summary, - changes: ready, - metrics: [], - reporters: draftRelease.release.reporters, - channel: draftRelease.release.channel, - live: { label: 'runs since release', goodLabel: 'errors' }, - }) - toast.success(`Released ${ready.length} changes`) - } - - return ( -
-
-
-
-

- Changelog -

-

- What’s about to ship in {workspace.name}, what Sim is still working on, and what - already changed. -

-
-
- - {draft && ( - release(draft)} - /> - )} - - {shipped.map((entry) => ( - - ))} - - {!draft && shipped.length === 0 && ( -

Nothing has shipped here yet.

- )} -
-
- ) -} - -interface NextReleaseProps { - draft: DraftRelease - released: boolean - onRelease: () => void -} - -function NextRelease({ draft, released, onRelease }: NextReleaseProps) { - const changes = draft.changes.filter((change) => !released || change.state === 'approval') - const readyCount = released ? 0 : draft.changes.filter((c) => c.state === 'ready').length - const held = draft.changes.filter((change) => change.state === 'approval') - return ( -
-
-
-
-

Next release

- Draft -
-

- {draft.versions.map((v) => `${v.workflow} ${v.from} → ${v.to}`).join(' · ')} -

-
- - {readyCount === 0 ? 'Nothing ready' : `Release ${readyCount} changes`} - -
- -
- 0 - ? `${held.length} held until approved, the rest ship together` - : undefined - } - /> - {changes.length === 0 ? ( -

Everything ready has shipped.

- ) : ( -
    - {changes.map((change) => ( - - ))} -
- )} -
- -
- -
    - {draft.running.map((work) => ( -
  • - - - {work.task} - {work.step} - -
  • - ))} -
-
- - -
- ) -} - -function SubHeading({ title, aside }: { title: string; aside?: string }) { - return ( -
- {title} - {aside && {aside}} -
- ) -} - -interface DraftChangeRowProps { - change: DraftRelease['changes'][number] - workspaceId: string -} - -function DraftChangeRow({ change, workspaceId }: DraftChangeRowProps) { - const needsApproval = change.state === 'approval' - return ( -
  • - - {needsApproval ? ( - - ) : ( - - )} - {change.text} - {needsApproval && ( - Needs approval - )} - -
  • - ) -} - -function Release({ entry }: { entry: ChangelogEntry }) { - const fresh = entry.id === 'released-now' - return ( -
    - - -
    -
    -

    {entry.title}

    -

    - {entry.summary} -

    -
    - -
      - {entry.changes.map((change) => ( -
    • - - - {change.text} - -
    • - ))} -
    - - {entry.metrics.length > 0 && ( -
    - {entry.metrics.map((metric) => ( - - ))} -
    - )} - - {entry.chart ? ( - - ) : ( -

    - Impact charts appear after a day of runs. -

    - )} - - {entry.live && } - - {entry.reporters.length > 0 && ( -
    -
    - {entry.reporters.map((name) => ( - - ))} -
    - {`Replied to ${entry.reporters.join(', ')} in`} - - - {entry.channel} - -
    - )} -
    -
    - ) -} - -function Metric({ metric }: { metric: ChangelogMetric }) { - const change = - metric.before === 0 ? null : Math.round(((metric.after - metric.before) / metric.before) * 100) - const improved = - change === null || change === 0 ? null : metric.lowerIsBetter ? change < 0 : change > 0 - return ( -
    - - - {change === null || change === 0 - ? 'unchanged' - : `${change > 0 ? '+' : ''}${change}% · was ${metric.before}${metric.unit ?? ''}`} - -
    - ) -} - -function dayLabel(date: string, offset: number): string { - const day = new Date(`${date} ${RELEASE_YEAR} 00:00:00 UTC`) - day.setUTCDate(day.getUTCDate() + offset) - return day.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) -} - -interface ReleaseChartProps { - chart: ChangelogChart - date: string - marker: string - shaded: boolean -} - -function ReleaseChart({ chart, date, marker, shaded }: ReleaseChartProps) { - const labels = chart.values.map((_, index) => dayLabel(date, index - chart.releaseIndex)) - const releaseLabel = labels[chart.releaseIndex] - const data = chart.values.map((value, index) => - chart.kind === 'bar' - ? { - value, - itemStyle: { color: shaded && index >= chart.releaseIndex ? AFTER_COLOR : BEFORE_COLOR }, - } - : value - ) - const option = { - grid: { left: 8, right: 56, top: 28, bottom: 4, containLabel: true }, - tooltip: { trigger: 'axis' }, - xAxis: { - type: 'category', - data: labels, - axisTick: { show: false }, - axisLabel: { interval: 3 }, - boundaryGap: chart.kind === 'bar', - }, - yAxis: { - type: 'value', - splitNumber: 3, - axisLabel: chart.unit ? { formatter: `{value}${chart.unit}` } : undefined, - }, - series: [ - { - type: chart.kind, - name: chart.title, - data, - barWidth: '56%', - smooth: 0.25, - showSymbol: false, - lineStyle: { width: 2, color: AFTER_COLOR }, - itemStyle: { color: AFTER_COLOR }, - areaStyle: chart.kind === 'line' ? { opacity: 0.06, color: AFTER_COLOR } : undefined, - markLine: { - symbol: 'none', - silent: true, - lineStyle: { type: 'dashed', color: BEFORE_COLOR }, - label: { formatter: marker, position: 'end', color: BEFORE_COLOR }, - data: [{ xAxis: releaseLabel }], - }, - markArea: shaded - ? { - silent: true, - itemStyle: { color: AFTER_COLOR, opacity: 0.07 }, - label: { - show: true, - position: 'insideTop', - color: AFTER_COLOR, - formatter: 'After release', - }, - data: [[{ xAxis: releaseLabel }, { xAxis: labels[labels.length - 1] }]], - } - : undefined, - }, - ], - } - return ( -
    -
    - {chart.title} - - {shaded ? 'two weeks around the release' : 'last two weeks'} - -
    -
    - -
    -
    - ) -} - -interface LiveSinceReleaseProps { - live: NonNullable - start: number -} - -/** Ticks while the page is open, standing in for a live query against the run log. */ -function LiveSinceRelease({ live, start }: LiveSinceReleaseProps) { - const [runs, setRuns] = useState(start) - useEffect(() => { - const timer = window.setInterval(() => setRuns((count) => count + 1), 2400) - return () => window.clearInterval(timer) - }, []) - return ( -
    - - - - - {`${runs.toLocaleString()} ${live.label}`} - · - {`0 ${live.goodLabel}`} - Live -
    - ) -} diff --git a/apps/sim/app/playground/org/components/chat-thread.tsx b/apps/sim/app/playground/org/components/chat-thread.tsx deleted file mode 100644 index 3bd942f22ad..00000000000 --- a/apps/sim/app/playground/org/components/chat-thread.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' - -import { useState } from 'react' -import { cn } from '@sim/emcn' -import { MockComposer } from '@/app/playground/org/components/mock-composer' - -interface Message { - role: 'user' | 'assistant' - text: string -} - -const SEED: Message[] = [ - { role: 'user', text: 'Why is churn-agent slow on batch 3?' }, - { - role: 'assistant', - text: 'Batch 3 has 214 accounts with no plan field, so each one falls back to a full usage scan. I can backfill plan from Stripe first — want me to add that as a step on SUP-131?', - }, -] - -interface ChatThreadProps { - placeholder: string - className?: string -} - -/** Static chat thread; replies are canned so the layout can be judged without Mothership. */ -export function ChatThread({ placeholder, className }: ChatThreadProps) { - const [messages, setMessages] = useState(SEED) - return ( -
    -
    - {messages.map((message, index) => - message.role === 'user' ? ( -
    - {message.text} -
    - ) : ( -

    - {message.text} -

    - ) - )} -
    -
    - - setMessages((prev) => [ - ...prev, - { role: 'user', text }, - { role: 'assistant', text: 'On it — this is a prototype, so this reply is canned.' }, - ]) - } - /> -
    -
    - ) -} diff --git a/apps/sim/app/playground/org/components/delegate-avatar.tsx b/apps/sim/app/playground/org/components/delegate-avatar.tsx deleted file mode 100644 index 14ac9eff9f2..00000000000 --- a/apps/sim/app/playground/org/components/delegate-avatar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Avatar, cn } from '@sim/emcn' -import { Sim, Workflow, Zap } from '@sim/emcn/icons' -import type { Delegate, Person } from '@/app/playground/org/lib/mock-data' - -const DELEGATE_ICON = { agent: Zap, workflow: Workflow, sim: Sim } as const - -interface DelegateAvatarProps { - owner: Person - delegate?: Delegate - className?: string -} - -/** Owner avatar; a delegated issue carries a small badge for the agent working it. */ -export function DelegateAvatar({ owner, delegate, className }: DelegateAvatarProps) { - const Icon = delegate ? DELEGATE_ICON[delegate.kind] : null - return ( - - - {Icon && ( - - - - )} - - ) -} - -export function delegateIcon(delegate: Delegate) { - return DELEGATE_ICON[delegate.kind] -} diff --git a/apps/sim/app/playground/org/components/glyphs.tsx b/apps/sim/app/playground/org/components/glyphs.tsx deleted file mode 100644 index 7aaa529fca9..00000000000 --- a/apps/sim/app/playground/org/components/glyphs.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { cn } from '@sim/emcn' -import { - Bell, - CircleAlert, - CircleCheck, - CircleX, - Clock, - Mail, - MessageSquareText, -} from '@sim/emcn/icons' -import { SlackMonoIcon } from '@/components/icons' -import type { - AgentState, - FeedbackSource, - IssueStatus, - Priority, -} from '@/app/playground/org/lib/mock-data' - -const GLYPH = 'size-[14px] shrink-0' - -/** Linear-style status circle: dashed backlog, empty todo, half in-progress, filled done. */ -export function StatusIcon({ status, className }: { status: IssueStatus; className?: string }) { - if (status === 'done') - return - if (status === 'canceled') - return - return ( - - - {status === 'in_progress' && ( - - )} - {status === 'blocked' && ( - - )} - - ) -} - -/** Signal bars for High/Medium/Low, a filled square for Urgent, dashes for none. */ -export function PriorityIcon({ priority, className }: { priority: Priority; className?: string }) { - if (priority === 1) - return ( - - - - - ) - const filled = priority === 0 ? 0 : 5 - priority - return ( - - {[0, 1, 2].map((bar) => - priority === 0 ? ( - - ) : ( - - ) - )} - - ) -} - -/** Agent session state, mirroring Linear's pending/active/awaitingInput/error/complete/stale. */ -export function AgentStateIcon({ state, className }: { state: AgentState; className?: string }) { - switch (state) { - case 'active': - return - case 'awaitingInput': - return - case 'error': - return - case 'complete': - return - case 'stale': - return - case 'pending': - return - } -} - -export function SourceIcon({ source, className }: { source: FeedbackSource; className?: string }) { - if (source === 'slack') - return - if (source === 'pagerduty') - return - if (source === 'email') - return - return -} - -/** An agent working right now: a yellow dot, used everywhere a run is in progress. */ -export function RunningDot({ className }: { className?: string }) { - return ( - - - - ) -} diff --git a/apps/sim/app/playground/org/components/investigation/evidence.tsx b/apps/sim/app/playground/org/components/investigation/evidence.tsx deleted file mode 100644 index 0b9b0fb66bd..00000000000 --- a/apps/sim/app/playground/org/components/investigation/evidence.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Chip, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@sim/emcn' -import { ArrowUpRight } from '@sim/emcn/icons' -import type { Evidence, RunBlock } from '@/app/playground/org/lib/infra-data' - -const MONO = 'font-mono text-caption' - -/** One piece of investigation evidence: a run trace, a slice of the run log, or a block diff. */ -export function EvidenceView({ evidence }: { evidence: Evidence }) { - switch (evidence.type) { - case 'run': - return ( - - - - ) - case 'runs': - return ( - - - - - {evidence.columns.map((column) => ( - {column} - ))} - - - - {evidence.rows.map((row, index) => ( - - {row.map((cell, cellIndex) => ( - - {cell} - - ))} - - ))} - -
    -
    - ) - case 'diff': - return ( - - - - ) - } -} - -export function DiffLines({ lines }: { lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] }) { - return ( -
    - {lines.map((line, index) => ( -
    - {line.kind === 'add' ? '+ ' : line.kind === 'del' ? '- ' : ' '} - {line.text} -
    - ))} -
    - ) -} - -interface EvidenceFrameProps { - title: string - subtitle: string - action?: string - children: React.ReactNode -} - -function EvidenceFrame({ title, subtitle, action, children }: EvidenceFrameProps) { - return ( -
    -
    - {title} - - {subtitle} - - {action && ( - - {action} - - )} -
    - {children} -
    - ) -} - -/** Block-by-block run trace, the same shape as the Logs trace view. */ -function RunTrace({ blocks }: { blocks: RunBlock[] }) { - const total = Math.max(...blocks.map((block) => block.start + block.duration)) - return ( -
    - {blocks.map((block) => ( -
    - - {block.name} - - {block.type} -
    -
    -
    - - {block.duration.toLocaleString()} ms - - {block.output && ( - - {block.output} - - )} -
    - ))} -
    - ) -} diff --git a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx b/apps/sim/app/playground/org/components/investigation/investigation-view.tsx deleted file mode 100644 index 5051a81f344..00000000000 --- a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx +++ /dev/null @@ -1,512 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Chip, ChipTag, cn, toast } from '@sim/emcn' -import { - ArrowUpRight, - ChevronDown, - CircleCheck, - Clock, - Database, - Library, - MessageSquareText, - RefreshCw, - Rocket, - SlidersHorizontal, - Workflow, -} from '@sim/emcn/icons' -import { ConfluenceIcon, SlackMonoIcon } from '@/components/icons' -import { AgentStateIcon } from '@/app/playground/org/components/glyphs' -import { DiffLines, EvidenceView } from '@/app/playground/org/components/investigation/evidence' -import type { - Investigation, - InvestigationStep, - SourceMessage, -} from '@/app/playground/org/lib/infra-data' - -const STEP_ICONS: Record> = { - feedback: MessageSquareText, - check: Database, - workflow: Workflow, - runs: Library, - run: Library, - config: SlidersHorizontal, - history: Clock, - conclusion: CircleCheck, -} - -const EVIDENCE_LABEL = { run: 'run', runs: 'runs', diff: 'change' } as const - -type Decision = 'pending' | 'redeployed' | 'rejected' - -const SECTION_IDS = [ - 'summary', - 'report', - 'documents', - 'rootCause', - 'frequency', - 'proposal', - 'investigation', -] as const -type SectionId = (typeof SECTION_IDS)[number] - -/** An agent investigation of a workflow, read top-down, ending in a redeploy approval. */ -export function InvestigationView({ investigation }: { investigation: Investigation }) { - const [decision, setDecision] = useState('pending') - const { origin, documents } = investigation - - const sections: Record = { - summary: { - id: 'summary', - title: 'Summary', - children: , - }, - report: { - id: 'report', - title: origin.kind === 'slack' ? 'Reported in Slack' : 'Detected by a knowledge check', - children: - origin.kind === 'slack' ? ( - - ) : ( - - ), - }, - documents: { - id: 'documents', - title: 'Contradicting documents', - aside: documents ? `${documents.length} Confluence pages` : undefined, - children: documents ? : null, - }, - rootCause: { - id: 'rootCause', - title: 'Root cause', - aside: {investigation.rootCause.confidence} confidence, - children: , - }, - frequency: { - id: 'frequency', - title: 'Seen in production', - aside: ( - - Re-check - - ), - children: , - }, - proposal: { - id: 'proposal', - title: 'Proposed fix', - children: , - }, - investigation: { - id: 'investigation', - title: 'Investigation', - aside: `${investigation.timeline.length} steps · 6 min`, - children: , - }, - } - - return ( -
    - - {SECTION_IDS.filter((id) => id !== 'documents' || documents).map((id) => ( -
    - ))} -
    - ) -} - -function Summary({ investigation }: { investigation: Investigation }) { - return ( -
    - {investigation.summary - .filter((row) => row.label !== 'Fix') - .map((row) => ( -
    - {row.label} - {row.text} -
    - ))} -
    - ) -} - -interface StatusStripProps { - investigation: Investigation - decision: Decision - onDecide: (decision: Decision) => void -} - -/** The one decision on the page, pinned at the top: redeploy the fix or reject it. */ -function StatusStrip({ investigation, decision, onDecide }: StatusStripProps) { - const { workflow, proposal } = investigation - if (proposal.docEdit) - return ( -
    - - - {`infra-analyzer stopped before editing ${proposal.docEdit.page} in Confluence.`} - -
    - ) - if (decision === 'redeployed') - return ( -
    - - - {`${workflow.name} ${workflow.nextVersion} is live. infra-analyzer is watching new runs.`} - -
    - ) - if (decision === 'rejected') - return ( -

    - Fix rejected. The investigation stays on record; the workflow was not changed. -

    - ) - return ( -
    - - - {`infra-analyzer stopped before changing ${workflow.name}.`} - - onDecide('rejected')}>Reject - { - onDecide('redeployed') - toast.success(`${workflow.name} ${workflow.nextVersion} deployed`) - }} - > - {`Redeploy as ${workflow.nextVersion}`} - -
    - ) -} - -interface SectionProps { - id: string - title: string - aside?: React.ReactNode - children: React.ReactNode -} - -function Section({ id, title, aside, children }: SectionProps) { - return ( -
    -
    -

    {title}

    - {aside &&
    {aside}
    } -
    - {children} -
    - ) -} - -type KbOrigin = Extract - -/** The automatic query that surfaced the problem, with the answers that disagreed. */ -function KnowledgeCheck({ origin }: { origin: KbOrigin }) { - return ( -
    -
    - - {origin.knowledgeBase} - {`· knowledge check · ${origin.at}`} -
    -

    {`“${origin.query}”`}

    -
    - {origin.answers.map((answer) => ( -
    - - {answer.answer} - {answer.cites} -
    - ))} -
    -

    {origin.detail}

    -
    - ) -} - -/** The source pages side by side, the conflicting sentence marked in each. */ -function Documents({ documents }: { documents: NonNullable }) { - return ( -
    - {documents.map((doc) => ( -
    -
    - -
    - {doc.title} - - {`${doc.space} · ${doc.author} · ${doc.updated}`} - -
    - -
    -
    - {doc.excerpt.map((line) => - line === doc.highlight ? ( - - {line} - - ) : ( -

    - {line} -

    - ) - )} -
    -
    - ))} -
    - ) -} - -function SlackThread({ trigger, thread }: { trigger: SourceMessage; thread: SourceMessage[] }) { - const [open, setOpen] = useState(false) - return ( -
    - - - {open && ( -
    - {thread.map((message, index) => ( - - ))} -
    - )} -
    - ) -} - -function SlackMessage({ message }: { message: SourceMessage }) { - return ( -
    - -
    -
    - {message.author} - - - {message.channel} · {message.at} - - -
    -

    {message.text}

    -
    -
    - ) -} - -/** Collapsed to one line by default; the summary and root cause carry the story. */ -function Timeline({ steps }: { steps: InvestigationStep[] }) { - const [expanded, setExpanded] = useState(false) - const [openSteps, setOpenSteps] = useState>(() => new Set()) - const toggle = (index: number) => - setOpenSteps((prev) => { - const next = new Set(prev) - if (next.has(index)) next.delete(index) - else next.add(index) - return next - }) - if (!expanded) - return ( - - ) - return ( -
      - {steps.map((step, index) => { - const Icon = STEP_ICONS[step.kind] - const last = index === steps.length - 1 - const open = openSteps.has(index) - return ( -
    1. - - {step.at} - -
      - - - - {!last && } -
      -
      -
      - {step.title} - {step.evidence && ( - toggle(index)}> - {open ? 'Hide' : 'Show'} {EVIDENCE_LABEL[step.evidence.type]} - - )} -
      - {step.detail &&

      {step.detail}

      } - {step.evidence && open && } -
      -
    2. - ) - })} -
    3. - -
    4. -
    - ) -} - -/** The summary sentence up front; the causal chain and the block live behind Details. */ -function RootCause({ rootCause }: { rootCause: Investigation['rootCause'] }) { - const [open, setOpen] = useState(false) - return ( -
    -

    {rootCause.summary}

    - - {open && ( -
    -
      - {rootCause.chain.map((link, index) => ( -
    1. - - {index + 1} - - {link} -
    2. - ))} -
    -
    -            {`${rootCause.block.path}\n${rootCause.block.lines.join('\n')}`}
    -          
    -

    {rootCause.confidenceReason}

    -
    - )} -
    - ) -} - -/** Stats on one line over a compact bar strip; the full chart lives in Dashboard. */ -function Frequency({ frequency }: { frequency: Investigation['frequency'] }) { - const max = Math.max(...frequency.days.map((day) => day.count), 1) - return ( -
    -

    - {frequency.stats.map((stat, index) => ( - - {index > 0 && ·} - {`${stat.label} `} - {stat.value} - - ))} -

    -
    - {frequency.days.map((day) => ( -
    - ))} -
    -
    - ) -} - -/** Plain words: what changes, what the replay showed, and what people will notice. */ -function ProposedFix({ investigation }: { investigation: Investigation }) { - const { proposal, workflow } = investigation - const { docEdit } = proposal - if (!docEdit) - return ( -

    - {`${proposal.summary} This ships as ${workflow.name} ${workflow.nextVersion}. ${proposal.replay} ${proposal.tradeoff}`} -

    - ) - return ( -
    -

    - {`${proposal.summary} ${proposal.replay}`} -

    -
    -
    - - {docEdit.page} - {docEdit.space} -
    - -
    -

    {proposal.tradeoff}

    -
    - Leave feedback - - Approve and send to chat - -
    -
    - ) -} diff --git a/apps/sim/app/playground/org/components/issue-page.tsx b/apps/sim/app/playground/org/components/issue-page.tsx deleted file mode 100644 index 65a9310810e..00000000000 --- a/apps/sim/app/playground/org/components/issue-page.tsx +++ /dev/null @@ -1,407 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Banner, Chip, ChipDropdown, ChipLink, chipVariants, cn } from '@sim/emcn' -import { - ArrowUpRight, - ChevronLeft, - Circle, - CircleCheck, - Database, - Files, - Layout, - Table, - Workflow, - X, -} from '@sim/emcn/icons' -import Link from 'next/link' -import { DelegateAvatar, delegateIcon } from '@/app/playground/org/components/delegate-avatar' -import { - AgentStateIcon, - PriorityIcon, - RunningDot, - SourceIcon, - StatusIcon, -} from '@/app/playground/org/components/glyphs' -import { InvestigationView } from '@/app/playground/org/components/investigation/investigation-view' -import { TicketIcon } from '@/app/playground/org/components/linked-tickets' -import { MockComposer } from '@/app/playground/org/components/mock-composer' -import { chatsForIssue } from '@/app/playground/org/lib/changelog-data' -import { - type Activity, - CHATS, - type Issue, - linkedTickets, - type PlanStep, - PRIORITY_LABELS, - type Priority, - RELEASES, - STATUS_LABELS, - STATUS_ORDER, - ticketUrl, - type Workspace, -} from '@/app/playground/org/lib/mock-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' - -const RESOURCE_ICONS = { - table: Table, - workflow: Workflow, - dashboard: Layout, - knowledge: Database, - file: Files, -} as const - -interface IssuePageProps { - workspace: Workspace - issue: Issue -} - -/** Full-page issue, Linear-style: content column plus a properties column. */ -export function IssuePage({ workspace, issue }: IssuePageProps) { - const [status, setStatus] = useState(issue.status) - const [priority, setPriority] = useState(issue.priority) - const [activity, setActivity] = useState(issue.activity) - const release = RELEASES.find((r) => r.id === issue.releaseId) - const tickets = linkedTickets(issue) - const chats = chatsForIssue(issue.key) - - return ( -
    -
    - - {workspace.name} - - / - {issue.key} -
    - {issue.agent?.state === 'error' && ( - a.kind === 'error')?.text} - actionLabel='Open credentials' - /> - )} -
    -
    -
    -
    -

    - {issue.title} -

    - {tickets.length > 0 && ( -
    - Tracks - {tickets.map((ticket) => ( - - - {ticket.key} - - ))} -
    - )} - {!issue.investigation && ( -

    - {issue.description || ( - Add a description… - )} -

    - )} -
    - - {issue.investigation && } - - {issue.research && ( -
    -

    {issue.research.cause}

    -
    - {issue.research.related.map((ref) => ( - - {ref} - - ))} -
    -
    - )} - - {issue.reports.length > 0 && !issue.investigation && ( -
    -
    - {issue.reports.map((report) => ( -
    - - {report.author} - - “{report.text}” - - - {report.channel} · {report.at} - -
    - ))} -
    -
    - )} - - {issue.delegate && !issue.investigation && ( -
    - {issue.agent && } - {issue.delegate.name} - {issue.agent && ` · ${issue.agent.label}`} - - } - > - {issue.plan.length > 0 && ( -
    - {issue.plan.map((step) => ( - - ))} -
    - )} -
    - {activity.map((item, index) => ( - - ))} -
    - - setActivity((prev) => [ - ...prev, - { kind: 'response', text: `You: ${text}`, at: 'now' }, - ]) - } - /> -
    - )} -
    -
    - - -
    -
    - ) -} - -function Section({ - title, - aside, - children, -}: { - title: string - aside?: React.ReactNode - children: React.ReactNode -}) { - return ( -
    -
    - {title} - {aside && {aside}} -
    - {children} -
    - ) -} - -function Property({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
    - {label} -
    {children}
    -
    - ) -} - -function PlanRow({ step }: { step: PlanStep }) { - const Icon = step.status === 'completed' ? CircleCheck : step.status === 'canceled' ? X : Circle - return ( -
    - {step.status === 'inProgress' ? ( - - ) : ( - - )} - - {step.label} - -
    - ) -} - -const ACTIVITY_PREFIX: Record = { - thought: 'Thinking', - action: '', - elicitation: 'Question', - response: '', - error: 'Error', -} - -function ActivityRow({ activity }: { activity: Activity }) { - return ( -
    - - - {ACTIVITY_PREFIX[activity.kind] && ( - {ACTIVITY_PREFIX[activity.kind]}: - )} - {activity.text} - - {activity.at} -
    - ) -} diff --git a/apps/sim/app/playground/org/components/issues-list.tsx b/apps/sim/app/playground/org/components/issues-list.tsx deleted file mode 100644 index f2b197d4d44..00000000000 --- a/apps/sim/app/playground/org/components/issues-list.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { chipHoverSurfaceClass, cn, OverflowText } from '@sim/emcn' -import Link from 'next/link' -import { DelegateAvatar } from '@/app/playground/org/components/delegate-avatar' -import { AgentStateIcon, PriorityIcon, StatusIcon } from '@/app/playground/org/components/glyphs' -import { - ISSUES, - type Issue, - STATUS_LABELS, - STATUS_ORDER, - type Workspace, -} from '@/app/playground/org/lib/mock-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' - -/** Sim issues for a project, grouped by status. Tracker links and chats live on the issue page. */ -export function IssuesList({ workspace }: { workspace: Workspace }) { - const issues = ISSUES.filter((issue) => issue.workspaceId === workspace.id) - if (!issues.length) - return

    No issues yet.

    - return ( -
    - {STATUS_ORDER.map((status) => { - const inStatus = [...issues] - .filter((issue) => issue.status === status) - .sort((a, b) => (a.priority || 5) - (b.priority || 5)) - if (!inStatus.length) return null - return ( -
    -
    - - {STATUS_LABELS[status]} - {inStatus.length} -
    -
    - {inStatus.map((issue) => ( - - ))} -
    -
    - ) - })} -
    - ) -} - -function IssueRow({ issue }: { issue: Issue }) { - return ( - - - {issue.key} - - - - {issue.agent && ( - <> - - {issue.agent.label} - - )} - - - - ) -} diff --git a/apps/sim/app/playground/org/components/linked-tickets.tsx b/apps/sim/app/playground/org/components/linked-tickets.tsx deleted file mode 100644 index 7b0a1336c1f..00000000000 --- a/apps/sim/app/playground/org/components/linked-tickets.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' - -import { - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@sim/emcn' -import { ArrowUpRight } from '@sim/emcn/icons' -import { GithubIcon, JiraIcon, LinearIcon } from '@/components/icons' -import { - issueByKey, - type LinkedTicket, - linkedTickets, - ticketUrl, -} from '@/app/playground/org/lib/mock-data' - -const SYSTEM_ICON = { linear: LinearIcon, jira: JiraIcon, github: GithubIcon } as const -const SYSTEM_LABEL = { linear: 'Linear', jira: 'Jira', github: 'GitHub' } as const - -export function TicketIcon({ ticket, className }: { ticket: LinkedTicket; className?: string }) { - const Icon = SYSTEM_ICON[ticket.system] - return -} - -/** Compact marker next to a Sim issue key: one tracker ticket links straight out, several open a list. */ -export function LinkedTickets({ issueKey }: { issueKey: string }) { - const issue = issueByKey(issueKey) - if (!issue) throw new Error(`Unknown issue ${issueKey}`) - const tickets = linkedTickets(issue) - if (!tickets.length) return null - const chrome = - 'inline-flex h-[18px] shrink-0 items-center gap-1 rounded-[4px] px-1 text-[var(--text-muted)] text-caption opacity-70 transition-opacity hover:bg-[var(--surface-hover)] hover:opacity-100' - if (tickets.length === 1) { - const [ticket] = tickets - return ( - - - - ) - } - return ( - - - - - - {tickets.map((ticket) => ( - - - - {ticket.key} - {ticket.title} - - - - ))} - - - ) -} diff --git a/apps/sim/app/playground/org/components/mock-composer.tsx b/apps/sim/app/playground/org/components/mock-composer.tsx deleted file mode 100644 index 838b485565e..00000000000 --- a/apps/sim/app/playground/org/components/mock-composer.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client' - -import { useState } from 'react' -import { ComposerActionButton, cn } from '@sim/emcn' -import { ArrowUp } from '@sim/emcn/icons' - -interface MockComposerProps { - placeholder: string - onSubmit?: (message: string) => void - rows?: number - className?: string -} - -/** Static stand-in for the chat composer: textarea plus send, no attachments or skills. */ -export function MockComposer({ placeholder, onSubmit, rows = 2, className }: MockComposerProps) { - const [value, setValue] = useState('') - const submit = () => { - const message = value.trim() - if (!message) return - onSubmit?.(message) - setValue('') - } - return ( -
    -