diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx
new file mode 100644
index 00000000000..a188abc9442
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockUseMothershipChatEvents } = vi.hoisted(() => ({
+ mockUseMothershipChatEvents: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-mothership-chat-events', () => ({
+ useMothershipChatEvents: mockUseMothershipChatEvents,
+}))
+
+import {
+ getDeploymentShape,
+ resetDeploymentShape,
+ resolveDeploymentShape,
+ useDeploymentShape,
+} from '@/lib/core/config/deployment-shape'
+import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
+import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+function ScimReader() {
+ return
+}
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ resetDeploymentShape()
+ host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ host.remove()
+ vi.clearAllMocks()
+})
+
+describe('OrganizationProvider', () => {
+ it("seeds the context's deployment shape before children render and follows its chat switch", () => {
+ /** Differs from this environment's env fallback in every field read below. */
+ const fallback = resolveDeploymentShape()
+ const deployment = {
+ ...fallback,
+ chatEnabled: !fallback.chatEnabled,
+ features: { ...fallback.features, scim: !fallback.features.scim },
+ }
+ const context = {
+ organization: { id: 'org-1', name: 'Acme', slug: 'acme', logo: null, memberCount: 1 },
+ searchAccess: { memberScoped: true, sourceMirrored: true },
+ deployment,
+ } as unknown as OrganizationSurfaceContext
+
+ act(() =>
+ root.render(
+
+
+
+ )
+ )
+
+ expect(host.querySelector('[data-testid="scim"]')?.textContent).toBe(
+ String(deployment.features.scim)
+ )
+ expect(getDeploymentShape()).toBe(deployment)
+ expect(mockUseMothershipChatEvents).toHaveBeenCalledWith(
+ { organizationId: 'org-1' },
+ deployment.chatEnabled
+ )
+ })
+})
diff --git a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
index af1f23d4260..6a980627e0d 100644
--- a/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
+++ b/apps/sim/app/o/[organizationId]/providers/organization-provider.tsx
@@ -3,28 +3,27 @@
import { createContext, type ReactNode, useContext } from 'react'
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
import { useMothershipChatEvents } from '@/hooks/use-mothership-chat-events'
+import { useSeedDeploymentShape } from '@/hooks/use-seed-deployment-shape'
const OrganizationContextValue = createContext
(null)
interface OrganizationProviderProps {
children: ReactNode
context: OrganizationSurfaceContext
- chatEnabled: boolean
}
/**
* Provides the route-resolved organization and the viewer's standing in it to the
- * organization surface. The layout resolves both on the server, so the first paint
- * already knows the organization's name and logo.
+ * organization surface, and seeds the context's deployment shape before any child
+ * renders, as the workspace host provider does for workspace routes. The layout
+ * resolves the context on the server, so the first paint already knows the
+ * organization's name, logo, and which features this deployment serves.
*/
-export function OrganizationProvider({
- children,
- context,
- chatEnabled,
-}: OrganizationProviderProps) {
+export function OrganizationProvider({ children, context }: OrganizationProviderProps) {
+ useSeedDeploymentShape(context.deployment)
useMothershipChatEvents(
context.searchAccess.memberScoped ? { organizationId: context.organization.id } : undefined,
- chatEnabled
+ context.deployment.chatEnabled
)
return (
diff --git a/apps/sim/app/selfhost/settings/layout.tsx b/apps/sim/app/selfhost/settings/layout.tsx
index ad705241995..54298406a75 100644
--- a/apps/sim/app/selfhost/settings/layout.tsx
+++ b/apps/sim/app/selfhost/settings/layout.tsx
@@ -1,10 +1,15 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'
+import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'
export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session?.user) redirect('/login')
- return {children}
+ return (
+
+ {children}
+
+ )
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
index 0e6e3602842..ddf44547039 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx
@@ -1,7 +1,7 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
-import { cn } from '@sim/emcn'
+import { bindPreviewWheelZoom, cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety'
@@ -9,7 +9,6 @@ import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
-import { bindPreviewWheelZoom } from './preview-wheel-zoom'
import { useDocPreviewBinary } from './use-doc-preview-binary'
const logger = createLogger('DocxPreview')
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
index d548c8ac2f2..43aaa175d3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx
@@ -6,12 +6,12 @@
*/
import '@/lib/core/utils/browser-polyfills'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { bindPreviewWheelZoom } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
-import 'react-pdf/dist/Page/TextLayer.css'
import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
-import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
+import 'react-pdf/dist/Page/TextLayer.css'
/**
* The worker runs in its own context that browser-polyfills cannot reach, so
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx
index cc769c274a2..e99013757cf 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx
@@ -1,11 +1,11 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
+import { bindPreviewWheelZoom } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer'
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
-import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
const logger = createLogger('PptxSandboxHost')
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts
index d5d941d26c0..0cd9ff3e40a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useRef } from 'react'
-import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
+import { bindPreviewHorizontalWheel } from '@sim/emcn'
/**
* Ref callback that gives a preview scroll container horizontal wheel scrolling.
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
index 327e2808393..afa9665bf77 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx
@@ -2,9 +2,8 @@
import type { MouseEvent, ReactNode } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
-import { cn } from '@sim/emcn'
+import { bindPreviewWheelZoom, cn } from '@sim/emcn'
import { PreviewToolbar } from './preview-toolbar'
-import { bindPreviewWheelZoom } from './preview-wheel-zoom'
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
index ff13612456f..10d16ddd7f0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
@@ -1,4 +1,4 @@
-import { cn } from '@sim/emcn'
+import { cn, Lightbox } from '@sim/emcn'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
@@ -57,9 +57,15 @@ export function ChatMessageAttachments(props: {
)
}
return (
-
-

-
+
+
+
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
index db3f8c36ecd..4f42d169c1c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
@@ -1,7 +1,7 @@
'use client'
import React, { useState } from 'react'
-import { cn, Loader } from '@sim/emcn'
+import { cn, Lightbox, Loader } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -41,74 +41,86 @@ const AttachedFileChip = React.memo(function AttachedFileChip({
const isMedia = isVideo || file.type.startsWith('image/')
const extension = getFileExtension(file.name)
const [previewFailed, setPreviewFailed] = useState(false)
+ const lightboxSrc = file.type.startsWith('image/') && !previewFailed ? file.previewUrl : undefined
- return (
- /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */
- onFileClick(file)}
+ aria-label={lightboxSrc ? `Preview ${file.name}` : `Open ${file.name}`}
>
-
+ >
+ )}
+ {file.uploading && (
+
+
+
+ )}
+
+ )
+
+ return (
+ /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */
+
+ {lightboxSrc ? (
+
+ {preview}
+
+ ) : (
+ preview
+ )}
{!file.uploading && (