diff --git a/apps/sim/app/api/organizations/[id]/projects/route.ts b/apps/sim/app/api/organizations/[id]/projects/route.ts new file mode 100644 index 00000000000..50a68eda735 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/projects/route.ts @@ -0,0 +1,25 @@ +import { listOrganizationProjectsContract } from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups' +import { + listOrganizationProjects, + listOrganizationProjectsOperation, +} from '@/lib/projects/application/organization-projects' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationProjectsContract, + auth: internalSessionAuth, + operation: listOrganizationProjectsOperation, + rateLimit: internalRateLimits.none({ + reason: + 'Read-only organization settings inventory follows existing workspace inventory admission', + }), + errorPolicy: internalPermissionGroupErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: listOrganizationProjects, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/route.ts b/apps/sim/app/api/projects/[id]/route.ts new file mode 100644 index 00000000000..8e89459ed0f --- /dev/null +++ b/apps/sim/app/api/projects/[id]/route.ts @@ -0,0 +1,18 @@ +import { renameProjectContract } from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectOperations, renameProject } from '@/lib/projects/application/projects' + +export const PATCH = defineInternalJsonRoute({ + contract: renameProjectContract, + auth: internalSessionAuth, + operation: projectOperations.rename, + rateLimit: internalRateLimits.none({ reason: 'Admin renaming a project they administer' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, name: body.name }), + useCase: renameProject, +}) diff --git a/apps/sim/app/api/projects/route.ts b/apps/sim/app/api/projects/route.ts new file mode 100644 index 00000000000..297470e0023 --- /dev/null +++ b/apps/sim/app/api/projects/route.ts @@ -0,0 +1,18 @@ +import { listProjectsContract } from '@/lib/api/contracts/projects' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listProjects, projectOperations } from '@/lib/projects/application/projects' + +export const GET = defineInternalJsonRoute({ + contract: listProjectsContract, + auth: internalSessionAuth, + operation: projectOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Read-only list of the caller’s own projects' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listProjects, +}) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts index 09657aec72a..64cb7b4c4de 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/data-retention/route.ts @@ -9,7 +9,7 @@ * `DATA_RETENTION_ENABLED` (or `ENTERPRISE_ENABLED`) when billing is off. * * Body: any subset of `logRetentionHours`, `softDeleteRetentionHours`, - * `taskCleanupHours`, `fileVersionRetentionHours`, `piiRedaction`, `retentionOverrides`. + * `taskCleanupHours`, `fileVersionRetentionHours`, `piiRedaction`, `retentionOverrides`, `projectOverrides`. * Omitted keys keep their current value; `null` means "forever" for an hours field. * * Response: AdminSingleResponse<{ success, organizationId }> @@ -85,6 +85,7 @@ export const PATCH = withRouteHandler( merged.piiRedaction = body.piiRedaction } + if (body.projectOverrides !== undefined) merged.projectOverrides = body.projectOverrides if (body.retentionOverrides !== undefined) { merged.retentionOverrides = body.retentionOverrides } @@ -97,6 +98,7 @@ export const PATCH = withRouteHandler( const foreignTargetsReason = await getForeignWorkspaceTargetsReason({ organizationId, retentionOverrides: body.retentionOverrides, + projectOverrides: body.projectOverrides, piiRedaction: body.piiRedaction, }) if (foreignTargetsReason) { diff --git a/apps/sim/app/api/workspaces/[id]/dashboard/route.ts b/apps/sim/app/api/workspaces/[id]/dashboard/route.ts new file mode 100644 index 00000000000..7299e0ac01e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/dashboard/route.ts @@ -0,0 +1,19 @@ +import { readWorkspaceDashboardContract } from '@/lib/api/contracts/dashboards' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readWorkspaceDashboard } from '@/lib/dashboards/application/dashboards' +import { dashboardOperations } from '@/lib/dashboards/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: readWorkspaceDashboardContract, + auth: internalSessionAuth, + operation: dashboardOperations.read, + rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: readWorkspaceDashboard, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts index 91a9f077cb3..d05b543a409 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts @@ -1,4 +1,5 @@ import { + createForkSecretMappingContract, getForkMappingContract, updateForkMappingContract, } from '@/lib/api/contracts/workspace-fork' @@ -8,6 +9,7 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { createWorkspaceForkSecretMapping } from '@/ee/workspace-forking/application/create-secret-mapping' import { getWorkspaceForkMappingDetails } from '@/ee/workspace-forking/application/mapping-details' import { forkOperations } from '@/ee/workspace-forking/application/operations' import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' @@ -38,3 +40,14 @@ export const PUT = defineInternalJsonRoute({ useCase: updateWorkspaceForkMappings, present: (result) => ({ success: true as const, ...result }), }) + +export const POST = defineInternalJsonRoute({ + contract: createForkSecretMappingContract, + auth: internalSessionAuth, + operation: forkOperations.createSecretMapping, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: createWorkspaceForkSecretMapping, + present: ({ name }) => ({ name }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts new file mode 100644 index 00000000000..dd21e77ecdc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { createRouteContext } from '@sim/testing/helpers/http' +import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' +import { + workspaceAuthorizationMock, + workspaceAuthorizationMockFns, +} from '@sim/testing/mocks/workspace-authorization.mock' +import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock' +import { + workspaceForkingLineageMock, + workspaceForkingLineageMockFns, +} from '@sim/testing/mocks/workspace-forking-lineage.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadSourceDeployedWorkflow: vi.fn(), + loadTargetDraftState: vi.fn(), + loadForkBlockMap: vi.fn(), + resolveForkPlanItem: vi.fn(), +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock) +vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock) +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + loadSourceDeployedWorkflow: mocks.loadSourceDeployedWorkflow, + loadTargetDraftState: mocks.loadTargetDraftState, +})) +vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ + loadForkBlockMap: mocks.loadForkBlockMap, +})) +vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ + resolveForkPlanItem: mocks.resolveForkPlanItem, +})) + +import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route' + +const { mockAuthorizeWorkspaceOperation } = workspaceAuthorizationMockFns +const mockGetSession = authMockFns.mockGetSession +permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({ + id, + name: id, + organizationId: null, + allowPersonalApiKeys: true, +})) + +const WORKSPACE_ID = 'child' +const routeContext = createRouteContext({ id: WORKSPACE_ID }) +const BASE_URL = `http://localhost/api/workspaces/${WORKSPACE_ID}/fork/workflow-diff` + +const emptyState = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} } + +function request(query: Record) { + const url = `${BASE_URL}?${new URLSearchParams(query).toString()}` + return createMockRequest('GET', undefined, undefined, url) +} + +describe('fork workflow-diff route', () => { + beforeEach(() => { + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue({ + parentWorkspaceId: 'parent', + childWorkspaceId: 'child', + }) + mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() }) + mocks.loadSourceDeployedWorkflow.mockImplementation(async (_ws: string, id: string) => + id === 'wf-src' ? { summary: { id, name: 'Support Agent' }, state: emptyState } : null + ) + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: null, + mode: 'create', + sourceMeta: { name: 'Support Agent' }, + }) + }) + + it('does not read any state when workspace authorization is refused', async () => { + mockAuthorizeWorkspaceOperation.mockRejectedValue( + new OrchestrationError('forbidden', 'Admin access required') + ) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(403) + }) + + it('rejects a request without the source workflow id', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push' }), + routeContext + ) + + expect(response.status).toBe(400) + }) + + it('rejects workspaces that are not a direct fork edge without reading state', async () => { + workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(400) + }) + + it('maps a workflow outside the sync plan to 404', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }), + routeContext + ) + + expect(response.status).toBe(404) + }) + + it('returns the before and after states with their labels', async () => { + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + targetWorkflowId: null, + before: null, + after: emptyState, + beforeLabel: 'Support Agent (current)', + afterLabel: 'Support Agent (deployed)', + }) + }) + it('maps a workflow whose target is excluded from sync to 404', async () => { + mocks.resolveForkPlanItem.mockResolvedValue(null) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(404) + }) + + it('returns the target draft as before when the sync replaces a workflow', async () => { + const targetState = { + ...emptyState, + blocks: {}, + } + mocks.resolveForkPlanItem.mockResolvedValue({ + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + targetName: 'Support Agent prod', + mode: 'replace', + sourceMeta: { name: 'Support Agent' }, + }) + /* Only the replaced target in the target workspace has a draft; any other read is a 404. */ + mocks.loadTargetDraftState.mockImplementation(async (id: string, workspaceId: string) => + id === 'wf-tgt' && workspaceId === 'parent' ? targetState : null + ) + + const response = await GET( + request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }), + routeContext + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + targetWorkflowId: 'wf-tgt', + before: targetState, + beforeLabel: 'Support Agent prod (current)', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts new file mode 100644 index 00000000000..cac548176f9 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts @@ -0,0 +1,20 @@ +import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff' + +export const GET = defineInternalJsonRoute({ + contract: getForkWorkflowDiffContract, + auth: internalSessionAuth, + operation: forkOperations.syncPreview, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-workflow-diff' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceSyncWorkflowDiff, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts b/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts new file mode 100644 index 00000000000..9e7a7dbfe10 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts @@ -0,0 +1,24 @@ +import { getWorkspaceSettingsNavigationContract } from '@/lib/api/contracts/workspace-settings' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + readWorkspaceSettingsNavigation, + readWorkspaceSettingsNavigationOperation, +} from '@/lib/settings/application/workspace-navigation' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceSettingsNavigationContract, + auth: internalSessionAuth, + operation: readWorkspaceSettingsNavigationOperation, + rateLimit: internalRateLimits.none({ + reason: 'Read-only settings navigation, matching existing settings-page admission', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: readWorkspaceSettingsNavigation, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 6d3f32de76f..08f4c457047 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReactNode } from 'react' import { chipVariants, cn, @@ -28,6 +29,8 @@ import { interface ChatRowProps { chat: OrganizationChat + /** Shown before the title, such as the projects the chat worked in. */ + leading?: ReactNode isCurrentRoute: boolean isMenuOpen: boolean onContextMenu: (e: React.MouseEvent, chatId: string) => void @@ -37,6 +40,7 @@ interface ChatRowProps { function ChatRow({ chat, + leading, isCurrentRoute, isMenuOpen, onContextMenu, @@ -61,6 +65,7 @@ function ChatRow({ )} onContextMenu={(e) => onContextMenu(e, chat.id)} > + {leading} ReactNode } export function ChatsSection({ @@ -106,6 +113,7 @@ export function ChatsSection({ isLoading, isCollapsed, pathname, + leadingFor, }: ChatsSectionProps) { const actions = useOrganizationChatActions({ organizationId, chats }) const { menu, hover, rename, selectedChat } = actions @@ -136,7 +144,7 @@ export function ChatsSection({ (null) + + const { + isOpen: menuOpen, + position, + menuRef, + handleContextMenu, + openMenuAt, + closeMenu, + } = useContextMenu() + + const rename = useItemRename({ + initialName: project.name, + onSave: (name) => actions.rename(project, name), + itemType: 'workspace', + itemId: project.id, + }) + + const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({ + onDragStart: (event) => { + event.dataTransfer.effectAllowed = 'move' + const ghost = createSidebarDragGhost(project.name) + void ghost.offsetHeight + event.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + drag.onDragStart() + }, + }) + + const onDragEnd = useCallback(() => { + dragGhostRef.current?.remove() + dragGhostRef.current = null + handleDragEnd() + drag.onDragEnd() + }, [drag, handleDragEnd]) + + const openMenuFromButton = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + openMenuAt({ x: rect.right, y: rect.bottom }) + } + + const openInNewTab = () => window.open(href, '_blank', 'noopener') + const copyLink = () => void navigator.clipboard.writeText(`${window.location.origin}${href}`) + + return ( + <> + +
+ {drag.indicator && ( + + )} + {rename.isEditing ? ( +
+ + rename.setEditValue(event.target.value)} + onKeyDown={rename.handleKeyDown} + onBlur={rename.handleInputBlur} + disabled={rename.isRenaming} + onClick={(event) => event.stopPropagation()} + className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-hidden focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + /> +
+ ) : ( + { + if (shouldPreventClickRef.current) { + event.preventDefault() + return + } + }} + onDoubleClick={(event) => { + event.preventDefault() + rename.handleStartEdit() + }} + 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..f6ea7a8365c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx @@ -0,0 +1,87 @@ +'use client' + +import { cn } from '@sim/emcn' +import { useSearchParams } from 'next/navigation' +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 { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' + +interface ProjectsSectionProps { + organizationId: string + isCollapsed: boolean + pathname: string | null +} + +/** Projects select the pinned pane; chats remain independent organization conversations. */ +export function ProjectsSection({ organizationId, isCollapsed, pathname }: ProjectsSectionProps) { + const { roots } = useProjects(organizationId) + const { projects, dragProps, isAnyDragActive } = useProjectOrder(roots) + return ( + +
+ {projects.map((project) => ( + + ))} +
+
+ ) +} + +interface ProjectTreeProps { + organizationId: string + project: Project + pathname: string | null + railCollapsed: boolean + drag: ProjectDragProps + isAnyDragActive: boolean +} + +function ProjectTree({ + organizationId, + project, + pathname, + railCollapsed, + drag, + isAnyDragActive, +}: ProjectTreeProps) { + const routes = organizationRoutes(organizationId) + const searchParams = useSearchParams() + const [{ project: selectedWorkspace }] = useQueryStates(projectPaneParsers) + const inProject = project.environments.some( + (environment) => environment.workspaceId === selectedWorkspace + ) + const query = new URLSearchParams(searchParams.toString()) + if (inProject) query.delete('project') + else query.set('project', project.id) + query.delete('section') + query.set('pane', 'project') + const chatPath = pathname?.startsWith(`/o/${organizationId}/chat/`) ? pathname : routes.home + const href = `${chatPath}?${query}` + return ( + + ) +} 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..c1268748a4d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts @@ -0,0 +1,54 @@ +'use client' + +import { useCallback } from 'react' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useRenameProject } from '@/hooks/queries/projects' +import { + useDeleteWorkspace, + usePinnedWorkspaceIds, + useToggleWorkspacePin, +} from '@/hooks/queries/workspace' + +/** + * Rename, pin and delete for a project: a rename changes the project's own name, a delete + * removes its workspaces forks first, and the pin lives on its root workspace. + */ +export function useProjectActions() { + const { data: pinnedIds } = usePinnedWorkspaceIds() + const { mutate: togglePin } = useToggleWorkspacePin() + const { mutateAsync: renameProject, isPending: isRenaming } = useRenameProject() + const { mutateAsync: 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 next = name.trim() + if (!next || next === project.name) return + await renameProject({ projectId: project.projectId, name: next }) + }, + [renameProject] + ) + + 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/hooks/use-organization-chat-actions.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts index 1ad28686c39..59ce9262096 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts @@ -99,7 +99,7 @@ export function useOrganizationChatActions({ deleteChat(chatToDelete.id, { onSuccess: () => { setChatToDelete(null) - if (window.location.pathname === chatToDelete.href) { + if (window.location.pathname === chatToDelete.href.split('?')[0]) { router.push(organizationRoutes(organizationId).home) } }, diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts index 0bff17ebe9e..79aa1569afb 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts @@ -1,3 +1,6 @@ +import { useQueryStates } from 'nuqs' +import { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats' export interface OrganizationChat { @@ -13,11 +16,15 @@ export interface OrganizationChat { /** Lists only the current member's private organization conversations. */ export function useOrganizationChats(organizationId: string) { + const [{ project, section }] = useQueryStates(projectPaneParsers) + const enabled = useFeatureFlag('org-project-view') + const paneQuery = + enabled && project ? `?${new URLSearchParams({ project, section, pane: 'project' })}` : '' const query = useOrganizationMothershipChats(organizationId) const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({ id: chat.id, name: chat.name, - href: `/o/${organizationId}/chat/${chat.id}`, + href: `/o/${organizationId}/chat/${chat.id}${paneQuery}`, isActive: chat.isActive, isUnread: chat.isUnread, isPinned: chat.isPinned, diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts index 14bcd85a849..75030aae7ee 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,29 @@ 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 + route === 'home' + ? mothershipAvailable + : /** With the org project view, Search is a mode of the Home composer, not a destination. */ + route === 'search' + ? searchAvailable && !projectViewEnabled + : 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/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 57d1cb40b36..6d387700af8 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -25,6 +25,7 @@ import { } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor' import { organizationSkillOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/organization-skill-options' import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import type { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { SKILL_CHIP_TRIGGER } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' import { getSkillsQueryOptions } from '@/hooks/queries/skills' @@ -35,6 +36,8 @@ import { useVoiceInput } from '@/hooks/use-voice-input' import type { ChatContext } from '@/stores/panel' interface ComposerProps { + lookupMode?: boolean + onSearchMode?: () => void searchEnabled?: boolean requestMode?: ChatRequestMode onModeChange?: (mode: ChatRequestMode) => void @@ -57,6 +60,8 @@ interface ComposerProps { * carries only the controls that are wired for the organization. */ export function Composer({ + lookupMode = false, + onSearchMode, searchEnabled = true, requestMode = 'assistant', onModeChange, @@ -73,7 +78,10 @@ export function Composer({ }: ComposerProps) { const attachedFilesRef = useRef(files.attachedFiles) attachedFilesRef.current = files.attachedFiles - const imagesOnly = requestMode === 'assistant' + const imagesOnly = lookupMode || requestMode === 'assistant' + /** With the org project view, Search keeps the composer's framed box instead of its own bar. */ + const projectViewEnabled = useFeatureFlag('org-project-view') + const searchBar = imagesOnly && !projectViewEnabled const { organization } = useOrganizationContext() const { data: allWorkspaces = [] } = useWorkspacesQuery(!imagesOnly) const workspaces = (imagesOnly ? [] : allWorkspaces).filter( @@ -97,7 +105,7 @@ export function Composer({ contextsEnabled: !imagesOnly, initialValue: value, initialContexts: restoredContexts, - onPasteFiles: files.processFiles, + onPasteFiles: lookupMode ? undefined : files.processFiles, }) const { textareaRef } = editor const editorRef = useRef(editor) @@ -149,7 +157,13 @@ export function Composer({ isInitialView && !value, imagesOnly ? 'search' : 'build' ) - const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + const placeholder = lookupMode + ? 'Search your sources' + : projectViewEnabled && imagesOnly + ? 'Ask a question about your sources' + : isInitialView + ? animatedPlaceholder + : 'Send message to Sim' const submit = () => { if (attachedFilesRef.current.some((file) => file.uploading)) return @@ -164,7 +178,7 @@ export function Composer({ voice.resetTranscript() const contexts = imagesOnly ? [] : editor.getActiveContexts() onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined) - editor.clear() + if (!lookupMode) editor.clear() } const contextPicker = (kind: 'resources' | 'skills', icon: typeof Plus, label: string) => ( @@ -191,14 +205,54 @@ export function Composer({ ) + const modeSelector = showModeSelector && ( + { + if (editor.getActiveContexts().length || files.attachedFiles.length) { + toast.info('Remove mentions and attachments before switching to Search.') + return + } + onSearchMode() + } + : undefined + } + searchEnabled={searchEnabled} + onChange={ + onModeChange + ? (mode) => { + if ( + mode === 'assistant' && + (editor.getActiveContexts().length > 0 || + files.attachedFiles.some((file) => !isAssistantImageType(file.type))) + ) { + toast.info( + projectViewEnabled + ? 'Remove resource and skill mentions and non-image attachments before switching to Ask.' + : 'Remove resource and skill mentions and non-image attachments before switching to Search.' + ) + return + } + onModeChange(mode) + } + : undefined + } + /> + ) + const leadingControls = ( <> + {/** With the org project view the mode leads, then what the message carries. */} + {projectViewEnabled && modeSelector} {imagesOnly && !showModeSelector && ( )} @@ -218,29 +272,7 @@ export function Composer({ )} {!imagesOnly && contextPicker('skills', Slash, 'Skills')} - {showModeSelector && ( - { - if ( - mode === 'assistant' && - (editor.getActiveContexts().length > 0 || - files.attachedFiles.some((file) => !isAssistantImageType(file.type))) - ) { - toast.info( - 'Remove resource and skill mentions and non-image attachments before switching to Search.' - ) - return - } - onModeChange(mode) - } - : undefined - } - /> - )} + {!projectViewEnabled && modeSelector} ) const voiceControl = voice.isSupported && ( @@ -259,7 +291,7 @@ export function Composer({ type='button' onClick={submit} disabled={!canSubmit} - aria-label='Send' + aria-label={lookupMode ? 'Search' : 'Send'} active={canSubmit} > @@ -276,22 +308,22 @@ export function Composer({ return (
- {!imagesOnly && attachmentList} - {!imagesOnly && promptEditor} + {!searchBar && attachmentList} + {!searchBar && promptEditor} - {imagesOnly ? ( + {searchBar ? ( ) : ( +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx new file mode 100644 index 00000000000..6571db0ff9b --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-control.tsx @@ -0,0 +1,98 @@ +'use client' + +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + OverflowText, +} from '@sim/emcn' +import { Check, ChevronDown } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +interface ProjectControlProps { + active: boolean + onShow: () => void +} + +/** Project navigation stays separate from the chat's resource tabs and ownership. */ +export function ProjectControl({ active, onShow }: ProjectControlProps) { + const { organization } = useOrganizationContext() + const { projects, roots } = useProjects(organization.id) + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) + const project = projects.find((candidate) => candidate.id === params.project) + const name = project?.name ?? organization.name + const pick = (workspaceId: string) => + requestLeave(() => { + void setParams({ + project: workspaceId, + section: null, + resourceKind: '', + resourceDetail: '', + pane: 'project', + }) + onShow() + }) + return ( +
+ + + + + + + + Switch project + pick('')}> + + {organization.name} + {!project && } + + {roots.map((candidate) => ( + + pick(candidate.projectId === project?.projectId ? project.id : candidate.id) + } + > + + {candidate.name} + {candidate.projectId === project?.projectId && } + + ))} + + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx new file mode 100644 index 00000000000..3529bd9d99c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-pane-content.tsx @@ -0,0 +1,78 @@ +import { OverflowText, Skeleton } from '@sim/emcn' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { ProjectView } from '@/app/o/[organizationId]/p/components/project-view' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { ResourceWorkspaceHost } from '@/app/workspace/[workspaceId]/home/components/resource-workspace-host' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +/** The pinned tab shows the organization until a project environment is selected. */ +export function ProjectPaneContent() { + const { organization } = useOrganizationContext() + const { roots, projects, isPending, error } = useProjects(organization.id) + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const requestLeave = useSettingsDirtyStore((state) => state.requestLeave) + const navigate = (next: Parameters[0]) => + requestLeave(() => { + void setParams(next) + }) + const project = projects.find((candidate) => candidate.id === params.project) + if (isPending) return + if (error) + return ( +

+ {error.message} +

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

{organization.name}

+
+

Projects

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

No projects yet.

+ )} +
+
+
+ ) + return ( + + + navigate({ section: 'resources', resourceKind: '', resourceDetail: '', pane: 'project' }) + } + onSectionChange={(section) => + navigate({ section, resourceKind: '', resourceDetail: '', pane: 'project' }) + } + onEnvironmentChange={(workspaceId) => + navigate({ project: workspaceId, resourceDetail: '', pane: 'project' }) + } + /> + + ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx new file mode 100644 index 00000000000..d85bc4376e1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/project-resource-browser.tsx @@ -0,0 +1,243 @@ +import { type ReactNode, useCallback, useState } from 'react' +import { Chip, ChipInput, OverflowText, Skeleton, toast } from '@sim/emcn' +import { ArrowLeft, Globe, Search, TerminalWindow } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { openBrowserTab } from '@/lib/browser-agent/transport' +import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { terminalResourceId } from '@/lib/terminal/resource-id' +import { openTerminal } from '@/lib/terminal/transport' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { + type AvailableResources, + useAvailableResources, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import { OrganizationResourceInventory } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory' +import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item' +import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' + +const EXCLUDED: readonly MothershipResourceType[] = [ + 'dashboard', + 'sources', + 'search', + 'folder', + 'task', + 'integration', + 'generic', +] +interface ProjectResourceBrowserProps { + tab: string + resources: MothershipResource[] + desktopScopeId: string +} +interface BrowseRowProps { + icon: ReactNode + label: string + detail?: string + onClick: () => void +} +function BrowseRow({ icon, label, detail, onClick }: BrowseRowProps) { + return ( + + ) +} + +/** Browse live resources without creating a chat or changing its ownership. */ +export function ProjectResourceBrowser({ + tab, + resources, + desktopScopeId, +}: ProjectResourceBrowserProps) { + const [id, workspaceId, kind] = tab.split(':') + const { organization } = useOrganizationContext() + const { projects, roots, isPending } = useProjects(organization.id) + const [, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const { addResource, selectResource } = useMothershipResources() + const [query, setQuery] = useState('') + const [inventories, setInventories] = useState>({}) + const project = projects.find((candidate) => candidate.id === workspaceId) + const inventory = useAvailableResources(project?.id ?? '', { + enabled: Boolean(project), + excludeTypes: EXCLUDED, + }) + const needle = query.trim().toLowerCase() + const browse = (workspace = '', type = '') => + void setParams( + (current) => ({ + browse: current.browse.map((entry) => + entry.split(':')[0] === id ? `${id}:${workspace}:${type}` : entry + ), + }), + { history: 'replace' } + ) + const open = (resource: MothershipResource) => { + if (resource.type === 'browser') { + void openBrowserTab(desktopScopeId) + .then((state) => { + if (state?.activeTabId) selectResource(state.activeTabId) + }) + .catch(() => toast.error('Could not open a browser tab.')) + } else if (resource.type === 'terminal') { + void openTerminal(undefined, desktopScopeId) + .then((state) => { + if (state.activeTerminalId) selectResource(terminalResourceId(state.activeTerminalId)) + }) + .catch(() => toast.error('Could not open a terminal.')) + } else addResource(resource) + } + const renderResource = (resource: MothershipResource, detail?: string) => { + const config = getResourceConfig(resource.type) + return ( + open(resource)} + /> + ) + } + const mentioned = resources.filter((resource) => !EXCLUDED.includes(resource.type)) + const groups = inventory.groups.filter((group) => !EXCLUDED.includes(group.type)) + const matches = project + ? groups.flatMap((group) => + !kind || group.type === kind + ? group.items + .filter((item) => !needle || item.name.toLowerCase().includes(needle)) + .map((item) => ({ + resource: resourceFromItem(group.type, { ...item, workspaceId: project.id }), + detail: getResourceConfig(group.type).label, + })) + : [] + ) + : projects.flatMap((candidate) => + (inventories[candidate.id]?.groups ?? []) + .filter( + (group) => + !EXCLUDED.includes(group.type) && + group.type !== 'browser' && + group.type !== 'terminal' + ) + .flatMap((group) => + group.items + .filter((item) => item.name.toLowerCase().includes(needle)) + .map((item) => ({ + resource: resourceFromItem(group.type, { ...item, workspaceId: candidate.id }), + detail: `${candidate.name} · ${candidate.environment}`, + })) + ) + ) + const updateInventory = useCallback( + (owner: string, next: AvailableResources) => + setInventories((current) => + current[owner] === next ? current : { ...current, [owner]: next } + ), + [] + ) + return ( +
+
+ {!project && + needle && + projects.map((candidate) => ( + + ))} +
+
+ {kind && ( + browse(workspaceId)} + /> + )} +

+ Browse {project?.name ?? organization.name} +

+ {project && browse()}>Change} +
+ setQuery(event.target.value)} + className='mb-2 w-full' + /> + {isPending || (project && inventory.isHydrating) ? ( + + ) : needle || kind ? ( + <> + {matches.map(({ resource, detail }) => renderResource(resource, detail))} + {!matches.length && ( +

No resources found.

+ )} + + ) : project ? ( + groups.map((group) => { + const config = getResourceConfig(group.type) + const Icon = + group.type === 'browser' + ? Globe + : group.type === 'terminal' + ? TerminalWindow + : config.icon + return ( + } + label={config.label} + onClick={() => + group.type === 'browser' || group.type === 'terminal' + ? open({ type: group.type, id: group.type, title: config.label }) + : browse(project.id, group.type) + } + /> + ) + }) + ) : ( + roots.map((candidate) => ( + } + label={candidate.name} + onClick={() => browse(candidate.id)} + /> + )) + )} +
+ {!kind && mentioned.length > 0 && ( +
+

Mentioned in this chat

+ {mentioned.map((resource) => renderResource(resource))} +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts b/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts new file mode 100644 index 00000000000..64c37ae03f4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/search-params.ts @@ -0,0 +1,20 @@ +import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Project names are presentation; workspace ids address the selected environment. */ +export const projectPaneParsers = { + project: parseAsString.withDefault(''), + section: parseAsStringLiteral([ + 'dashboard', + 'changelog', + 'issues', + 'environments', + 'settings', + 'resources', + ] as const).withDefault('dashboard'), + resourceKind: parseAsString.withDefault(''), + resourceDetail: parseAsString.withDefault(''), + pane: parseAsString.withDefault(''), + browse: parseAsArrayOf(parseAsString).withDefault([]), +} + +export const projectPaneOptions = { history: 'push', clearOnDefault: true } as const diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts new file mode 100644 index 00000000000..44724e3b346 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import type { MothershipResource } from '@/lib/mothership/resources/types' + +const RESTORABLE_TYPES = new Set([ + 'workflow', + 'table', + 'knowledgebase', + 'file', + 'filefolder', + 'log', +]) + +/** Store addresses only: previews, source content and desktop sessions have separate lifecycles. */ +function describeResource(resource: MothershipResource): MothershipResource | null { + if (!RESTORABLE_TYPES.has(resource.type) || !resource.workspaceId) return null + return { + type: resource.type, + id: resource.id, + title: resource.title, + workspaceId: resource.workspaceId, + path: resource.path, + viewId: resource.viewId, + executionId: resource.executionId, + } +} + +function isResourceDescriptor(value: unknown): value is MothershipResource { + if (!value || typeof value !== 'object') return false + const entry = value as Record + return ( + typeof entry.type === 'string' && + RESTORABLE_TYPES.has(entry.type) && + typeof entry.id === 'string' && + entry.id.length > 0 && + typeof entry.title === 'string' && + typeof entry.workspaceId === 'string' && + entry.workspaceId.length > 0 && + ['path', 'viewId', 'executionId'].every( + (key) => entry[key] === undefined || typeof entry[key] === 'string' + ) + ) +} + +interface UseDraftResourceTabsProps { + storageKey: string + enabled: boolean + hasChat: boolean + resources: MothershipResource[] + addResource: (resource: MothershipResource) => boolean +} + +/** Restore the current browser session's pre-chat tabs; saved chats own their own resources. */ +export function useDraftResourceTabs({ + storageKey, + enabled, + hasChat, + resources, + addResource, +}: UseDraftResourceTabsProps) { + const [restored, setRestored] = useState(false) + useEffect(() => { + if (!enabled || hasChat || restored) return + try { + const raw = sessionStorage.getItem(storageKey) + const parsed: unknown = raw ? JSON.parse(raw) : [] + if (Array.isArray(parsed)) { + for (const entry of parsed.slice(0, 100)) { + if (isResourceDescriptor(entry)) { + const descriptor = describeResource(entry) + if (descriptor) addResource(descriptor) + } + } + } + } catch { + /** Unavailable or malformed browser storage must not prevent opening Home. */ + } + setRestored(true) + }, [storageKey, enabled, hasChat, restored, addResource]) + + useEffect(() => { + if (!enabled || !restored) return + try { + if (hasChat) sessionStorage.removeItem(storageKey) + else + sessionStorage.setItem( + storageKey, + JSON.stringify(resources.map(describeResource).filter(Boolean)) + ) + } catch { + /** Storage quota and private-browser restrictions leave tabs in memory. */ + } + }, [storageKey, enabled, hasChat, resources, restored]) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx new file mode 100644 index 00000000000..9d0eab54c3f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/project-pane/use-project-pane.tsx @@ -0,0 +1,109 @@ +import { useEffect } from 'react' +import type { TabStripItem } from '@sim/emcn' +import { generateShortId } from '@sim/utils/id' +import { useQueryStates } from 'nuqs' +import { getChatResourceSelectionId } from '@/lib/mothership/resources/types' +import { ProjectControl } from '@/app/o/[organizationId]/home/components/project-pane/project-control' +import { ProjectPaneContent } from '@/app/o/[organizationId]/home/components/project-pane/project-pane-content' +import { ProjectResourceBrowser } from '@/app/o/[organizationId]/home/components/project-pane/project-resource-browser' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import type { ResourcePanelNavigation } from '@/app/workspace/[workspaceId]/home/components/resource-panel-navigation' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' + +const RESOURCE_SECTIONS: Partial> = { + workflow: 'workflows', + table: 'tables', + knowledgebase: 'knowledge', + file: 'files', + log: 'logs', +} + +/** UI tabs never enter the chat's persisted resource list or execution context. */ +export function useProjectPane( + resources: MothershipResource[], + activeResourceId: string | null, + desktopScopeId: string, + onReveal: () => void +) { + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const activeBrowse = params.browse.find((tab) => tab.split(':')[0] === params.pane) + const dashboard = resources.find( + (resource) => + resource.type === 'dashboard' && getChatResourceSelectionId(resource) === activeResourceId + ) + useEffect(() => { + if (dashboard?.workspaceId && !params.pane) { + void setParams( + { project: dashboard.workspaceId, section: 'dashboard', pane: 'project' }, + { history: 'replace' } + ) + onReveal() + } + }, [dashboard?.workspaceId, params.pane, setParams, onReveal]) + const hasActiveResource = resources.some( + (resource) => getChatResourceSelectionId(resource) === activeResourceId + ) + const showProject = params.pane === 'project' || (!activeBrowse && !hasActiveResource) + const browse = (workspaceId = params.project, kind = '') => { + const id = generateShortId(8) + void setParams({ pane: id, browse: [...params.browse, `${id}:${workspaceId}:${kind}`] }) + onReveal() + } + const select = (id: string) => { + void setParams({ pane: id }) + onReveal() + } + const close = (id: string) => { + const index = params.browse.findIndex((tab) => tab.split(':')[0] === id) + const remaining = params.browse.filter((tab) => tab.split(':')[0] !== id) + const neighbor = remaining[Math.max(0, index - 1)]?.split(':')[0] + void setParams( + { + browse: remaining, + ...(params.pane === id ? { pane: neighbor ?? (hasActiveResource ? null : 'project') } : {}), + }, + { history: 'replace' } + ) + } + const tabs: TabStripItem[] = [ + ...params.browse.map((tab) => ({ + id: tab.split(':')[0], + title: 'New tab', + active: tab === activeBrowse, + })), + ] + const navigation: ResourcePanelNavigation = { + control: select('project')} />, + tabs, + active: showProject || Boolean(activeBrowse), + onSelect: select, + onClose: close, + onNew: () => browse(), + onBrowseResource: (resource) => { + const kind = RESOURCE_SECTIONS[resource.type] + if (!kind || !resource.workspaceId) return + void setParams({ + project: resource.workspaceId, + section: 'resources', + resourceKind: kind, + resourceDetail: '', + pane: 'project', + }) + onReveal() + }, + content: activeBrowse ? ( + + ) : showProject ? ( + + ) : null, + } + return { navigation } +} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 0561cfcc0e9..82ecb8fb623 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -75,7 +75,11 @@ vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ getMothershipUseChatOptions: (options: object) => ({ ...options, mothership: true }), useChat: mocks.chat, })) +vi.mock('@/hooks/queries/projects', () => ({ + useProjectsQuery: () => ({ data: [], isPending: false }), +})) vi.mock('@/hooks/queries/mothership-chats', () => ({ + useOrganizationMothershipChats: () => ({ data: [] }), useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }), })) vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer })) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 7baabe357df..f0096feb560 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,9 +1,10 @@ 'use client' -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' -import { toast } from '@sim/emcn' +import { type ReactNode, useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { OverflowText, toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { requestJson } from '@/lib/api/client/request' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' @@ -15,13 +16,21 @@ import { getMothershipAttachmentPreviewUrl, getMothershipAttachmentUrl, } from '@/lib/mothership/chat/attachment-preview' +import { + MOTHERSHIP_SEND_MESSAGE_EVENT, + type MothershipSendMessageDetail, +} from '@/lib/mothership/events' import { createSearchResource } from '@/lib/mothership/resources/search' import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing' import { Composer } from '@/app/o/[organizationId]/home/components/composer' import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started' +import { projectPaneParsers } from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import { useDraftResourceTabs } from '@/app/o/[organizationId]/home/components/project-pane/use-draft-resource-tabs' +import { useProjectPane } from '@/app/o/[organizationId]/home/components/project-pane/use-project-pane' import { organizationHomeParsers } from '@/app/o/[organizationId]/home/search-params' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchUrlKeys } from '@/app/o/[organizationId]/search/search-params' +import { SearchResultsView } from '@/app/o/[organizationId]/search/search-results-view' import { ChatResourcePanel } from '@/app/workspace/[workspaceId]/home/components/chat-resource-panel' import { useSearchHistoryActions } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-history-context' import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' @@ -46,16 +55,35 @@ import type { import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { mentionifyIntegrations } from '@/blocks/integration-matcher' -import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' +import { + useMarkMothershipChatRead, + useOrganizationMothershipChats, +} from '@/hooks/queries/mothership-chats' import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files' 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 +110,9 @@ function OrganizationHomeContent({ userName, chatId, requestMode: savedMode, + landing, + onBeforeSend, + syncChatUrl, }: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() @@ -89,15 +120,35 @@ function OrganizationHomeContent({ const rememberedMode = useOrganizationChatModeStore( (state) => state.modes[`${userId}:${organization.id}`] ) - const [{ q, source, updated, from, to, searchLevel: urlSearchLevel }, setSearchParams] = - useQueryStates(organizationHomeParsers, organizationSearchUrlKeys) + const [ + { q, source, updated, from, to, mode: composerMode, searchLevel: urlSearchLevel }, + setSearchParams, + ] = useQueryStates(organizationHomeParsers, organizationSearchUrlKeys) const rememberMode = useOrganizationChatModeStore((state) => state.setMode) const [selectedMode, setSelectedMode] = useState(null) const planEnabled = useFeatureFlag('mothership-plan-mode') + const projectViewEnabled = useFeatureFlag('org-project-view') + const lookupMode = + projectViewEnabled && !chatId && composerMode === 'search' && searchAccess.memberScoped + const [, setPaneParams] = useQueryStates(projectPaneParsers) + const onResourceSelected = useCallback(() => { + if (!projectViewEnabled) return + void setPaneParams((current) => ({ + pane: null, + browse: current.browse.filter((tab) => tab.split(':')[0] !== current.pane), + })) + }, [projectViewEnabled, setPaneParams]) const requestMode = selectedMode ?? (urlSearchLevel && searchAccess.memberScoped && !chatId ? 'assistant' : undefined) ?? savedMode ?? + (projectViewEnabled && + !chatId && + (composerMode === 'agent' || + (composerMode === 'plan' && planEnabled) || + (composerMode === 'assistant' && searchAccess.memberScoped)) + ? composerMode + : undefined) ?? (!canBuild || !mothershipAvailable ? 'assistant' : rememberedMode === 'plan' && planEnabled @@ -105,7 +156,10 @@ function OrganizationHomeContent({ : rememberedMode === 'assistant' && searchAccess.memberScoped ? 'assistant' : 'agent') - const controller = useResourcePanelController() + const controller = useResourcePanelController({ + defaultExpanded: projectViewEnabled, + onResourceSelected, + }) const queryClient = useQueryClient() const chat = useChat( { organizationId: organization.id }, @@ -114,14 +168,27 @@ function OrganizationHomeContent({ requestMode, onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, + ...(syncChatUrl === false ? { syncChatUrl } : {}), }) ) + useDraftResourceTabs({ + storageKey: `org-draft-tabs:${userId}:${organization.id}`, + enabled: projectViewEnabled, + hasChat: Boolean(chatId || chat.resolvedChatId), + resources: chat.resources, + addResource: chat.addResource, + }) + const { data: organizationChats } = useOrganizationMothershipChats(organization.id) + const chatTitle = organizationChats?.find( + (entry) => entry.id === (chat.resolvedChatId ?? chatId) + )?.name const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}` const savedDraft = useMothershipDraftsStore.getState().drafts const initialDraft = savedDraft[draftKey] ?? savedDraft[initialDraftKey] const draft = useMothershipDraftsStore( - (state) => (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? '' + (state) => + (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? (lookupMode ? q : '') ) const setDraft = useCallback( (text: string, contexts?: ChatContext[]) => { @@ -141,11 +208,17 @@ function OrganizationHomeContent({ !hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled) const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true const assistantSearchLevel = 'fast' - const panel = useChatResourcePanel(chat, controller) + const panel = useChatResourcePanel(chat, controller, projectViewEnabled) + const projectPane = useProjectPane( + chat.resources, + chat.activeResourceId, + chat.desktopScopeId, + panel.expandResource + ) const addResource = panel.addResourceFromUser /** Restore only an explicitly selected results tab on an empty Home; closing it clears the URL. */ useEffect(() => { - if (hasChat || !q.trim()) return + if (lookupMode || hasChat || !q.trim()) return const resource = createSearchResource({ scope: { kind: 'organization', organizationId: organization.id }, query: q.trim(), @@ -158,6 +231,7 @@ function OrganizationHomeContent({ return addResource(resource) }, [ + lookupMode, hasChat, q, source, @@ -227,6 +301,54 @@ function OrganizationHomeContent({ }, [chat.error]) const { recordQuery } = useSearchHistoryActions() const { sendMessage } = chat + const router = useRouter() + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (event.defaultPrevented || !detail?.message) return + const mode = detail.requestMode ?? requestMode + if (!mothershipAvailable || (mode !== 'assistant' && !canBuild)) return + event.preventDefault() + if (hasChat && mode !== requestMode) { + if ( + !MothershipHandoffStorage.store( + { ...detail, requestMode: mode }, + { organizationId: organization.id } + ) + ) { + toast.error('Could not start dashboard setup in a new chat.') + return + } + const params = new URLSearchParams(window.location.search) + params.set('mode', mode) + params.delete('q') + params.delete('searchLevel') + router.push(`/o/${organization.id}/home?${params}`) + return + } + setSelectedMode(mode) + void setSearchParams({ mode, searchLevel: null, q: null }) + void sendMessage(detail.message, detail.fileAttachments, detail.contexts, { + requestMode: mode, + ...(detail.resumeUserMessageId ? { resumeUserMessageId: detail.resumeUserMessageId } : {}), + ...(detail.assistantSearch ? { assistantSearch: detail.assistantSearch } : {}), + ...(detail.assistantSearchLevel !== undefined + ? { assistantSearchLevel: detail.assistantSearchLevel } + : {}), + }) + } + window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) + return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) + }, [ + requestMode, + hasChat, + canBuild, + mothershipAvailable, + organization.id, + router, + sendMessage, + setSearchParams, + ]) const { mutate: markRead } = useMarkMothershipChatRead({ organizationId: organization.id }) const firstName = userName?.split(' ')[0] ?? '' @@ -276,14 +398,21 @@ function OrganizationHomeContent({ }) } const changeMode = (mode: ChatRequestMode) => { - if (!canSelectMode || mode === requestMode) return + if (!canSelectMode || (mode === requestMode && !lookupMode)) return setSelectedMode(mode) - void setSearchParams({ searchLevel: null }) + void setSearchParams({ searchLevel: null, mode, q: null }) if (userId) rememberMode(userId, organization.id, mode) } const submit = (text: string, contexts?: ChatContext[]) => { const message = text.trim() + if (lookupMode) { + if (!message) return + recordQuery(message) + setDraft(message) + void setSearchParams({ q: message }) + return + } if (files.attachedFiles.some((file) => file.uploading)) return const attachments: FileAttachmentForApi[] = files.attachedFiles .filter((file) => file.key) @@ -296,6 +425,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([]) @@ -308,13 +442,19 @@ function OrganizationHomeContent({ Build requires permission to create workspaces.{' '} {searchAccess.memberScoped && ( - Start a Search chat + Ask about your sources )}
) : ( void setSearchParams({ mode: 'search', searchLevel: null }) + : undefined + } searchEnabled={searchAccess.memberScoped} showModeSelector={canSelectMode} onModeChange={canSelectMode ? changeMode : undefined} @@ -334,95 +474,128 @@ function OrganizationHomeContent({ /> ) + const hostLanding = hasChat ? undefined : landing?.(requestMode) const content = ( -
- {hasChat ? ( - - addResource({ - type: 'sources', - id: 'cited-sources', - title: 'Sources', - sources: { messageId, ...(requestId ? { requestId } : {}) }, +
+ {projectViewEnabled && ( +
+ +
+ )} + {lookupMode ? ( + { + setSelectedMode('assistant') + void setSearchParams({ mode: 'assistant', q: null }) + void sendMessage(message, undefined, undefined, { + requestMode: 'assistant', + assistantSearchLevel, + assistantSearch: filters, }) - } - SearchConnectionComponent={SearchIntegrationConnection} - messages={chat.messages} - isSending={chat.isSending} - isReconnecting={chat.isReconnecting} - isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending} - onSubmit={send} - onStopGeneration={() => { - void chat.stopGeneration() - }} - messageQueue={chat.messageQueue} - editingQueuedId={chat.editingQueuedId} - dispatchingHeadId={chat.dispatchingHeadId} - onRemoveQueuedMessage={chat.removeFromQueue} - onSendQueuedMessage={chat.sendNow} - onEditQueuedMessage={(id) => { - const queued = chat.editQueuedMessage(id) - if (queued) { - const queuedMode = queued.requestMode ?? requestMode - setSelectedMode(queuedMode) - setDraft(queued.content, queued.contexts) - setRestoredContexts(queued.contexts ?? []) - files.restoreAttachedFiles( - (queued.fileAttachments ?? []).map((file) => ({ - id: file.id, - key: file.key, - name: file.filename, - type: file.media_type, - size: file.size, - path: - file.path || - getMothershipAttachmentPreviewUrl(file) || - getMothershipAttachmentUrl(file), - previewUrl: getMothershipAttachmentPreviewUrl(file), - uploading: false, - })) - ) - } - return queued }} - onCancelQueueEdit={chat.cancelQueueEdit} - userId={session?.user?.id} - chatId={chat.resolvedChatId} - composer={composer} - onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined} - initialScrollBlocked={ - (requestMode !== 'assistant' || liveSearch) && - chat.resources.length > 0 && - panel.isResourceCollapsed - } /> ) : ( - - {composer} -
- {requestMode === 'agent' ? ( - setDraft(mentionifyIntegrations(prompt))} - /> - ) : searchAccess.memberScoped ? ( - - ) : null} -
-
+ <> + {hasChat ? ( + + addResource({ + type: 'sources', + id: 'cited-sources', + title: 'Sources', + sources: { messageId, ...(requestId ? { requestId } : {}) }, + }) + } + SearchConnectionComponent={SearchIntegrationConnection} + messages={chat.messages} + isSending={chat.isSending} + isReconnecting={chat.isReconnecting} + isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending} + onSubmit={send} + onStopGeneration={() => { + void chat.stopGeneration() + }} + messageQueue={chat.messageQueue} + editingQueuedId={chat.editingQueuedId} + dispatchingHeadId={chat.dispatchingHeadId} + onRemoveQueuedMessage={chat.removeFromQueue} + onSendQueuedMessage={chat.sendNow} + onEditQueuedMessage={(id) => { + const queued = chat.editQueuedMessage(id) + if (queued) { + const queuedMode = queued.requestMode ?? requestMode + setSelectedMode(queuedMode) + setDraft(queued.content, queued.contexts) + setRestoredContexts(queued.contexts ?? []) + files.restoreAttachedFiles( + (queued.fileAttachments ?? []).map((file) => ({ + id: file.id, + key: file.key, + name: file.filename, + type: file.media_type, + size: file.size, + path: + file.path || + getMothershipAttachmentPreviewUrl(file) || + getMothershipAttachmentUrl(file), + previewUrl: getMothershipAttachmentPreviewUrl(file), + uploading: false, + })) + ) + } + return queued + }} + onCancelQueueEdit={chat.cancelQueueEdit} + userId={session?.user?.id} + chatId={chat.resolvedChatId} + composer={composer} + onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined} + initialScrollBlocked={ + (requestMode !== 'assistant' || liveSearch) && + chat.resources.length > 0 && + panel.isResourceCollapsed + } + /> + ) : ( + + {hostLanding?.accessory} + {composer} +
+ {requestMode === 'agent' ? ( + setDraft(mentionifyIntegrations(prompt))} + /> + ) : searchAccess.memberScoped ? ( + + ) : null} +
+
+ )} + )}
) return ( }> - + {projectViewEnabled ? ( + + ) : ( + + )} ) } diff --git a/apps/sim/app/o/[organizationId]/home/search-params.ts b/apps/sim/app/o/[organizationId]/home/search-params.ts index 5d9af1370ee..8faf7471d7d 100644 --- a/apps/sim/app/o/[organizationId]/home/search-params.ts +++ b/apps/sim/app/o/[organizationId]/home/search-params.ts @@ -12,6 +12,7 @@ export const SEARCH_LEVEL_VALUES = [ ] as const satisfies readonly SearchLevel[] export const organizationHomeParsers = { + mode: parseAsStringLiteral(['agent', 'plan', 'assistant', 'search'] as const), ...organizationSearchParsers, ...searchFilterParsers, searchLevel: parseAsStringLiteral(SEARCH_LEVEL_VALUES), diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 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..0d8f20a46c3 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/[section]/page.tsx @@ -0,0 +1,25 @@ +import { notFound, redirect } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' +import { isProjectSection } from '@/app/o/[organizationId]/p/routes' + +interface ProjectSectionPageProps { + params: Promise<{ organizationId: string; workspaceId: string; section: string }> + searchParams: Promise> +} + +/** Existing project bookmarks enter the same shell as Home and organization chats. */ +export default async function ProjectSectionPage({ + params, + searchParams, +}: ProjectSectionPageProps) { + const { organizationId, workspaceId, section } = await params + if (!isProjectSection(section)) notFound() + const incoming = await searchParams + const query = new URLSearchParams({ project: workspaceId, section, pane: 'project' }) + for (const key of ['dashboard', 'edge', 'sync']) { + const value = incoming[key] + if (typeof value === 'string') query.set(key, value) + } + if (typeof incoming.resource === 'string') query.set('environment-resource', incoming.resource) + redirect(`${organizationRoutes(organizationId).home}?${query}`) +} diff --git a/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx b/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx new file mode 100644 index 00000000000..d38e5c115e3 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { notFound } from 'next/navigation' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' +import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + +/** + * A project's pages, on while the org project view is rolled out to the organization. The + * `[workspaceId]` segment lets the Chat components read the project's workspace from the route, + * with the permissions its message actions expect. + */ +export default async function ProjectLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ organizationId: string; workspaceId: string }> +}) { + const { organizationId, workspaceId } = await params + if (!(await isOrgProjectViewEnabled(organizationId))) notFound() + return ( + + {children} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx b/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx new file mode 100644 index 00000000000..dc1a7885aa5 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/[workspaceId]/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from 'next/navigation' +import { organizationRoutes } from '@/lib/navigation/paths' + +export default async function ProjectIndexPage({ + params, +}: { + params: Promise<{ organizationId: string; workspaceId: string }> +}) { + const { organizationId, workspaceId } = await params + redirect(organizationRoutes(organizationId).project(workspaceId)) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx b/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx new file mode 100644 index 00000000000..23a1d5b92da --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx @@ -0,0 +1,64 @@ +'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 + onChange?: (workspaceId: string) => void +} + +/** + * The project's environment (Prod, Staging, Sandbox): each is a forked workspace in the same + * lineage, and switching keeps the section you are looking at. + */ +export function EnvironmentSwitcher({ project, section, onChange }: EnvironmentSwitcherProps) { + const router = useRouter() + const { organization } = useOrganizationContext() + if (project.environments.length <= 1) + return ( + + {project.environment} + + ) + return ( + + + + {project.environment} + + + + {project.environments.map((environment) => ( + + onChange + ? onChange(environment.workspaceId) + : router.push( + organizationRoutes(organization.id).project(environment.workspaceId, section) + ) + } + > + {environment.label} + {environment.workspaceId === project.id && ( + + )} + + ))} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx new file mode 100644 index 00000000000..76443082f90 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-name.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipInput, OverflowText, toast } from '@sim/emcn' +import { Pencil } from '@sim/emcn/icons' +import { useUpdateWorkspace } from '@/hooks/queries/workspace' + +interface EnvironmentNameProps { + workspaceId: string + name: string + canRename: boolean +} + +/** Renames the workspace represented by an environment without leaving its card. */ +export function EnvironmentName({ workspaceId, name, canRename }: EnvironmentNameProps) { + const rename = useUpdateWorkspace() + const [draft, setDraft] = useState(null) + const cancel = () => { + if (rename.isPending) return + setDraft(null) + rename.reset() + } + + if (!canRename) { + return + } + + if (draft === null) { + return ( + + ) + } + + return ( +
{ + event.preventDefault() + if (!draft.trim() || rename.isPending) return + if (draft.trim() === name) { + cancel() + return + } + rename.mutate( + { workspaceId, name: draft.trim() }, + { + onSuccess: () => { + setDraft(null) + toast.success('Environment renamed') + }, + } + ) + }} + > + event.target.select()} + onChange={(event) => setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + cancel() + } + }} + disabled={rename.isPending} + error={Boolean(rename.error)} + /> + {rename.error && ( +

+ {rename.error.message} +

+ )} +
+ + {rename.isPending ? 'Saving…' : 'Save'} + + + Cancel + +
+ + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx new file mode 100644 index 00000000000..af2451726b8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/environment-secret-peek.test.tsx @@ -0,0 +1,126 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + permissions: vi.fn(), + credentials: vi.fn(), + environment: vi.fn(), + save: vi.fn(), +})) +vi.mock('@/hooks/queries/workspace', () => ({ useWorkspacePermissionsQuery: mocks.permissions })) +vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredentials: mocks.credentials })) +vi.mock('@/hooks/queries/environment', () => ({ + useWorkspaceEnvironment: mocks.environment, + useUpsertWorkspaceEnvironment: () => ({ + mutate: mocks.save, + isPending: false, + error: null, + reset: vi.fn(), + }), +})) +vi.mock('@sim/emcn', () => ({ + Chip: (props: ComponentProps<'button'>) => + ) : ( +
{row}
+ )} + + ) + })} + + )} + + + {controller.kindSummaries + .filter((summary) => summary.requiredPending || summary.reconfigPending) + .map((summary) => { + const group = controller.groups.find((candidate) => candidate.kind === summary.kind) + return ( +
+ {group?.label} need configuration + + onResolveMapping( + summary.kind, + group?.items.find((entry) => !controller.targetFor(entry))?.sourceId ?? + group?.items[0]?.sourceId + ) + } + > + Resolve mapping + +
+ ) + })} + + {settingsOpen && } + + {comparing ? ( + { + if (!open) setComparing(null) + }} + workspaceId={child.id} + otherWorkspaceId={parent.id} + direction={direction} + sourceWorkflowId={comparing.sourceWorkflowId} + workflowName={comparing.currentName} + /> + ) : null} + + + ) +} 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/environments/use-pipeline-changes.ts b/apps/sim/app/o/[organizationId]/p/components/environments/use-pipeline-changes.ts new file mode 100644 index 00000000000..e2f52735762 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/environments/use-pipeline-changes.ts @@ -0,0 +1,45 @@ +'use client' + +import { type QueryFunctionContext, useQueries } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork' +import type { LineageEdge } from '@/app/o/[organizationId]/p/components/environments/mapping-model' +import { + type ForkDirection, + forkKeys, + WORKSPACE_FORK_DIFF_STALE_TIME, +} from '@/ee/workspace-forking/hooks/workspace-fork' + +const DIRECTIONS: readonly ForkDirection[] = ['push', 'pull'] + +/** + * How many deployed workflows each edge's sync would change, in both directions, from the same + * sync previews the sync itself reads (shared cache keys), so the pipeline's counts and the + * review below it always agree. + */ +export function usePipelineChanges(edges: readonly LineageEdge[], enabled: boolean) { + const pairs = edges.flatMap((edge) => DIRECTIONS.map((direction) => ({ edge, direction }))) + const diffs = useQueries({ + queries: pairs.map((pair) => ({ + queryKey: forkKeys.diff(pair.edge.childId, pair.edge.parentId, pair.direction), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkDiffContract, { + params: { id: pair.edge.childId }, + query: { otherWorkspaceId: pair.edge.parentId, direction: pair.direction }, + signal, + }), + staleTime: WORKSPACE_FORK_DIFF_STALE_TIME, + enabled, + })), + }) + const counts = new Map>>() + pairs.forEach(({ edge, direction }, index) => { + const workflows = diffs[index]?.data?.workflows + if (!workflows) return + const changed = workflows.filter( + (change) => change.action !== 'update' || change.hasChanges + ).length + counts.set(edge.childId, { ...counts.get(edge.childId), [direction]: changed }) + }) + return counts +} 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..b295a145994 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/index.ts @@ -0,0 +1,3 @@ +export { EnvironmentSwitcher } from './environment-switcher' +export { ProjectDashboard } from './project-dashboard' +export { ProjectView } from './project-view' diff --git a/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx new file mode 100644 index 00000000000..ff3f89ed666 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Chip, toast } from '@sim/emcn' +import { DashboardResource } from '@/components/dashboards/dashboard-resource' +import { EmptyState } from '@/components/empty-state/empty-state' +import { sendMothershipMessage } from '@/lib/mothership/events' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectDashboardProps { + project: Project +} + +export function ProjectDashboard({ project }: ProjectDashboardProps) { + const { canBuild, mothershipAvailable } = useOrganizationContext() + const createDashboard = () => { + const message = [ + `Help me create the first dashboard for project ${JSON.stringify(project.name)} (project ID: ${project.projectId}).`, + `I'm viewing environment ${JSON.stringify(project.environment)} (workspace ID: ${project.id}), which has no dashboard.`, + `Project environments: ${JSON.stringify(project.environments)}.`, + 'First ask me what I want to see in the dashboard: goals, metrics, data sources, and layout. Do not create dashboards until we agree on what to include.', + 'Then check the dashboard in every accessible environment of this project and create one for each environment that does not already have one, using that environment’s own tables and data. Preserve existing dashboards. Tell me if an environment needs data or permissions before it can be set up.', + ].join('\n\n') + if (!sendMothershipMessage(message, undefined, undefined, undefined, 'agent')) { + toast.error('Could not open dashboard setup in chat. Please try again.') + } + } + return ( + Create dashboard + ) : undefined + } + /> + } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx b/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx new file mode 100644 index 00000000000..9a5e0d51173 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-general-settings.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { Chip, ChipInput, Label, toast } from '@sim/emcn' +import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { useRenameProject } from '@/hooks/queries/projects' +import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' +import { useUserPermissions } from '@/hooks/use-user-permissions' + +interface ProjectSettingsProps { + project: Project +} + +/** Project settings use the project API; environment names remain independent. */ +export function ProjectGeneralSettings({ project }: ProjectSettingsProps) { + const [draft, setDraft] = useState(null) + const rename = useRenameProject() + const permissions = useWorkspacePermissionsQuery(project.rootId) + const { canAdmin, isLoading } = useUserPermissions( + permissions.data ?? null, + permissions.isPending + ) + const name = draft ?? project.name + const changed = name.trim() !== project.name + useSettingsUnsavedGuard({ isDirty: changed, navigationBlocked: rename.isPending }) + return ( +
+

Project settings

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

+ A project administrator can change this name. +

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

+ {rename.error.message} +

+ )} + {canAdmin && ( +
+ + {rename.isPending ? 'Saving…' : 'Save'} + + {changed && ( + { + setDraft(null) + rename.reset() + }} + > + Discard + + )} +
+ )} + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx b/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx new file mode 100644 index 00000000000..e0461ee4036 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-resources.tsx @@ -0,0 +1,319 @@ +'use client' + +import { lazy, Suspense, useCallback, useState } from 'react' +import { Skeleton, toast } from '@sim/emcn' +import { + Database, + Files as FilesIcon, + Integration, + Library, + Table, + Workflow, +} from '@sim/emcn/icons' +import { parseAsString, useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { INTEGRATIONS } from '@/lib/integrations' +import { sendMothershipMessage } from '@/lib/mothership/events' +import { + projectPaneOptions, + projectPaneParsers, +} from '@/app/o/[organizationId]/home/components/project-pane/search-params' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + Resource, + type ResourceRow, +} from '@/app/workspace/[workspaceId]/components/resource/resource' +import { ResourceNavigationProvider } from '@/app/workspace/[workspaceId]/components/resource/resource-navigation' +import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' +import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import { resourceFromItem } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-from-item' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +const Workflows = lazy(() => + import('@/app/workspace/[workspaceId]/w/components/workflows-list/workflows-list').then((m) => ({ + default: m.WorkflowsList, + })) +) +const Files = lazy(() => + import('@/app/workspace/[workspaceId]/files/files').then((m) => ({ default: m.Files })) +) +const Tables = lazy(() => + import('@/app/workspace/[workspaceId]/tables/tables').then((m) => ({ default: m.Tables })) +) +const Knowledge = lazy(() => + import('@/app/workspace/[workspaceId]/knowledge/knowledge').then((m) => ({ + default: m.Knowledge, + })) +) +const Logs = lazy(() => import('@/app/workspace/[workspaceId]/logs/logs')) +const Integrations = lazy(() => + import('@/app/workspace/[workspaceId]/integrations/integrations').then((m) => ({ + default: m.Integrations, + })) +) +const IntegrationDetail = lazy(() => + import('@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail').then( + (m) => ({ default: m.IntegrationBlockDetail }) + ) +) +const CredentialDetail = lazy(() => + import( + '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' + ).then((m) => ({ default: m.ConnectedCredentialDetail })) +) + +const KINDS = [ + { + id: 'workflows', + path: 'w', + type: 'workflow', + label: 'Workflows', + icon: Workflow, + component: Workflows, + }, + { + id: 'files', + path: 'files', + type: 'file', + label: 'Files', + icon: FilesIcon, + component: Files, + permission: 'hideFilesTab', + }, + { + id: 'tables', + path: 'tables', + type: 'table', + label: 'Tables', + icon: Table, + component: Tables, + permission: 'hideTablesTab', + }, + { + id: 'knowledge', + path: 'knowledge', + type: 'knowledgebase', + label: 'Knowledge', + icon: Database, + component: Knowledge, + permission: 'hideKnowledgeBaseTab', + }, + { id: 'logs', path: 'logs', type: 'log', label: 'Logs', icon: Library, component: Logs }, + { + id: 'integrations', + path: 'integrations', + type: 'integration', + label: 'Integrations', + icon: Integration, + component: Integrations, + permission: 'hideIntegrationsTab', + }, +] as const +const EXCLUDED: readonly MothershipResourceType[] = [ + 'dashboard', + 'sources', + 'search', + 'folder', + 'filefolder', + 'task', + 'integration', + 'generic', + 'browser', + 'terminal', +] +const COLUMNS = [ + { id: 'name', header: 'Name', widthMultiplier: 2 }, + { id: 'detail', header: 'Kind' }, +] +const KIND_COLUMNS = [ + { id: 'name', header: 'Name', widthMultiplier: 2 }, + { id: 'detail', header: 'Items' }, +] + +interface ProjectResourcesProps { + project: Project +} + +/** Production lists retain their actions while navigation stays beside the chat. */ +export function ProjectResources({ project }: ProjectResourcesProps) { + const [params, setParams] = useQueryStates(projectPaneParsers, projectPaneOptions) + const [, setListLocation] = useQueryStates({ folderId: parseAsString, search: parseAsString }) + const { addResource } = useMothershipResources() + const { config } = usePermissionConfig() + const [search, setSearch] = useState('') + const { data: credentials } = useWorkspaceCredentials({ workspaceId: project.id }) + const inventory = useAvailableResources(project.id, { excludeTypes: EXCLUDED }) + const kinds = KINDS.filter((kind) => !('permission' in kind) || !config[kind.permission]) + const selected = kinds.find((kind) => kind.id === params.resourceKind) + const select = useCallback( + (kind: string) => { + void setListLocation({ folderId: null, search: null }) + void setParams({ resourceKind: kind, resourceDetail: '' }) + }, + [setListLocation, setParams] + ) + const navigate = useCallback( + (href: string, replace = false) => { + const url = new URL(href, window.location.origin) + const [, scope, workspaceId, path, ...rest] = url.pathname.split('/') + if ( + url.origin !== window.location.origin || + scope !== 'workspace' || + workspaceId !== project.id + ) { + toast.error('This destination is outside the selected environment.') + return + } + if (path === 'home') { + const prompt = LandingPromptStorage.consume() + if (prompt) sendMothershipMessage(prompt, undefined, undefined, undefined, 'agent') + return + } + const kind = KINDS.find((candidate) => candidate.path === path) + if (!kind) { + void setParams({ resourceKind: '', resourceDetail: '' }) + return + } + if (rest[0] && kind.type !== 'integration') { + const item = inventory.groups + .find((group) => group.type === kind.type) + ?.items.find((item) => item.id === rest[0]) + addResource( + resourceFromItem(kind.type, { + ...item, + id: rest[0], + name: item?.name ?? kind.label, + workspaceId: project.id, + }) + ) + return + } + void setParams( + { resourceKind: kind.id, resourceDetail: rest.join('/') }, + { history: replace ? 'replace' : 'push' } + ) + }, + [project.id, inventory.groups, addResource, setParams] + ) + const needle = search.trim().toLowerCase() + const matches = inventory.groups.flatMap((group) => { + const kind = kinds.find((kind) => kind.type === group.type) + if (!kind) return [] + return group.items + .filter((item) => item.name.toLowerCase().includes(needle)) + .map((item) => ({ + kind, + resource: resourceFromItem(group.type, { ...item, workspaceId: project.id }), + })) + }) + const rows: ResourceRow[] = needle + ? matches.map(({ kind, resource }, index) => ({ + id: String(index), + cells: { + name: { icon: , label: resource.title }, + detail: { label: kind.label }, + }, + })) + : kinds.map((kind) => ({ + id: kind.id, + cells: { + name: { icon: , label: kind.label }, + detail: { + label: + kind.id === 'integrations' + ? String( + credentials?.filter( + (credential) => + credential.type === 'oauth' || + credential.type === 'service_account' || + credential.type === 'personal_token' + ).length ?? '—' + ) + : inventory.isHydrating + ? '—' + : String( + inventory.groups.find((group) => group.type === kind.type)?.items.length ?? 0 + ) + + (kind.id === 'logs' && + (inventory.groups.find((group) => group.type === kind.type)?.items.length ?? + 0) >= 50 + ? '+' + : ''), + }, + }, + })) + if (!selected) + return ( + + + {needle && inventory.isHydrating ? ( + + ) : needle && !rows.length ? ( + + ) : ( + (needle ? addResource(matches[Number(id)].resource) : select(id))} + /> + )} + + ) + const Component = selected.component + const integration = INTEGRATIONS.find((item) => item.slug === params.resourceDetail) + return ( + select('')} + > +
{ + const anchor = (event.target as HTMLElement).closest('a') + const href = anchor?.getAttribute('href') + if (href?.startsWith(`/workspace/${project.id}/`)) { + event.preventDefault() + event.stopPropagation() + navigate(href) + } + }} + > + {selected.id === 'integrations' && ( + select('') }, + { label: 'Integrations', onClick: () => select('integrations') }, + ]} + /> + )} +
+ }> + {selected.id === 'integrations' && params.resourceDetail ? ( + + {params.resourceDetail.startsWith('connected/') ? ( + + ) : integration ? ( + + ) : ( +

Integration unavailable.

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

{access.error.message}

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

+ These settings are unavailable for this environment. +

+ ) : ( + + {entry?.access === 'allowed' && selected.id === 'secrets' && credentialId ? ( + + ) : ( + + {entry?.access === 'allowed' ? ( + + ) : configKey ? ( + + ) : null} + + )} + + )} +
+
+ !open && cancelLeave()} + srTitle='Unsaved changes' + title='Unsaved changes' + text='You have unsaved changes. Are you sure you want to discard them?' + dismissLabel='Keep editing' + confirm={{ label: 'Discard changes', onClick: confirmLeave }} + /> +
+ ) +} 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..e2bb1e23e47 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx @@ -0,0 +1,129 @@ +'use client' + +import { Chip, ChipLink, cn } from '@sim/emcn' +import { Settings } 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 { ProjectResources } from '@/app/o/[organizationId]/p/components/project-resources' +import { ProjectSettings } from '@/app/o/[organizationId]/p/components/project-settings' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { PROJECT_SECTIONS, type ProjectSection } from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectViewProps { + project: Project + section: ProjectSection + onBrowse?: () => void + onSectionChange?: (section: ProjectSection) => void + onEnvironmentChange?: (workspaceId: string) => void +} + +/** Project content shared by the pinned pane and compatibility routes. */ +export function ProjectView({ + project, + section, + onBrowse, + onSectionChange, + onEnvironmentChange, +}: ProjectViewProps) { + const { organization } = useOrganizationContext() + const routes = organizationRoutes(organization.id) + return ( +
+
+
+
+
+

+ {project.name} +

+ + {onSectionChange && ( + onSectionChange('settings')} + /> + )} +
+
+
+ +
+
+ +
+
+ +
+
+ ) +} + +interface SectionBodyProps { + onBrowse?: () => void + project: Project + section: ProjectSection +} + +function SectionBody({ project, section, onBrowse }: SectionBodyProps) { + switch (section) { + case 'resources': + return + case 'settings': + return + case 'dashboard': + return ( +
+ +
+ ) + case 'changelog': + return 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/hooks/use-project-resources.ts b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts new file mode 100644 index 00000000000..7aa85eef7a0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +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]) + + return { + workflows: workflows.data ?? [], + folders: folders.data ?? {}, + tables: tables.data ?? [], + knowledgeBases: knowledgeBases.data ?? [], + files: files.data ?? [], + memberNames, + isPending: + enabled && + (workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending), + filesPending: enabled && files.isPending, + } +} + +export type ProjectResources = ReturnType 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..cb574032f3d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts @@ -0,0 +1,70 @@ +import type { ProjectApi } from '@/lib/api/contracts/projects' +import type { ProjectEnvironment } from '@/lib/projects/types' +import { useProjectsQuery } from '@/hooks/queries/projects' + +/** A workspace seen as a project: the project it belongs to, with this workspace as one environment. */ +export interface Project { + /** The workspace this view is on; every route and hook uses it. */ + id: string + /** The project's own id, used for project settings. */ + projectId: string + name: string + organizationId: string | null + /** The project's first workspace the viewer can access; lists show one project per root. */ + rootId: string + /** This workspace's environment, labelled by its name. */ + environment: string + /** Every environment of the project the viewer can access, root first; labels are workspace names. */ + environments: ProjectEnvironment[] +} + +/** Each accessible workspace of each project, as the view it opens on. */ +export function toProjectViews(projects: readonly ProjectApi[]): Project[] { + return projects.flatMap((project) => { + const environments = project.workspaces.map((workspace) => ({ + workspaceId: workspace.id, + label: workspace.name, + })) + const rootId = environments[0]?.workspaceId + if (!rootId) return [] + return project.workspaces.map((workspace) => ({ + id: workspace.id, + projectId: project.id, + name: project.name, + organizationId: project.organizationId, + rootId, + environment: workspace.name, + environments, + })) + }) +} + +/** The organization's projects, from the projects the viewer can access there. */ +export function useProjects(organizationId: string) { + const query = useProjectsQuery(organizationId) + const projects = toProjectViews(query.data ?? []) + /** One entry per project for the sidebar and pickers: each project's root. */ + const roots = projects.filter((project) => project.rootId === project.id) + const rootById = new Map(roots.map((root) => [root.id, root])) + /** The project each workspace belongs to, by workspace id. */ + const projectByWorkspace = new Map() + for (const project of projects) { + const root = rootById.get(project.rootId) + if (root) projectByWorkspace.set(project.id, root) + } + /** Each project's root view, by project id, for things that record projects rather than workspaces. */ + const projectById = new Map(roots.map((root) => [root.projectId, root])) + return { + projects, + roots, + projectByWorkspace, + projectById, + isPending: query.isPending, + error: query.error, + } +} + +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..48239aabf88 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/routes.ts @@ -0,0 +1,23 @@ +import { Folder, 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: 'resources', label: 'Resources', icon: Folder }, + { id: 'environments', label: 'Environments', icon: Server }, +] as const + +export type ProjectSection = (typeof PROJECT_SECTIONS)[number]['id'] | 'settings' + +export function isProjectSection(value: string): value is ProjectSection { + return value === 'settings' || 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..be7b54729d2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/search-params.ts @@ -0,0 +1,13 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { RESOURCE_TAB_IDS } from '@/app/o/[organizationId]/p/components/environments/mapping-model' + +export const projectParsers = { + /** Dashboard shown on the project page: a dashboard file id, or `runs`. */ + dashboard: parseAsString.withDefault(''), + /** The fork whose sync with its parent is under review in the Environments tab. */ + edge: parseAsString.withDefault(''), + /** That sync's direction: `push` promotes the fork into its parent, `pull` refreshes it. */ + sync: parseAsStringLiteral(['push', 'pull'] as const).withDefault('push'), + /** 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/o/[organizationId]/search/page.tsx b/apps/sim/app/o/[organizationId]/search/page.tsx index d9702b1b53d..1dc6dd53ace 100644 --- a/apps/sim/app/o/[organizationId]/search/page.tsx +++ b/apps/sim/app/o/[organizationId]/search/page.tsx @@ -1,16 +1,19 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' export const metadata: Metadata = { title: 'Search' } export default async function OrganizationSearchPage({ params, + searchParams, }: { params: Promise<{ organizationId: string }> + searchParams: Promise> }) { const { organizationId } = await params const session = await getSession() @@ -18,5 +21,14 @@ export default async function OrganizationSearchPage({ const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + /** With the org project view, search is a mode of the Home composer rather than its own page. */ + if (await isOrgProjectViewEnabled(organizationId)) { + const query = new URLSearchParams({ mode: 'search' }) + for (const [key, value] of Object.entries(await searchParams)) { + if (['q', 'source', 'updated', 'from', 'to'].includes(key) && typeof value === 'string') + query.set(key, value) + } + redirect(`${organizationRoutes(organizationId).home}?${query}`) + } return } diff --git a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx index a0d6bdd76dd..88cf7f116b9 100644 --- a/apps/sim/app/o/[organizationId]/search/search-results-view.tsx +++ b/apps/sim/app/o/[organizationId]/search/search-results-view.tsx @@ -13,6 +13,7 @@ import { } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' interface SearchResultsViewProps { + embedded?: boolean composer: ReactNode query: string onSummarize: (message: string, filters: WorkspaceSearchFilters) => void @@ -25,6 +26,7 @@ export function SearchResultsView({ query, onSummarize, onSearchChange, + embedded = false, }: SearchResultsViewProps) { const { organization } = useOrganizationContext() const scope = { kind: 'organization' as const, organizationId: organization.id } @@ -39,9 +41,11 @@ export function SearchResultsView({
{searching ? ( <> -
-
-
+ {!embedded && ( +
+
+
+ )}
{composer}
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 ( -
    -