= {
+ pptx: 'text/x-pptxgenjs',
+ docx: 'text/x-docxjs',
+ pdf: 'text/x-pdflibjs',
+}
+
+export function isTextEditable(file: { type: string; name: string }): boolean {
+ return resolveFileCategory(file.type, file.name) === 'text-editable'
+}
+
+export function isPreviewable(file: { type: string; name: string }): boolean {
+ return resolvePreviewType(file.type, file.name) !== null
+}
+
+/**
+ * Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
+ * the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
+ * which don't apply to the single-surface editor.
+ */
+export function isMarkdownFile(file: { type: string; name: string }): boolean {
+ return resolvePreviewType(file.type, file.name) === 'markdown'
+}
+
+/**
+ * A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
+ * the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
+ * and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
+ */
+export function isCsvStreamOnly(file: {
+ type: string | null
+ name: string
+ size?: number | null
+}): boolean {
+ return (
+ resolvePreviewType(file.type, file.name) === 'csv' &&
+ (file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
+ )
+}
+
+export type PreviewMode = 'editor' | 'split' | 'preview'
+
+/**
+ * Live agent output for the file on screen. One optional object rather than five
+ * loose props, because they are only ever set together and only this view has
+ * them — no other resource streams.
+ */
+export interface FileViewStreaming {
+ /** The text streamed so far, or `undefined` once the stream settles. */
+ content?: string
+ /** The agent holds the write lock: the surface stays read-only even after the stream ends. */
+ isAgentEditing?: boolean
+ /**
+ * True when the stream delivers complete full-file snapshots (an `append`/`patch` edit built on
+ * the existing file) rather than a from-scratch rebuild (`create`/`update`). Incremental
+ * snapshots are applied live; a rebuild is only revealed while it extends what is shown.
+ */
+ isIncremental?: boolean
+ /**
+ * The agent edit operation driving the stream, when known
+ * (`create`/`append`/`update`/`patch`). In the collaborative path it decides only
+ * whether to stream mid-flight: an `update` (from-scratch rewrite) is HELD until
+ * settle so the open doc doesn't collapse to a partial result, while
+ * `append`/`patch`/`create` apply each frame.
+ */
+ operation?: string
+ disableAutoScroll?: boolean
+ /** Remounts the editor when the agent starts a new turn against the same file. */
+ contextKey?: string
+ /**
+ * Name of a file the agent is creating that has no record yet. The renderer is
+ * chosen from it and the surface holds only the streamed text — there are no
+ * bytes to fetch until the agent's write lands.
+ */
+ fileName?: string
+}
+
+export interface FileViewProps {
+ source: ResourceSource<'file'>
+ grants: ResourceGrants
+ host: ResourceHost
+ /**
+ * Render a reading surface with no editor at all: text files render through
+ * {@link PreviewPanel} (or a plain ``) rather than a disabled
+ * {@link TextEditor}.
+ *
+ * Not derivable from `grants.write` — a workspace member who cannot edit still
+ * gets the full editor chrome (syntax highlighting, split preview) on the Files
+ * page, while an embedded or shared file is a reading surface for everyone.
+ */
+ readOnly?: boolean
+ previewMode?: PreviewMode
+ autoFocus?: boolean
+ onDirtyChange?: (isDirty: boolean) => void
+ onSaveStatusChange?: (
+ status: 'idle' | 'saving' | 'saved' | 'error',
+ retry?: () => Promise
+ ) => void
+ saveRef?: React.MutableRefObject<(() => Promise) | null>
+ discardRef?: React.MutableRefObject<(() => void) | null>
+ streaming?: FileViewStreaming
+ /**
+ * Opt this surface into live collaborative editing (markdown files only). Set by the
+ * Files page and the embedded chat file preview; other hosts leave it off so
+ * collaboration and agent-streaming never target one editor.
+ */
+ collaborative?: boolean
+ /**
+ * Called (debounced) with the markdown document's leading-heading text while the file
+ * is still untitled, so the caller can name the file after it. Only wired for the
+ * editable markdown editor.
+ */
+ onDeriveTitleFromHeading?: (headingText: string) => void
+ /**
+ * How this host moves the viewer — the router half of `host`. Supplied by a
+ * host that owns a router (`router.push`); omitted by a `'public'` one, where
+ * mentions and in-document links resolve to `null` anyway and stay inert.
+ */
+ onNavigate?: (path: string) => void
+}
+
+/** The record for a file an agent is writing that does not exist yet. */
+function streamingFileRecord(fileName: string): FileViewRecord {
+ const extension = getFileExtension(fileName)
+ return {
+ id: 'streaming-file',
+ name: fileName,
+ type: GENERATED_SOURCE_MIME_BY_EXTENSION[extension] ?? getMimeTypeFromExtension(extension),
+ key: '',
+ size: 0,
+ updatedAt: new Date(0),
+ folderId: null,
+ }
+}
+
+/**
+ * Renders one file's real contents — PDFs, images, docx, xlsx, pptx, markdown,
+ * CSV, and code — from whichever address its {@link ResourceSource} carries, so
+ * the same view serves the Files page, an embedded panel, and an anonymous share.
+ */
+export function FileView({ source, grants, host, onNavigate, ...props }: FileViewProps) {
+ const shared = useMemo(() => (source.via === 'share' ? shareFileRecord(source) : null), [source])
+ const streamingName = props.streaming?.fileName
+ const streaming = useMemo(
+ () => (streamingName ? streamingFileRecord(streamingName) : null),
+ [streamingName]
+ )
+ /**
+ * Both of these describe the file without a lookup — a share carries its
+ * server-resolved seed, and an agent-written file exists only in the stream.
+ * Resolving them here keeps {@link WorkspaceFileView}, and therefore the
+ * workspace record query, off the page entirely when neither address applies.
+ */
+ const known = shared ?? streaming
+
+ return (
+
+ {known ? : }
+
+ )
+}
+
+type FileViewSurfaceProps = Omit
+
+/**
+ * Resolves the workspace record for the addressed file. The record comes from the
+ * shared active-files query, so a surface that already listed the workspace's
+ * files reads it straight from cache.
+ */
+function WorkspaceFileView(props: FileViewSurfaceProps) {
+ const { source, host } = useResourceOfKind('file')
+ const workspaceId = fileWorkspaceId(source) ?? ''
+ const fileId = source.via === 'workspace' ? source.resourceId : ''
+ const { data, isPending, isFetching, isError } = useWorkspaceFileRecord(workspaceId, fileId)
+ const record = data ?? null
+
+ /**
+ * A background refetch that has not yet produced the record still reads as
+ * pending — a file created moments ago must not flash "not found" while the
+ * invalidated list is in flight.
+ */
+ if (isPending || (isFetching && !record)) {
+ return
+ }
+
+ if (!record) {
+ /**
+ * A failed lookup is not a missing file. Reporting an outage as a deletion
+ * sends the viewer off to re-wire something that is still perfectly valid.
+ */
+ const reason: UnavailableReason = isError ? 'transient' : 'missing'
+ return (
+
+ )
+ }
+
+ return
+}
+
+function FileViewContent({
+ file,
+ readOnly = false,
+ previewMode,
+ autoFocus,
+ onDirtyChange,
+ onSaveStatusChange,
+ saveRef,
+ discardRef,
+ streaming,
+ collaborative,
+ onDeriveTitleFromHeading,
+}: FileViewSurfaceProps & { file: FileViewRecord }) {
+ const { source, grants } = useResourceOfKind('file')
+ const canEdit = grants.write
+ const category = resolveFileCategory(file.type, file.name)
+
+ if (category === 'text-editable') {
+ if (readOnly) {
+ // ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
+ // browser. CsvTablePreview's streamed fallback is workspace-only, so on the
+ // read-only path a large CSV is download-only.
+ if (isCsvStreamOnly(file)) {
+ return
+ }
+ // Markdown renders through the inline rich editor (non-editable) so a shared or
+ // embedded file matches the in-app reading experience; canEdit={false} disables
+ // autosave, the bubble menu, and every other editing affordance.
+ if (isMarkdownFile(file)) {
+ return
+ }
+ return
+ }
+ // A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
+ // Render a streamed, read-only preview of the first rows + an "Import as a table" path
+ // instead. That route is workspace-authenticated, so a share falls back to download-only.
+ if (isCsvStreamOnly(file)) {
+ return fileWorkspaceId(source) ? (
+
+ ) : (
+
+ )
+ }
+
+ if (isMarkdownFile(file)) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ if (category === 'iframe-previewable') {
+ return
+ }
+
+ if (category === 'image-previewable') {
+ return
+ }
+
+ if (category === 'audio-previewable') {
+ return
+ }
+
+ if (category === 'video-previewable') {
+ return
+ }
+
+ if (category === 'docx-previewable') {
+ return
+ }
+
+ if (category === 'pptx-previewable') {
+ return
+ }
+
+ if (category === 'xlsx-previewable') {
+ return
+ }
+
+ return
+}
+
+/**
+ * Read-only text/markdown/code preview. Renders rich types (markdown, csv, svg,
+ * mermaid, html) through {@link PreviewPanel} and plain text/code in a ``.
+ * Fetches content through the mounted source, so it works for both workspace
+ * files and public share links.
+ */
+const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ file }: { file: FileViewRecord }) {
+ const { source } = useResourceOfKind('file')
+ const { data: content, isLoading, error } = useWorkspaceFileContent(source, file.id, file.key)
+
+ const resolvedError = resolvePreviewError((error as Error | null) ?? null, null)
+ if (resolvedError) return
+ if (isLoading || content == null) return
+
+ if (resolvePreviewType(file.type, file.name)) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+})
+
+const IframePreview = memo(function IframePreview({ file }: { file: FileViewRecord }) {
+ const preview = useDocPreviewBinary(file)
+
+ const bufferSource = useMemo(
+ () => (preview.data ? { kind: 'buffer', buffer: preview.data } : null),
+ [preview.data]
+ )
+
+ const error = resolvePreviewError(preview.error, null)
+ if (error) return
+
+ if (!bufferSource) {
+ return {PREVIEW_LOADING_OVERLAY}
+ }
+
+ return (
+
+
+
+ )
+})
+
+/**
+ * Audio and video, played straight from the content URL.
+ *
+ * Deliberately NOT fetched: the element streams the object itself, so playback
+ * starts on the first bytes and a seek costs one short ranged request. The
+ * previous implementation downloaded the whole file and played it from a
+ * `blob:` URL — the only reason the scrubber worked, since the routes advertised
+ * no `Accept-Ranges` — which put an entire video in the JS heap before the first
+ * frame. The routes byte-serve now, so the element does this correctly and for
+ * free.
+ *
+ * `preload='metadata'` fetches only enough to know the duration, so mounting a
+ * long video costs one small request rather than a buffer.
+ */
+const MediaPreview = memo(function MediaPreview({
+ file,
+ kind,
+}: {
+ file: FileViewRecord
+ kind: 'audio' | 'video'
+}) {
+ const { source } = useResourceOfKind('file')
+ const [failed, setFailed] = useState(false)
+
+ /** Versioned so an edited file busts the browser's media cache. */
+ const src = file.key
+ ? fileContentUrl(source, file.key, { version: file.updatedAt.getTime() })
+ : null
+
+ if (!src || failed) {
+ return
+ }
+
+ if (kind === 'audio') {
+ return (
+
+
+ {/* biome-ignore lint/a11y/useMediaCaption: audio from workspace files */}
+
setFailed(true)}
+ className='w-full max-w-[480px]'
+ />
+
+ )
+ }
+
+ /**
+ * The element fills the pane and letterboxes the frame, rather than sizing
+ * itself to the video.
+ *
+ * A `` reports an intrinsic 300x150 until its metadata arrives, so
+ * `max-h-full max-w-full` — which sizes *to* the intrinsics — paints a small
+ * box and snaps to full size once the first bytes land. Driving the box from
+ * the pane instead makes the layout independent of load state, and
+ * `object-contain` keeps the aspect ratio honest inside it.
+ */
+ return (
+
+ {/* biome-ignore lint/a11y/useMediaCaption: video from workspace files */}
+ setFailed(true)}
+ className='h-full w-full object-contain'
+ />
+
+ )
+})
+
+/**
+ * The dead end for a file no renderer handles — an archive, an installer, a
+ * columnar dataset. It carries its own download link rather than pointing at a
+ * button in the surrounding chrome: this view is mounted on surfaces that draw
+ * no chrome at all (the fullscreen file route, an interface's file module), and
+ * telling a visitor to press a button that is not on the page strands them with
+ * no way to reach the bytes.
+ */
+const UnsupportedPreview = memo(function UnsupportedPreview({ file }: { file: FileViewRecord }) {
+ const { source } = useResourceOfKind('file')
+ const ext = getFileExtension(file.name)
+ const href = file.key
+ ? fileContentUrl(source, file.key, { version: file.updatedAt.getTime() })
+ : null
+
+ return (
+
+
+ Preview not available{ext ? ` for .${ext} files` : ' for this file'}
+
+ {href ? (
+
+ Download
+
+ ) : (
+
This file has no content yet
+ )}
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/components/resources/file-view/hooks/csv-import.ts
similarity index 91%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
rename to apps/sim/components/resources/file-view/hooks/csv-import.ts
index b91d1b99318..e0daa8bab99 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts
+++ b/apps/sim/components/resources/file-view/hooks/csv-import.ts
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
import { generateId } from '@sim/utils/id'
-import { useRouter } from 'next/navigation'
+import { useResource } from '@/components/resources/resource-provider'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
@@ -22,7 +22,7 @@ export function useCsvTruncationImport(
truncated: boolean,
readOnly = false
) {
- const router = useRouter()
+ const { navigate } = useResource()
const importFile = useImportFileAsTable()
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
@@ -40,7 +40,7 @@ export function useCsvTruncationImport(
description: 'This runs in the background.',
action: {
label: 'View tables',
- onClick: () => router.push(`/workspace/${workspaceId}/tables`),
+ onClick: () => navigate(`/workspace/${workspaceId}/tables`),
},
})
importFile.mutate(
@@ -52,7 +52,7 @@ export function useCsvTruncationImport(
},
}
)
- // importFile.mutate and router are stable references
+ // importFile.mutate and navigate are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, file.key, file.name])
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.test.ts b/apps/sim/components/resources/file-view/hooks/use-doc-preview-binary.test.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.test.ts
rename to apps/sim/components/resources/file-view/hooks/use-doc-preview-binary.test.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts b/apps/sim/components/resources/file-view/hooks/use-doc-preview-binary.ts
similarity index 92%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts
rename to apps/sim/components/resources/file-view/hooks/use-doc-preview-binary.ts
index 5819182db44..f8e2e0e3afa 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts
+++ b/apps/sim/components/resources/file-view/hooks/use-doc-preview-binary.ts
@@ -1,8 +1,9 @@
'use client'
import { useRef } from 'react'
-import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
+import type { FileViewRecord } from '@/resources/file-source'
export type DocPreviewState = 'empty' | 'loading' | 'ready' | 'stale'
@@ -13,7 +14,7 @@ export interface DocPreviewBinary {
dataUpdatedAt: number
}
-type DocPreviewFile = Pick
+type DocPreviewFile = Pick
interface ResolveDocPreviewArgs {
data: ArrayBuffer | undefined
@@ -126,8 +127,9 @@ export function stepDocPreviewBinary({
* placeholder (which still holds the prior file's bytes) is ignored until a fresh
* binary resolves for the new file, so one viewer never renders another file's content.
*/
-export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile): DocPreviewBinary {
- const query = useWorkspaceFileBinary(workspaceId, file.id, file.key, {
+export function useDocPreviewBinary(file: DocPreviewFile): DocPreviewBinary {
+ const { source } = useResourceOfKind('file')
+ const query = useWorkspaceFileBinary(source, file.id, file.key, {
enabled: (file.size ?? 0) > 0,
version: Number(new Date(file.updatedAt)) || file.size,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx b/apps/sim/components/resources/file-view/hooks/use-editable-file-content.test.tsx
similarity index 91%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx
rename to apps/sim/components/resources/file-view/hooks/use-editable-file-content.test.tsx
index 58c5447df66..0e34c389b06 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.test.tsx
+++ b/apps/sim/components/resources/file-view/hooks/use-editable-file-content.test.tsx
@@ -23,7 +23,7 @@ const { queryState } = vi.hoisted(() => ({
vi.mock('@/hooks/queries/workspace-files', () => ({
useWorkspaceFileContent: (
- _workspaceId: string,
+ _source: unknown,
_fileId: string,
_key: string,
_raw?: boolean,
@@ -41,6 +41,8 @@ vi.mock('idb-keyval', () => ({
del: vi.fn(async () => {}),
}))
+import { ResourceProvider } from '@/components/resources/resource-provider'
+import { grantsFromPermissions, workspaceSource } from '@/resources'
import {
RECONCILING_REFETCH_INTERVAL_MS,
RECONCILING_REFETCH_SLOW_INTERVAL_MS,
@@ -48,6 +50,9 @@ import {
useEditableFileContent,
} from './use-editable-file-content'
+const SOURCE = workspaceSource({ kind: 'file', workspaceId: 'ws-1', resourceId: 'f1' })
+const GRANTS = grantsFromPermissions({ canRead: true, canEdit: true, canAdmin: false })
+
const FILE = {
id: 'f1',
key: 'workspace/ws-1/123-abc-doc.md',
@@ -68,7 +73,6 @@ let latest: ReturnType | null = null
function Probe(props: ProbeProps) {
latest = useEditableFileContent({
file: FILE,
- workspaceId: 'ws-1',
canEdit: true,
streamingContent: props.streamingContent,
isAgentEditing: props.isAgentEditing,
@@ -76,9 +80,17 @@ function Probe(props: ProbeProps) {
return null
}
+function MountedProbe(props: ProbeProps) {
+ return (
+
+
+
+ )
+}
+
function render(props: ProbeProps) {
act(() => {
- root?.render( )
+ root?.render( )
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/components/resources/file-view/hooks/use-editable-file-content.ts
similarity index 95%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts
rename to apps/sim/components/resources/file-view/hooks/use-editable-file-content.ts
index f8dac76bbdd..a6f9aca33ea 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts
+++ b/apps/sim/components/resources/file-view/hooks/use-editable-file-content.ts
@@ -2,7 +2,12 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import { toast } from '@sim/emcn'
-import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import {
+ INITIAL_TEXT_EDITOR_CONTENT_STATE,
+ type SyncTextEditorContentStateOptions,
+ textEditorContentReducer,
+} from '@/components/resources/file-view/utils/text-editor-state'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
import { GENERATED_DOCUMENT_SOURCE_TYPES } from '@/lib/uploads/utils/file-utils'
import {
useUpdateWorkspaceFileContent,
@@ -10,11 +15,7 @@ import {
} from '@/hooks/queries/workspace-files'
import { type SaveStatus, useAutosave } from '@/hooks/use-autosave'
import { useSmoothText } from '@/hooks/use-smooth-text'
-import {
- INITIAL_TEXT_EDITOR_CONTENT_STATE,
- type SyncTextEditorContentStateOptions,
- textEditorContentReducer,
-} from './text-editor-state'
+import { type FileViewRecord, fileWorkspaceId } from '@/resources/file-source'
/**
* Generated-document source files (`.pptx`/`.docx`/`.pdf`/`.xlsx` builders) whose
@@ -43,8 +44,7 @@ export const RECONCILING_REFETCH_WINDOW_MS = 45_000
export const RECONCILING_REFETCH_SLOW_INTERVAL_MS = 15_000
interface UseEditableFileContentOptions {
- file: WorkspaceFileRecord
- workspaceId: string
+ file: FileViewRecord
canEdit: boolean
streamingContent?: string
isAgentEditing?: boolean
@@ -134,7 +134,6 @@ function useFileContentState(options: SyncTextEditorContentStateOptions) {
*/
export function useEditableFileContent({
file,
- workspaceId,
canEdit,
streamingContent,
isAgentEditing,
@@ -145,6 +144,9 @@ export function useEditableFileContent({
normalizeBaseline,
canAutosave = true,
}: UseEditableFileContentOptions): EditableFileContent {
+ const { source } = useResourceOfKind('file')
+ /** `null` on a share: there is nothing to save to, and `canEdit` is already false there. */
+ const workspaceId = fileWorkspaceId(source)
const onDirtyChangeRef = useRef(onDirtyChange)
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
onDirtyChangeRef.current = onDirtyChange
@@ -174,7 +176,7 @@ export function useEditableFileContent({
isLoading,
error,
} = useWorkspaceFileContent(
- workspaceId,
+ source,
file.id,
file.key,
GENERATED_SOURCE_FILE_TYPES.has(file.type),
@@ -235,6 +237,7 @@ export function useEditableFileContent({
const onSave = useCallback(
async (overrideContent?: string) => {
+ if (!workspaceId) return
const next = overrideContent ?? contentRef.current
await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next })
markSavedContent(next)
@@ -242,7 +245,8 @@ export function useEditableFileContent({
[workspaceId, file.id, markSavedContent]
)
- const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked && canAutosave
+ const autosaveEnabled =
+ Boolean(workspaceId) && canEdit && isInitialized && !isStreamInteractionLocked && canAutosave
const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({
content,
diff --git a/apps/sim/components/resources/file-view/index.ts b/apps/sim/components/resources/file-view/index.ts
new file mode 100644
index 00000000000..9ac199ae97c
--- /dev/null
+++ b/apps/sim/components/resources/file-view/index.ts
@@ -0,0 +1,16 @@
+/**
+ * The file resource view. Consumers mount {@link FileView} against a source,
+ * grants, and a host; everything else here is what the surrounding surfaces
+ * (toolbars, tab chrome) need to describe a file without opening it.
+ */
+
+export { RICH_PREVIEWABLE_EXTENSIONS } from './components/preview-panel'
+export type { FileViewStreaming, PreviewMode } from './file-view'
+export {
+ FileView,
+ isCsvStreamOnly,
+ isMarkdownFile,
+ isPreviewable,
+ isTextEditable,
+} from './file-view'
+export { resolveFileCategory } from './utils/file-category'
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/components/resources/file-view/utils/file-category.test.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts
rename to apps/sim/components/resources/file-view/utils/file-category.test.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/components/resources/file-view/utils/file-category.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts
rename to apps/sim/components/resources/file-view/utils/file-category.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts b/apps/sim/components/resources/file-view/utils/preview-wheel-zoom.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts
rename to apps/sim/components/resources/file-view/utils/preview-wheel-zoom.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts b/apps/sim/components/resources/file-view/utils/text-editor-state.test.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.test.ts
rename to apps/sim/components/resources/file-view/utils/text-editor-state.test.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts b/apps/sim/components/resources/file-view/utils/text-editor-state.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-state.ts
rename to apps/sim/components/resources/file-view/utils/text-editor-state.ts
diff --git a/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/index.ts b/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/index.ts
new file mode 100644
index 00000000000..db8f5954259
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/index.ts
@@ -0,0 +1,2 @@
+export type { InterfaceCellProps } from './interface-cell'
+export { InterfaceCell } from './interface-cell'
diff --git a/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/interface-cell.tsx b/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/interface-cell.tsx
new file mode 100644
index 00000000000..35aff6fa886
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-canvas/components/interface-cell/interface-cell.tsx
@@ -0,0 +1,267 @@
+'use client'
+
+import { type CSSProperties, type DragEvent, useState } from 'react'
+import { Chip, chipContentGap, chipContentIconClass, chipContentLabelClass, cn } from '@sim/emcn'
+import { GripVertical, Plus, X } from '@sim/emcn/icons'
+import { ModuleChooser } from '@/components/resources/interface-view/components/module-chooser'
+import { ModuleRenderer } from '@/components/resources/interface-view/components/module-renderer'
+import { MODULE_GUTTER_X } from '@/components/resources/interface-view/module-chrome'
+import type {
+ InterfaceCell as InterfaceGridCell,
+ InterfaceModule,
+ InterfaceModuleType,
+} from '@/lib/interfaces'
+import { INTERFACE_MODULE_META, INTERFACE_MODULE_ORDER } from '@/lib/interfaces/module-meta'
+
+/**
+ * The border a cell wears when it is selected, or when a drag is hovering it.
+ *
+ * `--text-muted` rather than a `--border-*` token, deliberately: this has to
+ * read as clearly stronger than the resting `--border` in BOTH themes, and no
+ * border token does. `--border-1` is *lighter* than `--border` in light mode
+ * (`#e0e0e0` vs `#dedede`), so it would make a selected module less visible
+ * than an idle one; `--border-inverted` is near-black in light and barely
+ * distinguishable in dark. `--text-muted` is the one neutral grey that moves
+ * away from the surface in both (`#707070` light, `#787878` dark).
+ */
+const SELECTION_BORDER_CLASS = 'border-[var(--text-muted)]'
+
+export interface InterfaceCellProps {
+ /** Top-left corner of the area this element covers — its drop target. */
+ cell: InterfaceGridCell
+ /**
+ * Grid placement from `modulePlacementStyle`, carried as the `--module-row` /
+ * `--module-col` custom properties the classes below consume. An occupied
+ * cell spans its module's whole rectangle; a vacant one is always a single
+ * square.
+ */
+ style: CSSProperties
+ /** null = empty cell → dashed placeholder resting as a `+`. */
+ module: InterfaceModule | null
+ selected: boolean
+ canEdit: boolean
+ /** Whether the module mounted in this cell is live for the viewer. */
+ canRun: boolean
+ onSelect: () => void
+ onAddModule: (type: InterfaceModuleType) => void
+ /**
+ * Applies a config edit the module made about itself. Passed straight to the
+ * renderer with its identity intact — a closure minted here would defeat
+ * `ModuleRenderer`'s memo on every drag tick.
+ */
+ onConfigChange: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+ onRemove: () => void
+ /** HTML5 DnD. A drag can only *start* when `canEdit`; drop targets stay wired. */
+ onDragStart: () => void
+ onDragEnd: () => void
+ onDragOver: (event: DragEvent) => void
+ onDrop: () => void
+ isDragTarget: boolean
+ isDragging: boolean
+}
+
+/**
+ * One area of the authoring grid — a single square when vacant, a module's
+ * whole rectangle when occupied. The cell owns the entire module frame —
+ * border, selection ring, drag affordances, remove control, and the type bar —
+ * so module renderers only ever paint their interior.
+ *
+ * Edit-mode only, by construction: preview mounts `InterfacePane` instead, so
+ * none of this chrome needs a mode check and none of it can reach a visitor.
+ *
+ * An empty cell rests as a single `+`. Pressing it opens the module types *in
+ * the cell* — a stack of full-width chips rather than a menu floating over the
+ * canvas — with a dismiss in the corner that returns the cell to its resting
+ * `+`. Both states accept drops, so a module can still be moved in either way.
+ */
+export function InterfaceCell({
+ cell,
+ style,
+ module,
+ selected,
+ canEdit,
+ canRun,
+ onSelect,
+ onAddModule,
+ onConfigChange,
+ onRemove,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ onDrop,
+ isDragTarget,
+ isDragging,
+}: InterfaceCellProps) {
+ const [isChoosing, setIsChoosing] = useState(false)
+
+ const handleDrop = (event: DragEvent) => {
+ event.preventDefault()
+ onDrop()
+ }
+
+ if (!module) {
+ const cellName = `row ${cell.row + 1}, column ${cell.col + 1}`
+ return (
+
+ {canEdit &&
+ (isChoosing ? (
+ <>
+ {/**
+ * Anchored to the cell rather than carried inside the chooser
+ * column: it dismisses the *cell's* open state, which is state the
+ * column neither owns nor knows about.
+ */}
+
setIsChoosing(false)}
+ aria-label={`Cancel adding a module to ${cellName}`}
+ className='absolute top-2 left-2 z-10'
+ />
+
+ {INTERFACE_MODULE_ORDER.map((type) => {
+ const meta = INTERFACE_MODULE_META[type]
+ return (
+ onAddModule(type)}
+ aria-label={`Add a ${meta.label} module to ${cellName}`}
+ >
+ {meta.label}
+
+ )
+ })}
+
+ >
+ ) : (
+ setIsChoosing(true)}
+ aria-label={`Add a module to ${cellName}`}
+ className='flex size-full items-center justify-center'
+ >
+
+
+ ))}
+
+ )
+ }
+
+ const meta = INTERFACE_MODULE_META[module.type]
+ const Icon = meta.icon
+
+ const handleDragStart = (event: DragEvent) => {
+ if (!canEdit) {
+ event.preventDefault()
+ return
+ }
+ event.dataTransfer.effectAllowed = 'move'
+ /** Firefox refuses to start a drag with an empty data transfer. */
+ event.dataTransfer.setData('text/plain', module.id)
+ onDragStart()
+ }
+
+ return (
+
+ {/**
+ * `chipContentGap` so the title bar's icon↔label spacing is the chip's,
+ * not a lookalike. The whole bar is the module's drag handle — the cell
+ * itself carries `draggable`, so grabbing anywhere on the header works —
+ * and it wears `cursor-grab` to say so. Only the remove control opts back
+ * out, since it is a click target rather than a place to grab.
+ */}
+
+ {/**
+ * The title bar is the module's select handle. It is a real button so
+ * selection is keyboard-reachable and announces its pressed state —
+ * the body's pointer-capture below is a mouse convenience on top of it,
+ * not the only way in.
+ */}
+
+
+ {meta.label}
+
+ {canEdit && (
+
+ )}
+ {canEdit && (
+
+ )}
+
+ {/**
+ * Clicking anywhere in the module selects it — captured so a renderer that
+ * stops propagation cannot swallow it. The body stays a plain scroll
+ * container rather than sitting under an absolute overlay: an overlay is a
+ * sibling of this scroller, so it would eat the wheel events an 8-field
+ * form in a quarter cell needs, and it would make every renderer's inert
+ * edit-mode branch unreachable. Those branches are the real inertness —
+ * the form's submit and the chat composer both disable themselves on
+ * `mode === 'edit'`, which is the only mode this cell ever renders in.
+ */}
+
+
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/interface-canvas/index.ts b/apps/sim/components/resources/interface-view/components/interface-canvas/index.ts
new file mode 100644
index 00000000000..c84fee6c25a
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-canvas/index.ts
@@ -0,0 +1,2 @@
+export type { InterfaceCanvasProps } from './interface-canvas'
+export { InterfaceCanvas } from './interface-canvas'
diff --git a/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.test.tsx b/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.test.tsx
new file mode 100644
index 00000000000..b15ddce3e2d
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.test.tsx
@@ -0,0 +1,206 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The frame is what these tests are about — which chrome each mode paints
+ * around a module — so the renderer is stubbed to keep the chat, table, and
+ * file dependency trees out of the run.
+ */
+vi.mock('@/components/resources/interface-view/components/module-renderer', () => ({
+ ModuleRenderer: ({ module, mode }: { module: { id: string }; mode: string }) => (
+
+ ),
+}))
+
+import { InterfaceCanvas } from '@/components/resources/interface-view/components/interface-canvas'
+import type { InterfaceLayout, InterfaceMode, InterfaceModule } from '@/lib/interfaces/types'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+let container: HTMLDivElement
+let root: Root
+
+function moduleAt(id: string, row: 0 | 1, col: 0 | 1): InterfaceModule {
+ return {
+ id,
+ type: 'table',
+ placement: { row, col, rowSpan: 1, colSpan: 1 },
+ config: { tableId: null },
+ }
+}
+
+function layoutOf(...modules: InterfaceModule[]): InterfaceLayout {
+ return { version: 1, grid: { rows: 2, cols: 2 }, modules }
+}
+
+function render(layout: InterfaceLayout, mode: InterfaceMode, canEdit = true) {
+ act(() => {
+ root.render(
+
+ )
+ })
+}
+
+/**
+ * `${moduleId}@${row}|${col}` — the placement each pane actually got.
+ *
+ * Read off the `--module-row` / `--module-col` custom properties rather than
+ * `style.gridRow` / `style.gridColumn`: an inline `style` outranks every media
+ * query, so the placement has to travel as variables for the phone layout to be
+ * able to drop it.
+ */
+function placements(): string[] {
+ return [...container.querySelectorAll('[data-testid="module"]')].map((node) => {
+ const pane = node.parentElement?.parentElement as HTMLElement
+ const row = pane.style.getPropertyValue('--module-row')
+ const col = pane.style.getPropertyValue('--module-col')
+ return `${node.getAttribute('data-module-id')}@${row}|${col}`
+ })
+}
+
+/** The preview grid element, found by the geometry variables it carries. */
+function previewGrid(): HTMLElement {
+ const grid = container.querySelector('[style*="--interface-cols"]') as HTMLElement | null
+ if (!grid) throw new Error('Preview grid not found')
+ return grid
+}
+
+beforeEach(() => {
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('InterfaceCanvas — preview mode', () => {
+ const layout = layoutOf(moduleAt('a', 0, 0), moduleAt('b', 0, 1))
+
+ it('renders one live module per placement and nothing else', () => {
+ render(layout, 'preview')
+
+ const modules = [...container.querySelectorAll('[data-testid="module"]')]
+ expect(modules.map((node) => node.getAttribute('data-module-id'))).toEqual(['a', 'b'])
+ expect(modules.every((node) => node.getAttribute('data-mode') === 'preview')).toBe(true)
+ })
+
+ it('paints no editing affordances', () => {
+ render(layout, 'preview')
+
+ expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[draggable="true"]')).toBeNull()
+ expect(container.querySelector('.border-dashed')).toBeNull()
+ /** The type bar is the module's select handle; it must not reach a visitor. */
+ expect(container.textContent).not.toContain('Table')
+ })
+
+ it('never paints the selection ring, even on the selected module', () => {
+ render(layout, 'preview')
+
+ /**
+ * Matched by class string rather than a CSS selector: the ring is a
+ * Tailwind arbitrary value, and its brackets are not selector-safe.
+ */
+ const ringed = [...container.querySelectorAll('*')].some((node) =>
+ node.className.toString().includes('border-[var(--text-muted)]')
+ )
+ expect(ringed).toBe(false)
+ })
+
+ it('drops empty tracks so a filled top row becomes two full-height columns', () => {
+ render(layout, 'preview')
+
+ const grid = previewGrid()
+ expect(grid.style.getPropertyValue('--interface-rows')).toBe('1')
+ expect(grid.style.getPropertyValue('--interface-cols')).toBe('2')
+ expect(placements()).toEqual(['a@1 / span 1|1 / span 1', 'b@1 / span 1|2 / span 1'])
+ })
+
+ /**
+ * The geometry must not be written as an inline `grid-template-*`: media
+ * queries cannot outrank an inline style, so the phone layout could never
+ * collapse the grid to one column.
+ */
+ it('carries the geometry in custom properties, not inline grid templates', () => {
+ render(layout, 'preview')
+
+ const grid = previewGrid()
+ expect(grid.style.gridTemplateRows).toBe('')
+ expect(grid.style.gridTemplateColumns).toBe('')
+
+ const pane = container.querySelector('[style*="--module-row"]') as HTMLElement
+ expect(pane.style.gridRow).toBe('')
+ expect(pane.style.gridColumn).toBe('')
+ })
+
+ it('stacks the panes below the sm breakpoint', () => {
+ render(layout, 'preview')
+
+ expect(previewGrid().className).toContain('max-sm:[grid-template-columns:minmax(0,1fr)]')
+ const pane = container.querySelector('[style*="--module-row"]') as HTMLElement
+ expect(pane.className).toContain('max-sm:[grid-column:auto]')
+ expect(pane.className).toContain('max-sm:[grid-row:auto]')
+ })
+
+ it('lets a lone module fill the page wherever it was authored', () => {
+ render(layoutOf(moduleAt('a', 1, 1)), 'preview')
+ expect(placements()).toEqual(['a@1 / span 1|1 / span 1'])
+ })
+
+ it('spans the lone module of a row across both columns', () => {
+ render(layoutOf(moduleAt('a', 0, 0), moduleAt('b', 0, 1), moduleAt('c', 1, 0)), 'preview')
+ expect(placements()).toEqual([
+ 'a@1 / span 1|1 / span 1',
+ 'b@1 / span 1|2 / span 1',
+ 'c@2 / span 1|1 / span 2',
+ ])
+ })
+
+ it('shows a neutral empty state that names no editing surface', () => {
+ render(layoutOf(), 'preview')
+
+ expect(container.textContent).toContain('This interface has no modules yet.')
+ expect(container.textContent).not.toContain('edit mode')
+ expect(container.textContent).not.toContain('properties panel')
+ })
+})
+
+describe('InterfaceCanvas — edit mode', () => {
+ it('paints all four authoring cells with the module chrome', () => {
+ render(layoutOf(moduleAt('a', 0, 0)), 'edit')
+
+ expect(container.querySelectorAll('[data-testid="module"]')).toHaveLength(1)
+ expect(container.querySelector('[data-testid="module"]')?.getAttribute('data-mode')).toBe(
+ 'edit'
+ )
+ expect(container.querySelector('[aria-label="Select Table module"]')).not.toBeNull()
+ expect(container.querySelector('[aria-label="Remove Table module"]')).not.toBeNull()
+ expect(container.querySelectorAll('.border-dashed')).toHaveLength(3)
+ expect(container.querySelector('[draggable="true"]')).not.toBeNull()
+ })
+
+ it('withholds drag and the add affordance from a viewer', () => {
+ render(layoutOf(moduleAt('a', 0, 0)), 'edit', false)
+
+ expect(container.querySelector('[draggable="true"]')).toBeNull()
+ expect(container.querySelector('[aria-label="Remove Table module"]')).toBeNull()
+ expect(container.querySelector('[aria-label^="Add a module"]')).toBeNull()
+ })
+})
diff --git a/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.tsx b/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.tsx
new file mode 100644
index 00000000000..9a503b8abe4
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-canvas/interface-canvas.tsx
@@ -0,0 +1,164 @@
+'use client'
+
+import { type DragEvent, useMemo, useState } from 'react'
+import { InterfaceCell } from '@/components/resources/interface-view/components/interface-canvas/components/interface-cell'
+import { InterfacePreviewGrid } from '@/components/resources/interface-view/components/interface-preview-grid'
+import {
+ INTERFACE_GRID_CLASS,
+ INTERFACE_SCROLL_WELL_CLASS,
+ interfaceGridStyle,
+ modulePlacementStyle,
+} from '@/components/resources/interface-view/module-chrome'
+import { DEFAULT_MODULE_SPAN } from '@/lib/interfaces/constants'
+import { cellKey, freeCells } from '@/lib/interfaces/geometry'
+import type {
+ InterfaceCell as InterfaceGridCell,
+ InterfaceLayout,
+ InterfaceMode,
+ InterfaceModule,
+ InterfaceModuleType,
+ InterfacePlacement,
+} from '@/lib/interfaces/types'
+
+export interface InterfaceCanvasProps {
+ layout: InterfaceLayout
+ mode: InterfaceMode
+ /** null = nothing selected; the inspector shows its empty state. */
+ selectedModuleId: string | null
+ onSelectModule: (moduleId: string | null) => void
+ onAddModule: (type: InterfaceModuleType, cell: InterfaceGridCell) => void
+ onMoveModule: (moduleId: string, cell: InterfaceGridCell) => void
+ onRemoveModule: (moduleId: string) => void
+ /** Applies a config edit a module made about itself from inside the canvas. */
+ onUpdateModuleConfig: (
+ moduleId: string,
+ config: InterfaceModule['config'],
+ isValid: boolean
+ ) => void
+ /** `grants.write`. When false the canvas is read-only in both modes. */
+ canEdit: boolean
+ /** Whether the interactive modules are live for this viewer. */
+ canRun: boolean
+}
+
+/**
+ * The interface grid, in whichever of its two forms the mode calls for.
+ *
+ * Edit mode paints the authoring grid through `InterfaceCell` — each module
+ * renders at its own placement inside a frame with a type bar, drag handle,
+ * and remove control, and every cell no module covers renders the dashed
+ * add-a-module placeholder — so the page's shape stays visible while
+ * composing. The track counts come from `layout.grid`, and a module is drawn
+ * from its rectangle rather than one element per square, so a wider grid or a
+ * module that spans tracks needs nothing here.
+ *
+ * Preview mode renders the shipped page instead: `collapseLayout` drops
+ * empty tracks and spans lone modules across their row so nothing has a hole in
+ * it, and each module is mounted by `InterfacePane`, which carries no authoring
+ * affordances at all. The two surfaces are separate components rather than one
+ * component switched off, so no edit chrome can leak into what a visitor sees.
+ *
+ * Modules keep their component identity across the mode toggle and across drags
+ * because both surfaces key on module id, so an in-flight chat stream survives
+ * both.
+ *
+ * Nothing here carries an address: the renderers resolve their data from the
+ * surrounding `ResourceProvider`, which is what lets the public share page
+ * mount the very same components against token-scoped endpoints.
+ */
+export function InterfaceCanvas({
+ layout,
+ mode,
+ selectedModuleId,
+ onSelectModule,
+ onAddModule,
+ onMoveModule,
+ onRemoveModule,
+ onUpdateModuleConfig,
+ canEdit,
+ canRun,
+}: InterfaceCanvasProps) {
+ const [draggingModuleId, setDraggingModuleId] = useState(null)
+ const [dragOverCellKey, setDragOverCellKey] = useState(null)
+
+ /** Computed before the preview branch so the hook order stays unconditional. */
+ const vacant = useMemo(() => freeCells(layout), [layout])
+
+ if (mode === 'preview') {
+ return
+ }
+
+ /**
+ * `dragend` fires on the source cell for both a completed and a cancelled
+ * drag, so it is the only teardown the highlight needs — there is no
+ * `dragleave` in the cell contract.
+ */
+ const handleDragEnd = () => {
+ setDraggingModuleId(null)
+ setDragOverCellKey(null)
+ }
+
+ const handleDragOver = (cell: InterfaceGridCell) => (event: DragEvent) => {
+ if (!canEdit || !draggingModuleId) return
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ const key = cellKey(cell)
+ setDragOverCellKey((previous) => (previous === key ? previous : key))
+ }
+
+ const handleDrop = (cell: InterfaceGridCell) => () => {
+ const moduleId = draggingModuleId
+ setDraggingModuleId(null)
+ setDragOverCellKey(null)
+ if (!canEdit || !moduleId) return
+ const dragged = layout.modules.find((module) => module.id === moduleId)
+ if (!dragged) return
+ if (dragged.placement.row === cell.row && dragged.placement.col === cell.col) return
+ onMoveModule(moduleId, cell)
+ }
+
+ /**
+ * A module is one element covering its whole rectangle, so a drop anywhere on
+ * it targets its top-left corner — the same corner `moveModule` places the
+ * dragged module's own corner on.
+ */
+ const renderCell = (placement: InterfacePlacement, module: InterfaceModule | null) => {
+ const cell: InterfaceGridCell = { row: placement.row, col: placement.col }
+ return (
+ onSelectModule(module ? module.id : null)}
+ onAddModule={(type) => onAddModule(type, cell)}
+ onConfigChange={onUpdateModuleConfig}
+ onRemove={() => {
+ if (module) onRemoveModule(module.id)
+ }}
+ onDragStart={() => {
+ if (module) setDraggingModuleId(module.id)
+ }}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver(cell)}
+ onDrop={handleDrop(cell)}
+ isDragTarget={
+ canEdit && dragOverCellKey === cellKey(cell) && draggingModuleId !== (module?.id ?? null)
+ }
+ isDragging={module !== null && module.id === draggingModuleId}
+ />
+ )
+ }
+
+ return (
+
+
+ {layout.modules.map((module) => renderCell(module.placement, module))}
+ {vacant.map((cell) => renderCell({ ...cell, ...DEFAULT_MODULE_SPAN }, null))}
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/index.ts b/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/index.ts
new file mode 100644
index 00000000000..4d42ad16e47
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/index.ts
@@ -0,0 +1,2 @@
+export type { InterfacePaneProps } from './interface-pane'
+export { InterfacePane } from './interface-pane'
diff --git a/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/interface-pane.tsx b/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/interface-pane.tsx
new file mode 100644
index 00000000000..b3079e58bac
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-preview-grid/components/interface-pane/interface-pane.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import type { CSSProperties } from 'react'
+import { ModuleRenderer } from '@/components/resources/interface-view/components/module-renderer'
+import type { InterfaceModule } from '@/lib/interfaces'
+
+export interface InterfacePaneProps {
+ module: InterfaceModule
+ /**
+ * Collapsed grid placement from `collapseLayout`, built by
+ * `modulePlacementStyle` and carried as the `--module-row` / `--module-col`
+ * custom properties the classes below consume. Custom properties rather than
+ * `gridRow`/`gridColumn` directly: an inline `style` outranks every media
+ * query, so a literal placement could not be dropped for the stacked phone
+ * layout.
+ */
+ style: CSSProperties
+ /** Whether this surface is live for the viewer — the interactive modules need it to run. */
+ canRun: boolean
+}
+
+/**
+ * One module of the interface as an end user sees it.
+ *
+ * Deliberately *not* a disabled `InterfaceCell`: a visitor has nothing to
+ * select, drag, remove, or add, so none of that wiring exists here rather than
+ * existing and being switched off. The pane is a border and a scroll well — no
+ * type bar, no selection ring, no hover chrome — and the module it mounts is the
+ * same live renderer the editor mounts, so toggling modes never tears down an
+ * in-flight chat stream or a half-filled form.
+ *
+ * Below `sm` the pane leaves the authored grid entirely and flows in DOM order
+ * at a readable height: two columns on a 390px phone would give a chat
+ * composer, a form row, or a table header roughly 180px, which is unusable.
+ */
+export function InterfacePane({ module, style, canRun }: InterfacePaneProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/interface-preview-grid/index.ts b/apps/sim/components/resources/interface-view/components/interface-preview-grid/index.ts
new file mode 100644
index 00000000000..f93af0dee62
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-preview-grid/index.ts
@@ -0,0 +1,2 @@
+export type { InterfacePreviewGridProps } from './interface-preview-grid'
+export { InterfacePreviewGrid } from './interface-preview-grid'
diff --git a/apps/sim/components/resources/interface-view/components/interface-preview-grid/interface-preview-grid.tsx b/apps/sim/components/resources/interface-view/components/interface-preview-grid/interface-preview-grid.tsx
new file mode 100644
index 00000000000..c4cdad6a67d
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/interface-preview-grid/interface-preview-grid.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import { cn } from '@sim/emcn'
+import { Panels } from '@sim/emcn/icons'
+import { InterfacePane } from '@/components/resources/interface-view/components/interface-preview-grid/components/interface-pane'
+import {
+ INTERFACE_GRID_CLASS,
+ INTERFACE_GRID_STACK_SM_CLASS,
+ INTERFACE_SCROLL_WELL_CLASS,
+ interfaceGridStyle,
+ modulePlacementStyle,
+} from '@/components/resources/interface-view/module-chrome'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { collapseLayout } from '@/lib/interfaces/geometry'
+import type { InterfaceLayout } from '@/lib/interfaces/types'
+
+export interface InterfacePreviewGridProps {
+ layout: InterfaceLayout
+ /**
+ * Whether the modules are live for the viewer. The editor passes its
+ * workspace write permission; a public share passes `true`.
+ */
+ canRun: boolean
+}
+
+/**
+ * The interface exactly as it ships — the one component both the editor's
+ * Preview mode and the public share page render, so the preview cannot lie
+ * about the page and the two cannot drift.
+ *
+ * `collapseLayout` drops empty tracks and spans a row's lone module across it,
+ * so a two-module interface whose second module was un-wired collapses into a
+ * clean full-page surface rather than leaving a hole. The collapsed grid is
+ * whatever that pass produces, so this component never assumes a track count.
+ *
+ * Geometry lives in CSS custom properties rather than inline `grid-template-*`
+ * values: a phone must stack the panes, and an inline `style` outranks every
+ * media query, so a literal template could not be overridden. The variables are
+ * SSR-safe and need no JS, so there is no hydration flash and no layout jump.
+ */
+export function InterfacePreviewGrid({ layout, canRun }: InterfacePreviewGridProps) {
+ const preview = collapseLayout(layout)
+
+ if (preview.modules.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ {preview.modules.map(({ module, placement }) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-chooser/index.ts b/apps/sim/components/resources/interface-view/components/module-chooser/index.ts
new file mode 100644
index 00000000000..67b004ba1c4
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-chooser/index.ts
@@ -0,0 +1,2 @@
+export type { ModuleChooserProps } from './module-chooser'
+export { ModuleChooser } from './module-chooser'
diff --git a/apps/sim/components/resources/interface-view/components/module-chooser/module-chooser.tsx b/apps/sim/components/resources/interface-view/components/module-chooser/module-chooser.tsx
new file mode 100644
index 00000000000..7d315ff82dd
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-chooser/module-chooser.tsx
@@ -0,0 +1,47 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { cn } from '@sim/emcn'
+import { MODULE_GUTTER_X } from '@/components/resources/interface-view/module-chrome'
+
+export interface ModuleChooserProps {
+ /**
+ * Section label above the stack, in the sidebar's section-header style — the
+ * app's one way of titling a list of chips.
+ */
+ title: string
+ /** Full-width `Chip`s, or the single picker control that stands in for them. */
+ children: ReactNode
+}
+
+/**
+ * The centered column a module surface presents when it is being pointed at
+ * something: the empty grid cell offering the four module types, and a placed
+ * module offering the workspace resource it still needs.
+ *
+ * One component for both, so the surface a builder meets before picking a
+ * module and the one they meet before picking its resource are the same shape:
+ * same column width, same header, same rhythm.
+ *
+ * Carries no chrome beyond the header. The cell owns the frame — including any
+ * dismiss affordance, which belongs to the surface being dismissed rather than
+ * to the column it happens to contain — and every child owns its own surface.
+ */
+export function ModuleChooser({ title, children }: ModuleChooserProps) {
+ return (
+
+
+ {/** `h-[18px]` + muted `text-small`, matching the sidebar's "Chats" header exactly. */}
+
+ {children}
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/chat-module.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/chat-module.tsx
new file mode 100644
index 00000000000..5170a4ce758
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/chat-module.tsx
@@ -0,0 +1,208 @@
+'use client'
+
+import { Fragment } from 'react'
+import { cn } from '@sim/emcn'
+import { BubbleChat, Check, CircleAlert, Loader } from '@sim/emcn/icons'
+import { ChatTurn } from '@/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn'
+import {
+ type InterfaceChatStep,
+ useInterfaceChat,
+} from '@/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat'
+import { ModuleResourcePicker } from '@/components/resources/interface-view/components/module-renderer/components/module-resource-picker'
+import { MODULE_GUTTER_X } from '@/components/resources/interface-view/module-chrome'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
+import type { InterfaceMode, InterfaceModule } from '@/lib/interfaces/types'
+import { ChatInput } from '@/app/(interfaces)/chat/components/input/input'
+import type { ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import { useAutoScroll } from '@/hooks/use-auto-scroll'
+
+/**
+ * Filler timestamp for the synthesized welcome turn. A turn never renders a
+ * timestamp, so this is a module-scope constant rather than a `Date` allocated
+ * on every keystroke re-render.
+ */
+const WELCOME_TIMESTAMP = new Date(0)
+
+/**
+ * Attachment budget for a module turn. `/api/workflows/[id]/execute` caps a
+ * request body at 10MB and attachments are inlined as base64 (≈ +33%), so
+ * 3 x 2MB leaves comfortable headroom for the rest of the payload.
+ */
+const MODULE_CHAT_MAX_FILE_BYTES = 2 * 1024 * 1024
+const MODULE_CHAT_MAX_FILES = 3
+
+export interface ChatModuleProps {
+ module: Extract
+ mode: InterfaceMode
+ /**
+ * Whether this surface is live for the viewer. Sending without it fails at
+ * the execute route, so the composer is disabled rather than left live.
+ */
+ canRun?: boolean
+ /** Present only where this module may bind its own workflow — see `ModuleRenderer`. */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+function StepIcon({ status }: { status: InterfaceChatStep['status'] }) {
+ if (status === 'running') return
+ if (status === 'failed') {
+ return
+ }
+ return
+}
+
+/**
+ * Per-block progress for the turn in flight, rendered above the assistant
+ * bubble when the module has `showThinking` enabled.
+ */
+function ChatThinking({ steps }: { steps: InterfaceChatStep[] }) {
+ return (
+
+ {steps.map((step) => (
+
+
+ {step.label}
+
+ ))}
+
+ )
+}
+
+/**
+ * Chat surface for one interface module: sends a message to the wired
+ * workspace workflow and renders the block outputs the module selected.
+ *
+ * Execution mirrors a chat deployment exactly — same execute route, same
+ * `triggerType: 'chat'` payload, same `selectedOutputs` serialization (see
+ * {@link useInterfaceChat}).
+ *
+ * Rendering mirrors the Sim chat instead, through the shared renderers in
+ * `@/components/chat` (see {@link ChatTurn}): the same markdown pipeline, code
+ * highlighting, stream reveal, bubble, and attachment strip. A visitor reading
+ * an agent's answer here sees exactly what they would see reading one in the
+ * workspace.
+ *
+ * In `edit` mode — and for a viewer who cannot run the surface — the composer
+ * is disabled and no request is ever made; the welcome message still renders so
+ * the canvas previews the configured surface.
+ *
+ * On a public share the same component runs against the token-scoped execute
+ * route, which resolves the workflow and the output selection from the stored
+ * layout rather than from anything this client sends, and streams back the same
+ * typed execution events this client decodes — a different route, deliberately
+ * pinned to one wire format.
+ */
+export function ChatModule({ module, mode, canRun = true, onConfigChange }: ChatModuleProps) {
+ const { source } = useResourceOfKind('interface')
+ const { workflowId, outputConfigs, showThinking, welcomeMessage } = module.config
+ const isEditing = mode === 'edit'
+ const isSendDisabled = isEditing || !canRun
+
+ const { messages, steps, isRunning, send, stop } = useInterfaceChat({
+ moduleId: module.id,
+ workflowId,
+ outputConfigs,
+ showThinking,
+ endpoint:
+ source.via === 'share'
+ ? `/api/interfaces/public/${source.token}/modules/${module.id}/chat`
+ : undefined,
+ })
+ const { ref: scrollRef } = useAutoScroll(isRunning)
+
+ /**
+ * Only the workspace arm can tell an unwired module from a wired one by its
+ * config: the share arm is addressed by `(token, moduleId)` and carries no
+ * workflow id at all, because the server pruned every module a visitor could
+ * not run before the layout ever reached this client.
+ */
+ if (source.via === 'workspace' && !workflowId) {
+ if (onConfigChange) {
+ return (
+
+ onConfigChange(
+ module.id,
+ { ...module.config, workflowId: next, outputConfigs: [] },
+ true
+ )
+ }
+ />
+ )
+ }
+ return
+ }
+
+ const welcomeTurn: ChatMessage | null = welcomeMessage.trim()
+ ? {
+ id: 'welcome',
+ content: welcomeMessage,
+ type: 'assistant',
+ timestamp: WELCOME_TIMESTAMP,
+ isInitialMessage: true,
+ }
+ : null
+ const turns = welcomeTurn ? [welcomeTurn, ...messages] : messages
+ const showSteps = showThinking && steps.length > 0
+
+ return (
+
+
+ {turns.length === 0 ? (
+
+ ) : (
+ turns.map((turn, index) => (
+
+ {showSteps && index === turns.length - 1 ? : null}
+
+
+ ))
+ )}
+
+ {/**
+ * The deployed chat's own composer, mounted rather than re-drawn: same
+ * rounded surface, same send affordance, same behaviour — so a module and
+ * a deployed chat cannot drift apart.
+ *
+ * Attachments are offered on the workspace arm only. The token-scoped
+ * chat route still declares a text-only body, so a share would present an
+ * affordance whose files the contract strips — worse than none.
+ *
+ * The caps are lower than the deployed chat's because attachments travel
+ * inline as base64 and `/api/workflows/[id]/execute` caps a body at 10MB;
+ * base64 inflates by a third, so this keeps a full turn inside it.
+ */}
+
+ send(value, files)}
+ isStreaming={isRunning}
+ onStopStreaming={stop}
+ disabled={isSendDisabled}
+ docked={false}
+ allowAttachments={source.via === 'workspace'}
+ maxFileSizeBytes={MODULE_CHAT_MAX_FILE_BYTES}
+ maxFiles={MODULE_CHAT_MAX_FILES}
+ placeholder={
+ isEditing
+ ? 'Chat runs in preview'
+ : canRun
+ ? 'Enter a message...'
+ : 'You do not have access to chat'
+ }
+ />
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/chat-turn.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/chat-turn.tsx
new file mode 100644
index 00000000000..c6ca4657807
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/chat-turn.tsx
@@ -0,0 +1,113 @@
+'use client'
+
+import { memo } from 'react'
+import { cn } from '@sim/emcn'
+import { ChatContent } from '@/components/chat/chat-content'
+import { ChatCopyButton } from '@/components/chat/chat-copy-button'
+import { ChatMessageAttachments } from '@/components/chat/chat-message-attachments'
+import { CHAT_ACTION_ROW_GAP, CHAT_TURN_LAYOUT } from '@/components/chat/turn-layout'
+import type { ChatMessageAttachment } from '@/components/chat/types'
+import { UserMessageContent } from '@/components/chat/user-message-content'
+import { MODULE_GUTTER_X } from '@/components/resources/interface-view/module-chrome'
+import { ChatFileDownload } from '@/app/(interfaces)/chat/components/message/components/file-download'
+import type {
+ ChatAttachment,
+ ChatMessage,
+} from '@/app/(interfaces)/chat/components/message/message'
+
+/** A chat module sits in a canvas cell, so it wears the panel-width rhythm. */
+const TURN = CHAT_TURN_LAYOUT.narrow
+
+/**
+ * A turn's text as markdown. An interface chat's content is a string in every
+ * real case — the run resolver joins its parts before the turn is built — but
+ * the transcript type still admits a raw object, which is fenced rather than
+ * dumped so it lands in the same code block the Sim chat would draw for it.
+ */
+function toMarkdown(content: ChatMessage['content']): string {
+ if (typeof content === 'string') return content
+ return ['```json', JSON.stringify(content, null, 2), '```'].join('\n')
+}
+
+/**
+ * Composer attachments in the shape the shared strip renders.
+ *
+ * A preview is offered only for media the browser can actually paint inline,
+ * and only when the composer pre-read it: everything else falls through to the
+ * named pill, which is the same degradation the Sim chat shows for a document.
+ */
+function toAttachmentStrip(attachments: readonly ChatAttachment[]): ChatMessageAttachment[] {
+ return attachments.map((attachment) => {
+ const isMedia = attachment.type.startsWith('image/') || attachment.type.startsWith('video/')
+ const preview = isMedia && attachment.dataUrl ? attachment.dataUrl : undefined
+ return {
+ id: attachment.id,
+ filename: attachment.name,
+ media_type: attachment.type,
+ size: attachment.size ?? 0,
+ previewUrl: preview,
+ }
+ })
+}
+
+export interface ChatTurnProps {
+ message: ChatMessage
+}
+
+/**
+ * One turn of an interface chat module, drawn with the Sim chat's own
+ * renderers: {@link UserMessageContent} in the shared bubble for what the
+ * visitor sent, {@link ChatContent} for what the workflow streamed back.
+ *
+ * Mounting those rather than re-drawing them is the whole point — markdown,
+ * code highlighting, tables, links, the word-paced stream reveal, and the
+ * bubble's own metrics are then defined once and cannot drift between the two
+ * surfaces.
+ *
+ * `ChatContent`'s `renderSpecialTags` seam is deliberately left empty here: the
+ * special tags are the Sim agent's protocol, and a chat module runs a user's
+ * workflow, which streams markdown and nothing else.
+ */
+export const ChatTurn = memo(function ChatTurn({ message }: ChatTurnProps) {
+ const markdown = toMarkdown(message.content)
+ const attachments = toAttachmentStrip(message.attachments ?? [])
+
+ if (message.type === 'user') {
+ return (
+
+ {attachments.length > 0 && (
+
+ )}
+ {markdown.trim() && (
+
+
+
+ )}
+
+ )
+ }
+
+ const files = message.files ?? []
+
+ return (
+
+
+ {files.length > 0 && (
+
+ {files.map((file) => (
+
+ ))}
+
+ )}
+ {!message.isStreaming && !message.isInitialMessage && markdown.trim() && (
+
+
+
+ )}
+
+ )
+})
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/index.ts
new file mode 100644
index 00000000000..bfa42ad10b8
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/components/chat-turn/index.ts
@@ -0,0 +1 @@
+export { ChatTurn } from './chat-turn'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.test.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.test.tsx
new file mode 100644
index 00000000000..dfc2e2262c4
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.test.tsx
@@ -0,0 +1,342 @@
+/**
+ * @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 { mockExecute, mockCancelExecute } = vi.hoisted(() => ({
+ mockExecute: vi.fn(),
+ mockCancelExecute: vi.fn(),
+}))
+
+vi.mock('@/hooks/use-execution-stream', () => ({
+ useExecutionStream: () => ({
+ execute: mockExecute,
+ cancelExecute: mockCancelExecute,
+ executeFromBlock: vi.fn(),
+ reconnect: vi.fn(),
+ cancel: vi.fn(),
+ cancelReconnect: vi.fn(),
+ }),
+}))
+
+import {
+ type UseInterfaceChatArgs,
+ type UseInterfaceChatResult,
+ useInterfaceChat,
+} from '@/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat'
+import type { InterfaceOutputConfig } from '@/lib/interfaces'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const AGENT_BLOCK = 'block-agent'
+const RESPONSE_BLOCK = 'block-response'
+const MODULE_ID = 'module-1'
+
+let container: HTMLDivElement
+let root: Root
+let latest: UseInterfaceChatResult
+
+function Probe({ args }: { args: UseInterfaceChatArgs }) {
+ latest = useInterfaceChat(args)
+ return null
+}
+
+function render(args: Omit & { moduleId?: string }) {
+ act(() => {
+ root.render( )
+ })
+}
+
+function blockCompleted(blockId: string, output: unknown, executionOrder = 1) {
+ return {
+ blockId,
+ blockName: blockId,
+ blockType: 'agent',
+ output,
+ durationMs: 1,
+ startedAt: '',
+ endedAt: '',
+ executionOrder,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('useInterfaceChat', () => {
+ it('serializes outputConfigs onto the execute payload the way a chat deployment does', async () => {
+ mockExecute.mockResolvedValue(undefined)
+ const outputConfigs: InterfaceOutputConfig[] = [
+ { blockId: AGENT_BLOCK, path: 'content' },
+ { blockId: RESPONSE_BLOCK, path: '' },
+ { blockId: RESPONSE_BLOCK, path: 'data.answer' },
+ ]
+ render({ workflowId: 'wf-1', outputConfigs, showThinking: false })
+
+ await act(async () => {
+ latest.send('hello')
+ })
+
+ expect(mockExecute).toHaveBeenCalledTimes(1)
+ const options = mockExecute.mock.calls[0][0]
+ expect(options.workflowId).toBe('wf-1')
+ expect(options.triggerType).toBe('chat')
+ expect(options.input.input).toBe('hello')
+ expect(typeof options.input.conversationId).toBe('string')
+ expect(options.selectedOutputs).toEqual([
+ `${AGENT_BLOCK}_content`,
+ `${RESPONSE_BLOCK}_content`,
+ `${RESPONSE_BLOCK}_data.answer`,
+ ])
+ })
+
+ it('keys the stream per module so two modules on one workflow do not abort each other', async () => {
+ mockExecute.mockResolvedValue(undefined)
+ render({ moduleId: 'module-b', workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hello')
+ })
+
+ expect(mockExecute.mock.calls[0][0].streamKey).toBe('module-b')
+ })
+
+ it('accumulates streamed chunks into the assistant turn and settles on completion', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onStreamChunk({ blockId: AGENT_BLOCK, chunk: 'Hel' })
+ await callbacks.onStreamChunk({ blockId: AGENT_BLOCK, chunk: 'lo!' })
+ await callbacks.onExecutionCompleted({ success: true, output: {}, duration: 5 })
+ })
+ render({
+ workflowId: 'wf-1',
+ outputConfigs: [{ blockId: AGENT_BLOCK, path: 'content' }],
+ showThinking: false,
+ })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages).toHaveLength(2)
+ expect(latest.messages[0]).toMatchObject({ type: 'user', content: 'hi' })
+ expect(latest.messages[1]).toMatchObject({
+ type: 'assistant',
+ content: 'Hello!',
+ isStreaming: false,
+ })
+ expect(latest.isRunning).toBe(false)
+ })
+
+ it('does not duplicate a selected output whose block already streamed', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onStreamChunk({ blockId: AGENT_BLOCK, chunk: 'Streamed answer' })
+ await callbacks.onBlockCompleted(blockCompleted(AGENT_BLOCK, { content: 'Streamed answer' }))
+ await callbacks.onExecutionCompleted({ success: true, output: {}, duration: 5 })
+ })
+ render({
+ workflowId: 'wf-1',
+ outputConfigs: [{ blockId: AGENT_BLOCK, path: 'content' }],
+ showThinking: false,
+ })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].content).toBe('Streamed answer')
+ })
+
+ it('reads a non-streaming selected output out of the block that produced it', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onBlockCompleted(
+ blockCompleted(RESPONSE_BLOCK, { data: { answer: 'From the response block' } })
+ )
+ await callbacks.onExecutionCompleted({
+ success: true,
+ output: { ignored: true },
+ duration: 5,
+ })
+ })
+ render({
+ workflowId: 'wf-1',
+ outputConfigs: [{ blockId: RESPONSE_BLOCK, path: 'data.answer' }],
+ showThinking: false,
+ })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].content).toBe('From the response block')
+ })
+
+ it('falls back to the execution output when nothing streamed and nothing was selected', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onExecutionCompleted({
+ success: true,
+ output: { content: 'Final output' },
+ duration: 5,
+ })
+ })
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].content).toBe('Final output')
+ })
+
+ it('surfaces a workflow-produced file as a download instead of raw JSON', async () => {
+ const file = {
+ id: 'file-1',
+ name: 'report.pdf',
+ url: 'https://example.com/report.pdf',
+ key: 'ws/report.pdf',
+ size: 1024,
+ type: 'application/pdf',
+ base64: 'AAAA',
+ }
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onBlockCompleted(blockCompleted(RESPONSE_BLOCK, { content: file }))
+ await callbacks.onExecutionCompleted({ success: true, output: {}, duration: 5 })
+ })
+ render({
+ workflowId: 'wf-1',
+ outputConfigs: [{ blockId: RESPONSE_BLOCK, path: 'content' }],
+ showThinking: false,
+ })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].files).toEqual([
+ {
+ id: 'file-1',
+ name: 'report.pdf',
+ url: 'https://example.com/report.pdf',
+ key: 'ws/report.pdf',
+ size: 1024,
+ type: 'application/pdf',
+ context: undefined,
+ },
+ ])
+ })
+
+ it('reports a succeeded run that produced nothing without calling it an error', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onExecutionCompleted({ success: true, output: null, duration: 5 })
+ })
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].content).toBe('_The workflow returned no output._')
+ })
+
+ it('appends an execution error below whatever already streamed', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onStreamChunk({ blockId: AGENT_BLOCK, chunk: 'Partial' })
+ await callbacks.onExecutionError({ error: 'Workflow is not deployed', duration: 1 })
+ })
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1].content).toBe('Partial\n\nWorkflow is not deployed')
+ })
+
+ it('closes out an aborted run with the stop marker', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onStreamChunk({ blockId: AGENT_BLOCK, chunk: 'Half a sen' })
+ })
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(latest.messages[1]).toMatchObject({
+ content: 'Half a sen\n\n_Response stopped by user._',
+ isStreaming: false,
+ })
+ })
+
+ it('tracks per-block progress only when showThinking is on', async () => {
+ mockExecute.mockImplementation(async ({ callbacks }: any) => {
+ await callbacks.onBlockStarted({
+ blockId: AGENT_BLOCK,
+ blockName: 'Agent',
+ blockType: 'agent',
+ executionOrder: 1,
+ })
+ await callbacks.onBlockCompleted(blockCompleted(AGENT_BLOCK, { content: 'x' }))
+ await callbacks.onExecutionCompleted({ success: true, output: {}, duration: 5 })
+ })
+
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+ await act(async () => {
+ latest.send('hi')
+ })
+ expect(latest.steps).toEqual([])
+
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: true })
+ await act(async () => {
+ latest.send('again')
+ })
+ expect(latest.steps).toEqual([{ id: `${AGENT_BLOCK}:1`, label: 'Agent', status: 'completed' }])
+ })
+
+ it('never sends without a wired workflow', async () => {
+ render({ workflowId: null, outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+
+ expect(mockExecute).not.toHaveBeenCalled()
+ expect(latest.messages).toEqual([])
+ })
+
+ it('aborts the in-flight run through the shared stream registry', async () => {
+ let release: (() => void) | undefined
+ mockExecute.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ release = resolve
+ })
+ )
+ render({ workflowId: 'wf-1', outputConfigs: [], showThinking: false })
+
+ await act(async () => {
+ latest.send('hi')
+ })
+ expect(latest.isRunning).toBe(true)
+
+ act(() => {
+ latest.stop()
+ })
+ expect(mockCancelExecute).toHaveBeenCalledWith('wf-1', MODULE_ID)
+
+ await act(async () => {
+ release?.()
+ })
+ expect(latest.isRunning).toBe(false)
+ })
+})
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.ts
new file mode 100644
index 00000000000..ce1f74b7f3e
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/hooks/use-interface-chat.ts
@@ -0,0 +1,449 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { isRecordLike } from '@sim/utils/object'
+import {
+ formatChatOutputValue,
+ resolveChatOutputValue,
+ toChatFileMetadata,
+} from '@/lib/core/utils/chat-outputs'
+import { serializeSelectedOutputs, traverseObjectPath } from '@/lib/core/utils/response-format'
+import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
+import type { InterfaceOutputConfig } from '@/lib/interfaces'
+import type { AttachedFile } from '@/app/(interfaces)/chat/components/input/input'
+import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import { CHAT_ERROR_MESSAGES, CHAT_STOPPED_NOTE } from '@/app/(interfaces)/chat/constants'
+import { toChatFilePayloads } from '@/app/(interfaces)/chat/utils/attachments'
+import { useExecutionStream } from '@/hooks/use-execution-stream'
+
+const logger = createLogger('UseInterfaceChat')
+
+/**
+ * Upper bound on the retained thinking trace. A workflow with loops emits one
+ * `block:started` per iteration, so an uncapped list would grow without limit
+ * for the lifetime of the run.
+ */
+const MAX_THINKING_STEPS = 30
+
+/** Shown when a run succeeds but nothing selected — or produced — anything to display. */
+const NO_OUTPUT_NOTE = '_The workflow returned no output._'
+
+/** One block invocation surfaced while `showThinking` is on. */
+export interface InterfaceChatStep {
+ /** Stable per-invocation key — one block runs many times inside a loop. */
+ id: string
+ label: string
+ status: 'running' | 'completed' | 'failed'
+}
+
+export interface UseInterfaceChatArgs {
+ /**
+ * Identifies this module's run among all runs of the same workflow. Two chat
+ * modules can be wired to one workflow (the natural way to surface two output
+ * selections), and streams are keyed per workflow by default — without a
+ * per-module key each module's run would abort the other's.
+ */
+ moduleId: string
+ /** `null` until a workflow is wired in the properties panel; sending is then a no-op. */
+ workflowId: string | null
+ /** Selected block outputs, mirroring a chat deployment's `outputConfigs`. */
+ outputConfigs: InterfaceOutputConfig[]
+ /** Surfaces per-block progress and every streamed chunk while a run is in flight. */
+ showThinking: boolean
+ /**
+ * Token-scoped execute URL for a public share. When set, the run posts there
+ * instead of `/api/workflows/{workflowId}/execute`: that route derives both
+ * the workflow and the selected outputs from the **stored** layout, so
+ * `selectedOutputs` is deliberately omitted from the body — publicly it would
+ * be a client-controlled output selector over the workflow's blocks. The
+ * local `outputConfigs` still drive rendering.
+ */
+ endpoint?: string
+}
+
+export interface UseInterfaceChatResult {
+ messages: ChatMessage[]
+ /** Empty unless `showThinking` was on for the most recent run. */
+ steps: InterfaceChatStep[]
+ isRunning: boolean
+ send: (text: string, files?: readonly AttachedFile[]) => void
+ stop: () => void
+}
+
+/**
+ * Reads one selected output out of a block's terminal output via the shared
+ * chat resolution, with `traverseObjectPath` as this surface's deep-path
+ * fallback — it parses JSON `content` and materializes large-value refs, which
+ * the deployed chat's plain dot-walk does not.
+ */
+function resolveOutputValue(output: unknown, path: string): unknown {
+ if (!isRecordLike(output)) return output
+ return resolveChatOutputValue(output, path, traverseObjectPath)
+}
+
+/**
+ * Pulls workflow-produced files out of a resolved output so they render as
+ * download chips instead of a JSON dump of their storage metadata.
+ */
+function collectUserFiles(value: unknown): ChatFile[] {
+ if (isUserFileWithMetadata(value)) {
+ return [toChatFileMetadata(value)]
+ }
+ if (!Array.isArray(value)) return []
+ const files: ChatFile[] = []
+ for (const item of value) {
+ if (isUserFileWithMetadata(item)) files.push(toChatFileMetadata(item))
+ }
+ return files
+}
+
+/** Routes one resolved value to either the file list or the markdown parts. */
+function collectValue(value: unknown, parts: string[], files: ChatFile[]): void {
+ const collected = collectUserFiles(value)
+ if (collected.length > 0) {
+ files.push(...collected)
+ return
+ }
+ const formatted = formatChatOutputValue(value)
+ if (formatted?.trim()) parts.push(formatted)
+}
+
+interface ResolveAssistantContentArgs {
+ /** Concatenated `stream:chunk` text, in arrival order. */
+ streamedText: string
+ outputConfigs: readonly InterfaceOutputConfig[]
+ /** Terminal output of every block referenced by `outputConfigs`. */
+ blockOutputs: ReadonlyMap
+ /** Blocks whose content already reached the user as chunks. */
+ streamedBlockIds: ReadonlySet
+ /** `execution:completed`/`execution:paused` output, used when nothing else resolved. */
+ fallbackOutput: unknown
+}
+
+/**
+ * Builds the assistant turn from a finished run, mirroring the deployed chat's
+ * `buildMinimalResult` + client formatting: streamed text first, then every
+ * selected output whose block did **not** stream (its content would otherwise
+ * be duplicated), and the raw execution output only when neither produced
+ * anything.
+ */
+function resolveAssistantContent({
+ streamedText,
+ outputConfigs,
+ blockOutputs,
+ streamedBlockIds,
+ fallbackOutput,
+}: ResolveAssistantContentArgs): { content: string; files: ChatFile[] } {
+ const parts: string[] = []
+ const files: ChatFile[] = []
+
+ const streamed = streamedText.trim()
+ if (streamed) parts.push(streamed)
+
+ for (const config of outputConfigs) {
+ if (streamedBlockIds.has(config.blockId)) continue
+ if (!blockOutputs.has(config.blockId)) continue
+ collectValue(resolveOutputValue(blockOutputs.get(config.blockId), config.path), parts, files)
+ }
+
+ if (parts.length === 0 && files.length === 0) {
+ /**
+ * The terminal event carries the last block's normalized output, so it is
+ * unwrapped on the same default path a selected output uses — otherwise a
+ * plain `{ content: '...' }` answer would render as a JSON code block.
+ */
+ collectValue(resolveOutputValue(fallbackOutput, 'content'), parts, files)
+ }
+
+ return { content: parts.join('\n\n'), files }
+}
+
+/** Joins non-empty markdown fragments with a blank line between them. */
+function joinParts(parts: Array): string {
+ return parts
+ .map((part) => part?.trim())
+ .filter((part): part is string => Boolean(part))
+ .join('\n\n')
+}
+
+function appendStep(steps: InterfaceChatStep[], step: InterfaceChatStep): InterfaceChatStep[] {
+ const next = [...steps, step]
+ return next.length > MAX_THINKING_STEPS ? next.slice(next.length - MAX_THINKING_STEPS) : next
+}
+
+function markStep(
+ steps: InterfaceChatStep[],
+ id: string,
+ status: InterfaceChatStep['status']
+): InterfaceChatStep[] {
+ return steps.map((step) => (step.id === id ? { ...step, status } : step))
+}
+
+/**
+ * Runs a chat-module turn against the workspace workflow wired to the module.
+ *
+ * Reuses {@link useExecutionStream} — the shared consumer of
+ * `POST /api/workflows/[id]/execute`'s SSE stream — so the interface chat runs
+ * a workflow through exactly the same path as the workflow editor: the same
+ * route, the same `triggerType: 'chat'` payload, the same
+ * `selectedOutputs` serialization a chat deployment uses, and the same
+ * `ExecutionEvent` decoding. Nothing about the stream is reimplemented here;
+ * this hook only maps those events onto chat turns.
+ *
+ * Transcript state is deliberately local and ephemeral — an interface chat has
+ * no server-side conversation record, so nothing here belongs in React Query,
+ * a store, or the URL.
+ *
+ * On a public share the only difference on *this* side is `endpoint`: the run
+ * posts to the token-scoped route and sends no output selection. The server
+ * side is a different route, so the surfaces can and do drift — the shared
+ * client here decodes only the typed `ExecutionEvent` dialect, and a route that
+ * streams the deployed chat's `{ blockId, chunk }` / `{ event: 'final' }`
+ * dialect instead has every frame silently dropped, ending the turn on the
+ * unsettled-stream fallback below. The token-scoped route is pinned to this
+ * dialect by a decode test beside it; keep it that way rather than teaching
+ * this decoder a second wire format.
+ */
+export function useInterfaceChat({
+ moduleId,
+ workflowId,
+ outputConfigs,
+ showThinking,
+ endpoint,
+}: UseInterfaceChatArgs): UseInterfaceChatResult {
+ const { execute, cancelExecute } = useExecutionStream()
+
+ const [messages, setMessages] = useState([])
+ const [steps, setSteps] = useState([])
+ const [isRunning, setIsRunning] = useState(false)
+
+ /** One conversation id per mounted module, so multi-turn context stays coherent. */
+ const conversationIdRef = useRef(null)
+ conversationIdRef.current ??= generateId()
+
+ const moduleIdRef = useRef(moduleId)
+ const workflowIdRef = useRef(workflowId)
+ const outputConfigsRef = useRef(outputConfigs)
+ const showThinkingRef = useRef(showThinking)
+ const endpointRef = useRef(endpoint)
+ /** Guards against a second turn starting while one is still streaming. */
+ const runningRef = useRef(false)
+ /** Workflow of the in-flight run — the key `cancelExecute` aborts on. */
+ const runWorkflowIdRef = useRef(null)
+
+ useEffect(() => {
+ moduleIdRef.current = moduleId
+ workflowIdRef.current = workflowId
+ outputConfigsRef.current = outputConfigs
+ showThinkingRef.current = showThinking
+ endpointRef.current = endpoint
+ }, [moduleId, workflowId, outputConfigs, showThinking, endpoint])
+
+ useEffect(
+ () => () => {
+ const active = runWorkflowIdRef.current
+ if (active) cancelExecute(active, moduleIdRef.current)
+ },
+ [cancelExecute]
+ )
+
+ const stop = useCallback(() => {
+ const active = runWorkflowIdRef.current
+ if (active) cancelExecute(active, moduleIdRef.current)
+ }, [cancelExecute])
+
+ const send = useCallback(
+ async (rawText: string, files?: readonly AttachedFile[]) => {
+ const text = rawText.trim()
+ const activeEndpoint = endpointRef.current
+ /**
+ * A token-scoped run has no workflow id to send — the endpoint derives it
+ * from the stored layout — so the module id stands in as the stream
+ * identity `cancelExecute` aborts on.
+ */
+ const runId = activeEndpoint ? moduleIdRef.current : workflowIdRef.current
+ /** A turn carrying only attachments is a real turn — the composer allows it. */
+ if ((!text && !files?.length) || !runId || runningRef.current) return
+
+ const selectedOutputConfigs = outputConfigsRef.current
+ const assistantId = generateId()
+
+ runningRef.current = true
+ runWorkflowIdRef.current = runId
+ setMessages((previous) => [
+ ...previous,
+ {
+ id: generateId(),
+ content: text,
+ type: 'user',
+ timestamp: new Date(),
+ attachments: files?.map((file) => ({
+ id: file.id,
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ dataUrl: file.dataUrl ?? '',
+ })),
+ },
+ {
+ id: assistantId,
+ content: '',
+ type: 'assistant',
+ timestamp: new Date(),
+ isStreaming: true,
+ },
+ ])
+ setSteps([])
+ setIsRunning(true)
+
+ let streamedText = ''
+ const streamedBlockIds = new Set()
+ const blockOutputs = new Map()
+ /** Only blocks feeding a selected output are retained — the rest are dropped. */
+ const wantedBlockIds = new Set(selectedOutputConfigs.map((config) => config.blockId))
+ let settled = false
+ let frame: number | null = null
+
+ const cancelFlush = () => {
+ if (frame === null) return
+ cancelAnimationFrame(frame)
+ frame = null
+ }
+
+ /**
+ * Chunks arrive far faster than the browser paints, so the growing text is
+ * committed once per frame instead of once per token.
+ */
+ const scheduleFlush = () => {
+ if (frame !== null) return
+ frame = requestAnimationFrame(() => {
+ frame = null
+ const snapshot = streamedText
+ setMessages((previous) =>
+ previous.map((message) =>
+ message.id === assistantId && message.isStreaming
+ ? { ...message, content: snapshot }
+ : message
+ )
+ )
+ })
+ }
+
+ const settle = (content: string, files?: ChatFile[]) => {
+ settled = true
+ cancelFlush()
+ setMessages((previous) =>
+ previous.map((message) =>
+ message.id === assistantId
+ ? { ...message, content, isStreaming: false, files }
+ : message
+ )
+ )
+ }
+
+ const settleFromOutput = (fallbackOutput: unknown, succeeded: boolean) => {
+ const { content, files } = resolveAssistantContent({
+ streamedText,
+ outputConfigs: selectedOutputConfigs,
+ blockOutputs,
+ streamedBlockIds,
+ fallbackOutput,
+ })
+ if (content || files.length > 0) {
+ settle(content, files.length > 0 ? files : undefined)
+ return
+ }
+ settle(succeeded ? NO_OUTPUT_NOTE : CHAT_ERROR_MESSAGES.GENERIC_ERROR)
+ }
+
+ /**
+ * Read here rather than in the composer: only images arrive pre-read, so
+ * every other attachment is turned into its base64 payload at send time.
+ */
+ const filePayloads = await toChatFilePayloads(files)
+
+ try {
+ await execute({
+ workflowId: runId,
+ endpoint: activeEndpoint,
+ streamKey: moduleIdRef.current,
+ triggerType: 'chat',
+ input: {
+ input: text,
+ conversationId: conversationIdRef.current,
+ ...(filePayloads.length > 0 ? { files: filePayloads } : {}),
+ },
+ ...(activeEndpoint
+ ? {}
+ : { selectedOutputs: serializeSelectedOutputs(selectedOutputConfigs) }),
+ callbacks: {
+ onBlockStarted: (data) => {
+ if (!showThinkingRef.current) return
+ setSteps((previous) =>
+ appendStep(previous, {
+ id: `${data.blockId}:${data.executionOrder}`,
+ label: data.blockName,
+ status: 'running',
+ })
+ )
+ },
+ onBlockCompleted: (data) => {
+ if (wantedBlockIds.has(data.blockId)) blockOutputs.set(data.blockId, data.output)
+ if (!showThinkingRef.current) return
+ setSteps((previous) =>
+ markStep(previous, `${data.blockId}:${data.executionOrder}`, 'completed')
+ )
+ },
+ onBlockError: (data) => {
+ if (!showThinkingRef.current) return
+ setSteps((previous) =>
+ markStep(previous, `${data.blockId}:${data.executionOrder}`, 'failed')
+ )
+ },
+ onStreamChunk: (data) => {
+ streamedBlockIds.add(data.blockId)
+ streamedText += data.chunk
+ scheduleFlush()
+ },
+ onExecutionCompleted: (data) => settleFromOutput(data.output, data.success),
+ /**
+ * A human-in-the-loop pause is terminal for the chat turn: whatever
+ * ran before the pause is the answer, exactly as a chat deployment
+ * treats it.
+ */
+ onExecutionPaused: (data) => settleFromOutput(data.output, true),
+ onExecutionError: (data) => {
+ settle(joinParts([streamedText, data.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR]))
+ },
+ onExecutionCancelled: () => {
+ settle(joinParts([streamedText, CHAT_STOPPED_NOTE]))
+ },
+ },
+ })
+ } catch (error) {
+ logger.error('Interface chat run failed', { error })
+ if (!settled) {
+ settle(
+ joinParts([streamedText, getErrorMessage(error, CHAT_ERROR_MESSAGES.GENERIC_ERROR)])
+ )
+ }
+ } finally {
+ cancelFlush()
+ /**
+ * A user-initiated abort resolves the stream without a terminal event,
+ * so the partial turn is closed out here rather than left streaming.
+ */
+ if (!settled) settle(joinParts([streamedText, CHAT_STOPPED_NOTE]))
+ runningRef.current = false
+ runWorkflowIdRef.current = null
+ setIsRunning(false)
+ }
+ },
+ [execute]
+ )
+
+ return { messages, steps, isRunning, send, stop }
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/index.ts
new file mode 100644
index 00000000000..3baafd9b681
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/chat-module/index.ts
@@ -0,0 +1,2 @@
+export type { ChatModuleProps } from './chat-module'
+export { ChatModule } from './chat-module'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/file-module.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/file-module.tsx
new file mode 100644
index 00000000000..ac3d7769fb9
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/file-module.tsx
@@ -0,0 +1,119 @@
+'use client'
+
+import { lazy, Suspense, useMemo } from 'react'
+import { Skeleton } from '@sim/emcn'
+import { File as FileIcon } from '@sim/emcn/icons'
+import { ModuleResourcePicker } from '@/components/resources/interface-view/components/module-renderer/components/module-resource-picker'
+import { interfaceModuleSeed } from '@/components/resources/interface-view/interface-scope'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
+import type { InterfaceModule } from '@/lib/interfaces/types'
+import { type ResourceGrants, type ResourceSource, shareSource, workspaceSource } from '@/resources'
+
+/**
+ * The view pulls in pdf.js, the docx renderer, the xlsx parser, and the pptx
+ * sandbox host — none of which this route needs until a file module actually
+ * resolves a file. Imported by its deep path rather than the file-view barrel so
+ * webpack cannot re-attach those to the interfaces chunk
+ * (`.claude/rules/sim-imports.md`, "Code-splitting through barrels").
+ */
+const FileView = lazy(() =>
+ import('@/components/resources/file-view/file-view').then((module) => ({
+ default: module.FileView,
+ }))
+)
+
+/** A file module presents its file. It never offers to change it, in any scope. */
+const FILE_MODULE_GRANTS: ResourceGrants = { write: false, run: false }
+
+export interface FileModuleProps {
+ module: Extract
+ /**
+ * Present only where this module may bind its own file — see
+ * `ModuleRenderer`. There is no `mode` here: a file renders identically in
+ * every scope, so the only thing edit mode ever changed was whether the
+ * module could be bound, which is exactly what this prop's presence says.
+ */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+/** Placeholder for the view's code-split load. */
+function FileModuleSkeleton() {
+ return (
+
+
+
+
+ )
+}
+
+/**
+ * Renders one workspace file with the same view the Files surface uses — PDFs,
+ * images, docx, xlsx, pptx, markdown, CSV, and code all paint their real
+ * contents rather than a card standing in for them.
+ *
+ * On a public share the interface's own share source already carries the file's
+ * server-resolved, server-authorized metadata, so the module mints a child file
+ * share source addressed by `(token, moduleId)` — the identical view works
+ * anonymously without ever touching a workspace-authenticated URL. `fileId` is
+ * read only on the workspace arm; the share page strips it from every module
+ * config before the layout crosses to the browser, so there is no file id in
+ * scope to leak.
+ *
+ * An unbound module authors itself: given `onConfigChange` it renders the same
+ * chooser column the empty cell offered a moment earlier, so picking the file
+ * happens where the module is rather than in the inspector.
+ */
+export function FileModule({ module, onConfigChange }: FileModuleProps) {
+ const { source: interfaceSource } = useResourceOfKind('interface')
+ const { fileId } = module.config
+ const moduleId = module.id
+
+ const workspaceId = interfaceSource.via === 'workspace' ? interfaceSource.workspaceId : null
+ const token = interfaceSource.via === 'share' ? interfaceSource.token : null
+ const moduleSeed = interfaceModuleSeed(interfaceSource, moduleId)
+ const fileSeed = moduleSeed?.kind === 'file' ? moduleSeed.seed : null
+
+ const source = useMemo | null>(() => {
+ if (token !== null) {
+ if (!fileSeed) return null
+ return shareSource({ kind: 'file', token, grantId: moduleId, seed: fileSeed })
+ }
+ if (!workspaceId || !fileId) return null
+ return workspaceSource({ kind: 'file', workspaceId, resourceId: fileId })
+ }, [token, fileSeed, moduleId, workspaceId, fileId])
+
+ if (!source) {
+ if (workspaceId && onConfigChange) {
+ return (
+ onConfigChange(moduleId, { fileId: next }, true)}
+ />
+ )
+ }
+ return (
+
+ )
+ }
+
+ return (
+
+ }>
+
+
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/index.ts
new file mode 100644
index 00000000000..c181b149f51
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/file-module/index.ts
@@ -0,0 +1,2 @@
+export type { FileModuleProps } from './file-module'
+export { FileModule } from './file-module'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/form-field-control.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/form-field-control.tsx
new file mode 100644
index 00000000000..5f78f57f957
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/form-field-control.tsx
@@ -0,0 +1,261 @@
+'use client'
+
+import { useId } from 'react'
+import {
+ Chip,
+ ChipInput,
+ ChipSelect,
+ type ChipSelectOption,
+ ChipTextarea,
+ chipContentGap,
+ chipContentIconClass,
+ chipFieldTextClass,
+ cn,
+ Label,
+ Switch,
+} from '@sim/emcn'
+import { GripVertical, X } from '@sim/emcn/icons'
+import type { FormField } from '@/lib/interfaces'
+import { INTERFACE_LAYOUT_LIMITS } from '@/lib/interfaces/constants'
+
+/** Shown by a field that has no placeholder of its own. */
+const DEFAULT_FIELD_PLACEHOLDER = 'Type something...'
+
+/**
+ * The 2px optical inset a field's title carries, authored or rendered. Declared
+ * once because the whole point is that the two agree — a drift here shows up as
+ * the title jumping sideways when the builder toggles edit → preview.
+ */
+const LABEL_INSET = 'pl-0.5'
+
+/**
+ * The label editor while authoring: a bare input carrying the chip field's
+ * geometry and typography but none of its surface, so the title reads as the
+ * label it will become rather than as one more form control. Composed from chip
+ * tokens — `h-[30px]` is `chipGeometryClass`'s own height — so it keeps the
+ * footprint of the control it replaced.
+ *
+ * `pl-0.5` is {@link LABEL_INSET}, the same 2px the rendered `` below
+ * carries: the authored title and the shipped label then sit on exactly the
+ * same x, so toggling edit → preview does not nudge the text sideways.
+ */
+const INVISIBLE_TITLE_INPUT_CLASS = cn(
+ 'h-[30px] w-full min-w-0 flex-1 bg-transparent pr-2',
+ chipFieldTextClass,
+ LABEL_INSET
+)
+
+/**
+ * Distinct, non-empty choices for a dropdown field. Empty and duplicate
+ * options are dropped because the select keys its rows by value; both are
+ * transient states the builder can produce mid-edit.
+ */
+function toSelectOptions(options: readonly string[] | undefined): ChipSelectOption[] {
+ const choices: ChipSelectOption[] = []
+ const seen = new Set()
+ for (const option of options ?? []) {
+ if (option.length === 0 || seen.has(option)) continue
+ seen.add(option)
+ choices.push({ value: option, label: option })
+ }
+ return choices
+}
+
+/**
+ * The authoring affordances a field carries when the builder is editing the
+ * form on the canvas. Present only in edit mode for a viewer who can write, so
+ * a filled-in form never renders them rather than rendering them disabled.
+ *
+ * Deliberately narrow: the label is the one property worth editing where the
+ * field is *seen*, because it is the only one a visitor reads. Type, name,
+ * placeholder, hint, required, options, and default are invisible on the
+ * rendered field and stay in the inspector, which carries all of them.
+ *
+ * Reordering is not in here: the row is dragged, exactly like a module on the
+ * canvas, and the surrounding list owns that wiring via `useDragReorder`.
+ */
+export interface FormFieldEditing {
+ onLabelChange: (label: string) => void
+ onRemove: () => void
+}
+
+export interface FormFieldControlProps {
+ field: FormField
+ value: string | boolean | undefined
+ onChange: (value: string | boolean) => void
+ error?: string
+ disabled?: boolean
+ /** Authoring affordances — absent means this is a field being filled in, not authored. */
+ editing?: FormFieldEditing
+}
+
+/**
+ * One rendered form field — label, control, and the hint/error line beneath
+ * it. Uses the chip-field rhythm (`gap-[9px]`, muted normal-weight label,
+ * `text-caption` message) so an interface form reads exactly like every other
+ * labeled field surface in the app.
+ *
+ * While authoring, the static label becomes an editable title — a bare input
+ * with no surface of its own, so the row still reads as a label rather than as
+ * a second form control — flanked by a drag grip and a remove chip. The control
+ * beneath is the real one either way — a builder sees the input a visitor will
+ * see, at the size they will see it — but it is inert while authoring because
+ * edit mode never submits.
+ */
+export function FormFieldControl({
+ field,
+ value,
+ onChange,
+ error,
+ disabled,
+ editing,
+}: FormFieldControlProps) {
+ const id = useId()
+ const errorId = `${id}-error`
+ const hintId = `${id}-hint`
+
+ const selectOptions = toSelectOptions(field.options)
+
+ const aria = {
+ 'aria-required': field.required || undefined,
+ 'aria-invalid': Boolean(error) || undefined,
+ 'aria-describedby': error ? errorId : field.hint ? hintId : undefined,
+ } as const
+
+ const fieldName = field.label.trim() || field.name.trim() || 'Untitled field'
+
+ const label = editing ? (
+
+ {/**
+ * The title leads the row, at the same inset the rendered label uses —
+ * the grip sat here first and pushed the whole title out of line with the
+ * field it names.
+ */}
+ editing.onLabelChange(event.target.value)}
+ placeholder='Field label'
+ maxLength={INTERFACE_LAYOUT_LIMITS.MAX_FIELD_LABEL_LENGTH}
+ aria-label={`Label for ${fieldName}`}
+ className={INVISIBLE_TITLE_INPUT_CLASS}
+ />
+
+ {/**
+ * `type='submit'` is the HTML default, and this sits inside the rendered
+ * `
+ ) : (
+
+ {field.label}
+ {field.required && (
+
+ *
+
+ )}
+
+ )
+
+ const message = error ? (
+
+ {error}
+
+ ) : field.hint ? (
+
+ {field.hint}
+
+ ) : null
+
+ /**
+ * Authoring puts the label input on its own row, so the switch drops out of
+ * its inline layout and stacks like every other type — one shape to reason
+ * about while composing.
+ */
+ if (field.type === 'switch' && !editing) {
+ return (
+
+
+ {label}
+ onChange(next === true)}
+ disabled={disabled}
+ {...aria}
+ />
+
+ {message}
+
+ )
+ }
+
+ const textValue = typeof value === 'string' ? value : ''
+
+ return (
+
+ {label}
+ {field.type === 'switch' ? (
+ onChange(next === true)}
+ disabled={disabled}
+ {...aria}
+ />
+ ) : field.type === 'long-text' ? (
+ onChange(event.target.value)}
+ placeholder={field.placeholder || DEFAULT_FIELD_PLACEHOLDER}
+ maxLength={INTERFACE_LAYOUT_LIMITS.MAX_FORM_VALUE_LENGTH}
+ error={Boolean(error)}
+ disabled={disabled}
+ {...aria}
+ />
+ ) : field.type === 'dropdown' ? (
+
+ ) : (
+ onChange(event.target.value)}
+ placeholder={field.placeholder || DEFAULT_FIELD_PLACEHOLDER}
+ maxLength={INTERFACE_LAYOUT_LIMITS.MAX_FORM_VALUE_LENGTH}
+ error={Boolean(error)}
+ disabled={disabled}
+ {...aria}
+ />
+ )}
+ {message}
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/index.ts
new file mode 100644
index 00000000000..312676cb072
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control/index.ts
@@ -0,0 +1,2 @@
+export type { FormFieldControlProps } from './form-field-control'
+export { FormFieldControl } from './form-field-control'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.test.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.test.tsx
new file mode 100644
index 00000000000..e467a311b65
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.test.tsx
@@ -0,0 +1,879 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ApiClientError } from '@/lib/api/client/errors'
+
+const {
+ mockWorkspaceMutate,
+ mockShareMutate,
+ mockUseSubmitInterfaceForm,
+ mockUseSubmitPublicInterfaceForm,
+ mockUseWorkflows,
+} = vi.hoisted(() => ({
+ mockWorkspaceMutate: vi.fn(),
+ mockShareMutate: vi.fn(),
+ mockUseSubmitInterfaceForm: vi.fn(),
+ mockUseSubmitPublicInterfaceForm: vi.fn(),
+ mockUseWorkflows: vi.fn(() => ({ data: [{ id: 'wf-1', name: 'Triage' }], isLoading: false })),
+}))
+
+vi.mock('@/hooks/queries/interfaces', () => ({
+ useSubmitInterfaceForm: mockUseSubmitInterfaceForm,
+}))
+
+vi.mock('@/hooks/queries/workflows', () => ({
+ useWorkflows: mockUseWorkflows,
+}))
+
+vi.mock('@/hooks/queries/public-interfaces', () => ({
+ useSubmitPublicInterfaceForm: mockUseSubmitPublicInterfaceForm,
+}))
+
+import { FormModule } from '@/components/resources/interface-view/components/module-renderer/components/form-module'
+import { ResourceProvider } from '@/components/resources/resource-provider'
+import type {
+ FormField,
+ FormFieldType,
+ InterfaceLayout,
+ InterfaceMode,
+ InterfaceModule,
+} from '@/lib/interfaces/types'
+import { type ResourceGrants, type ResourceSource, shareSource, workspaceSource } from '@/resources'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+/** The switch measures its thumb through Radix's `useSize`; jsdom ships no observer. */
+class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+;(globalThis as { ResizeObserver?: unknown }).ResizeObserver ??= ResizeObserverStub
+
+const WORKSPACE_ID = 'ws-1'
+const INTERFACE_ID = 'if-1'
+const TOKEN = 'tok-1'
+const MODULE_ID = 'm-form'
+
+const EMPTY_LAYOUT: InterfaceLayout = { version: 1, grid: { rows: 1, cols: 1 }, modules: [] }
+
+const WORKSPACE_SOURCE: ResourceSource<'interface'> = workspaceSource({
+ kind: 'interface',
+ workspaceId: WORKSPACE_ID,
+ resourceId: INTERFACE_ID,
+})
+
+const SHARE_SOURCE: ResourceSource<'interface'> = shareSource({
+ kind: 'interface',
+ token: TOKEN,
+ grantId: TOKEN,
+ seed: { name: 'Shared', layout: EMPTY_LAYOUT, modules: {} },
+})
+
+const GRANTS: ResourceGrants = { write: true, run: true }
+
+/** The subset of a TanStack mutation `FormModule` reads, mutable per test. */
+interface MutationState {
+ mutate: typeof mockWorkspaceMutate
+ isPending: boolean
+ isSuccess: boolean
+ isError: boolean
+ error: Error | null
+ reset: () => void
+}
+
+let workspaceMutation: MutationState
+let shareMutation: MutationState
+let container: HTMLDivElement
+let root: Root
+let rerender: () => void
+
+function mutationState(mutate: typeof mockWorkspaceMutate): MutationState {
+ const state: MutationState = {
+ mutate,
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ error: null,
+ reset: () => {},
+ }
+ /** Mirrors TanStack's `reset()` — the status flags go back to idle. */
+ state.reset = vi.fn(() => {
+ state.isSuccess = false
+ state.isError = false
+ state.error = null
+ })
+ return state
+}
+
+function field(id: string, type: FormFieldType, overrides: Partial = {}): FormField {
+ return { id, name: id, label: id, type, required: false, ...overrides }
+}
+
+function formModule(
+ fields: FormField[],
+ config: Partial['config']> = {}
+): Extract {
+ return {
+ id: MODULE_ID,
+ type: 'form',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { workflowId: 'wf-1', fields, submitLabel: 'Submit', ...config },
+ }
+}
+
+interface RenderOptions {
+ mode?: InterfaceMode
+ canRun?: boolean
+ source?: ResourceSource<'interface'>
+ /** Present = this surface may author the module, exactly as the canvas decides. */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+function render(
+ module: Extract,
+ { mode = 'preview', canRun = true, source = WORKSPACE_SOURCE, onConfigChange }: RenderOptions = {}
+) {
+ rerender = () => {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+ }
+ rerender()
+}
+
+function labelFor(text: string): HTMLLabelElement {
+ const found = [...container.querySelectorAll('label')].find(
+ (node) => node.textContent?.replace('*', '').trim() === text
+ )
+ if (!found) throw new Error(`No field labeled "${text}"`)
+ return found
+}
+
+/** The control the field's label points at — i.e. the one it names. */
+function controlFor(text: string): HTMLElement {
+ const control = document.getElementById(labelFor(text).htmlFor)
+ if (!control) throw new Error(`Field "${text}" has no labelled control`)
+ return control
+}
+
+function textField(text: string): HTMLInputElement | HTMLTextAreaElement {
+ return controlFor(text) as HTMLInputElement | HTMLTextAreaElement
+}
+
+/** The dropdown's trigger, found by the accessible name `FormFieldControl` gives it. */
+function dropdown(label: string): HTMLButtonElement {
+ const trigger = container.querySelector(`[aria-label="${label}"]`)
+ if (!trigger) throw new Error(`No dropdown labelled "${label}"`)
+ return trigger as HTMLButtonElement
+}
+
+/** The message a control announces through `aria-describedby`, with its role. */
+function messageFor(text: string): { role: string | null; text: string } | null {
+ const describedBy = controlFor(text).getAttribute('aria-describedby')
+ if (!describedBy) return null
+ const node = document.getElementById(describedBy)
+ if (!node) return null
+ return { role: node.getAttribute('role'), text: node.textContent ?? '' }
+}
+
+/** The form-level alert — the one alert no control claims as its description. */
+function formAlert(): string | null {
+ const described = new Set(
+ [...container.querySelectorAll('[aria-describedby]')].map((node) =>
+ node.getAttribute('aria-describedby')
+ )
+ )
+ const alert = [...container.querySelectorAll('[role="alert"]')].find(
+ (node) => !described.has(node.id)
+ )
+ return alert?.textContent ?? null
+}
+
+function submitButton(): HTMLButtonElement {
+ const button = [...container.querySelectorAll('button')].find((node) => node.type === 'submit')
+ if (!button) throw new Error('No submit button')
+ return button
+}
+
+/** The authoring affordances are named, not labelled — they replace the ``. */
+function buttonLabeled(name: string): HTMLButtonElement {
+ const button = [...container.querySelectorAll('button')].find(
+ (node) => node.getAttribute('aria-label') === name || node.textContent?.trim() === name
+ )
+ if (!button) throw new Error(`No button named "${name}"`)
+ return button
+}
+
+function inputLabeled(name: string): HTMLInputElement {
+ const input = container.querySelector(`input[aria-label="${name}"]`)
+ if (!input) throw new Error(`No input named "${name}"`)
+ return input
+}
+
+/** The reorderable field rows — only present while the surface may author. */
+function draggableRows(): HTMLElement[] {
+ return [...container.querySelectorAll('[draggable="true"]')]
+}
+
+/**
+ * Plays a full HTML5 drag from one row to another. jsdom builds no
+ * `DragEvent`, so the events carry a hand-rolled `dataTransfer` — the same
+ * three fields the hook actually reads.
+ */
+function dragRowOnto(from: number, to: number) {
+ const rows = draggableRows()
+ const dataTransfer = { effectAllowed: '', dropEffect: '', setData: () => {}, getData: () => '' }
+ const fire = (node: HTMLElement, type: string) => {
+ const event = new Event(type, { bubbles: true, cancelable: true })
+ Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
+ act(() => {
+ node.dispatchEvent(event)
+ })
+ }
+ fire(rows[from], 'dragstart')
+ fire(rows[to], 'dragover')
+ fire(rows[to], 'drop')
+}
+
+function typeInto(control: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
+ setter?.call(control, value)
+ act(() => {
+ control.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function type(text: string, value: string) {
+ const control = textField(text)
+ const prototype =
+ control.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+ setter?.call(control, value)
+ act(() => {
+ control.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+}
+
+function submit() {
+ act(() => {
+ submitButton().click()
+ })
+}
+
+/** Values the active source's mutation was asked to submit. */
+function submittedValues(mutate: typeof mockWorkspaceMutate): Record {
+ return (mutate.mock.calls.at(-1)?.[0] as { values: Record }).values
+}
+
+/**
+ * Drives the mutation's error path the way TanStack does: the mutation is
+ * already failed by the time the per-call `onError` runs.
+ */
+function fail(mutation: MutationState, mutate: typeof mockWorkspaceMutate, error: Error) {
+ mutation.isError = true
+ mutation.error = error
+ act(() => {
+ const options = mutate.mock.calls.at(-1)?.[1] as { onError?: (error: unknown) => void }
+ options?.onError?.(error)
+ })
+ rerender()
+}
+
+function succeed(mutation: MutationState, mutate: typeof mockWorkspaceMutate) {
+ mutation.isSuccess = true
+ act(() => {
+ const options = mutate.mock.calls.at(-1)?.[1] as { onSuccess?: () => void }
+ options?.onSuccess?.()
+ })
+ rerender()
+}
+
+/** A 400 from the submit route: `{ error, details: FormSubmissionFieldError[] }`. */
+function rejection(details: unknown): ApiClientError {
+ return new ApiClientError({
+ status: 400,
+ message: 'Invalid submission',
+ body: { error: 'Invalid submission', details },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ workspaceMutation = mutationState(mockWorkspaceMutate)
+ shareMutation = mutationState(mockShareMutate)
+ mockUseSubmitInterfaceForm.mockImplementation(() => workspaceMutation)
+ mockUseSubmitPublicInterfaceForm.mockImplementation(() => shareMutation)
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('FormModule — unconfigured', () => {
+ it('binds its own workflow from the module while authoring', () => {
+ render(formModule([field('a', 'short-text')], { workflowId: null }), {
+ mode: 'edit',
+ onConfigChange: vi.fn(),
+ })
+ expect(container.textContent).toContain('Select a workflow')
+ })
+
+ /**
+ * `onConfigChange` is what the canvas withholds from a read-only member, so
+ * its absence must never leave a picker the viewer cannot act on.
+ */
+ it('falls back to the unavailable copy without an authoring callback', () => {
+ render(formModule([field('a', 'short-text')], { workflowId: null }), { mode: 'edit' })
+ expect(container.textContent).toContain('This form is not available.')
+ expect(container.textContent).not.toContain('Select a workflow')
+ })
+
+ it('tells a visitor the form is unavailable instead of naming the editor', () => {
+ render(formModule([field('a', 'short-text')], { workflowId: null }))
+ expect(container.textContent).toContain('This form is not available.')
+ expect(container.textContent).not.toContain('properties panel')
+ })
+
+ it('offers to add the first field on the module while authoring', () => {
+ const onConfigChange = vi.fn()
+ render(formModule([]), { mode: 'edit', onConfigChange })
+
+ const addField = buttonLabeled('Add field')
+ act(() => addField.click())
+
+ const [moduleId, config, isValid] = onConfigChange.mock.calls[0]
+ expect(moduleId).toBe(MODULE_ID)
+ expect((config as { fields: FormField[] }).fields).toHaveLength(1)
+ expect(isValid).toBe(true)
+ })
+
+ it('offers no add-field affordance without an authoring callback', () => {
+ render(formModule([]), { mode: 'edit' })
+ expect(container.textContent).toContain('This form is not available.')
+ expect(container.textContent).not.toContain('Add field')
+ })
+
+ it('tells a visitor a field-less form is unavailable', () => {
+ render(formModule([]))
+ expect(container.textContent).toContain('This form is not available.')
+ expect(container.textContent).not.toContain('properties panel')
+ })
+})
+
+describe('FormModule — field rendering', () => {
+ const fields = [
+ field('short', 'short-text', { label: 'Name' }),
+ field('long', 'long-text', { label: 'Bio' }),
+ field('drop', 'dropdown', { label: 'Country', options: ['Germany', 'Japan'] }),
+ field('flag', 'switch', { label: 'Subscribe' }),
+ ]
+
+ it('renders one control per field type', () => {
+ render(formModule(fields))
+
+ expect(textField('Name').tagName).toBe('INPUT')
+ expect(textField('Bio').tagName).toBe('TEXTAREA')
+ expect(dropdown('Country').textContent).toContain('Select an option')
+ expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('false')
+ })
+
+ it('seeds every control from its default value', () => {
+ render(
+ formModule([
+ field('short', 'short-text', { label: 'Name', defaultValue: 'Ada' }),
+ field('long', 'long-text', { label: 'Bio', defaultValue: 'Mathematician' }),
+ field('drop', 'dropdown', {
+ label: 'Country',
+ options: ['Germany', 'Japan'],
+ defaultValue: 'Japan',
+ }),
+ field('flag', 'switch', { label: 'Subscribe', defaultValue: true }),
+ ])
+ )
+
+ expect(textField('Name').value).toBe('Ada')
+ expect(textField('Bio').value).toBe('Mathematician')
+ expect(dropdown('Country').textContent).toContain('Japan')
+ expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('true')
+ })
+
+ it('ignores a dropdown default the builder has since removed from the options', () => {
+ render(
+ formModule([
+ field('drop', 'dropdown', {
+ label: 'Country',
+ options: ['Germany'],
+ defaultValue: 'Japan',
+ }),
+ ])
+ )
+
+ expect(dropdown('Country').textContent).toContain('Select an option')
+ expect(dropdown('Country').textContent).not.toContain('Japan')
+ })
+
+ it('treats a non-boolean switch default as off', () => {
+ render(formModule([field('flag', 'switch', { label: 'Subscribe', defaultValue: 'true' })]))
+ expect(container.querySelector('[role="switch"]')?.getAttribute('aria-checked')).toBe('false')
+ })
+
+ it('announces required fields and renders their hints', () => {
+ render(
+ formModule([
+ field('short', 'short-text', { label: 'Name', required: true, hint: 'Your full name' }),
+ ])
+ )
+
+ expect(textField('Name').getAttribute('aria-required')).toBe('true')
+ expect(messageFor('Name')).toEqual({ role: null, text: 'Your full name' })
+ })
+
+ it('labels the submit button with the configured text', () => {
+ render(formModule(fields, { submitLabel: 'Run report' }))
+ expect(submitButton().textContent).toBe('Run report')
+ })
+})
+
+/**
+ * Authoring on the module itself. The inspector still carries every field
+ * property; these cover only what the module took over — the label, the order,
+ * and the field list.
+ */
+describe('FormModule — in-module authoring', () => {
+ const fields = [field('a', 'short-text', { label: 'Email' }), field('b', 'short-text')]
+
+ function renderAuthoring(onConfigChange = vi.fn()) {
+ render(formModule(fields), { mode: 'edit', onConfigChange })
+ return onConfigChange
+ }
+
+ it('renames a field in place', () => {
+ const onConfigChange = renderAuthoring()
+
+ typeInto(inputLabeled('Label for Email'), 'Work email')
+
+ const [, config, isValid] = onConfigChange.mock.calls.at(-1) as [
+ string,
+ { fields: FormField[] },
+ boolean,
+ ]
+ expect(config.fields[0].label).toBe('Work email')
+ expect(isValid).toBe(true)
+ })
+
+ /** An empty label is a real violation, so the edit is emitted but not armed. */
+ it('reports an emptied label as unsafe to persist', () => {
+ const onConfigChange = renderAuthoring()
+
+ typeInto(inputLabeled('Label for Email'), '')
+
+ const [, , isValid] = onConfigChange.mock.calls.at(-1) as [string, unknown, boolean]
+ expect(isValid).toBe(false)
+ })
+
+ it('removes a field from the module', () => {
+ const onConfigChange = renderAuthoring()
+
+ act(() => buttonLabeled('Remove Email').click())
+
+ const [, config] = onConfigChange.mock.calls.at(-1) as [string, { fields: FormField[] }]
+ expect(config.fields.map((f) => f.id)).toEqual(['b'])
+ })
+
+ /**
+ * Reordering replaced a move-up/move-down menu with a drag, so this covers
+ * the wiring end to end: the row is draggable, and a drop emits the reordered
+ * field list.
+ */
+ it('reorders fields by dragging one row onto another', () => {
+ const onConfigChange = renderAuthoring()
+
+ const rows = draggableRows()
+ expect(rows).toHaveLength(fields.length)
+
+ dragRowOnto(0, 1)
+
+ const [, config, isValid] = onConfigChange.mock.calls.at(-1) as [
+ string,
+ { fields: FormField[] },
+ boolean,
+ ]
+ expect(config.fields.map((f) => f.id)).toEqual(['b', 'a'])
+ expect(isValid).toBe(true)
+ })
+
+ it('mounts no drag affordance for a viewer who cannot write', () => {
+ render(formModule(fields), { mode: 'edit' })
+ expect(draggableRows()).toHaveLength(0)
+ })
+
+ /** Only the label editors stay live; the fields themselves never accept input here. */
+ it('keeps the rendered controls inert while authoring', () => {
+ renderAuthoring()
+
+ const valueInputs = [...container.querySelectorAll('input:not([aria-label])')]
+ expect(valueInputs).toHaveLength(fields.length)
+ expect(valueInputs.every((input) => input.disabled)).toBe(true)
+ expect(submitButton().disabled).toBe(true)
+ })
+
+ it('mounts no authoring affordances for a viewer who cannot write', () => {
+ render(formModule(fields), { mode: 'edit' })
+ expect(container.querySelector('input[aria-label="Label for Email"]')).toBeNull()
+ expect(container.textContent).not.toContain('Add field')
+ })
+})
+
+describe('FormModule — client-side validation', () => {
+ const requiredName = formModule([
+ field('short', 'short-text', { label: 'Name', required: true }),
+ field('flag', 'switch', { label: 'Subscribe' }),
+ ])
+
+ it('refuses to submit an invalid form and marks the offending field', () => {
+ render(requiredName)
+
+ submit()
+
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ expect(messageFor('Name')).toEqual({ role: 'alert', text: 'Name is required' })
+ })
+
+ it('rejects a required field holding only whitespace', () => {
+ render(requiredName)
+
+ type('Name', ' ')
+ submit()
+
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ expect(messageFor('Name')?.text).toBe('Name is required')
+ })
+
+ it('clears a field error as soon as the visitor edits that field', () => {
+ render(requiredName)
+ submit()
+ expect(messageFor('Name')?.role).toBe('alert')
+
+ type('Name', 'Ada')
+
+ expect(messageFor('Name')).toBeNull()
+ })
+
+ it('submits every field once the form validates, defaults included', () => {
+ render(requiredName)
+
+ type('Name', 'Ada')
+ submit()
+
+ expect(mockWorkspaceMutate).toHaveBeenCalledTimes(1)
+ expect(submittedValues(mockWorkspaceMutate)).toEqual({ short: 'Ada', flag: false })
+ })
+
+ it('sends a dropdown left untouched as an empty value', () => {
+ render(
+ formModule([field('drop', 'dropdown', { label: 'Country', options: ['Germany', 'Japan'] })])
+ )
+
+ submit()
+
+ expect(submittedValues(mockWorkspaceMutate)).toEqual({ drop: '' })
+ })
+
+ it('rejects a required dropdown with nothing chosen', () => {
+ render(
+ formModule([
+ field('drop', 'dropdown', { label: 'Country', options: ['Germany'], required: true }),
+ ])
+ )
+
+ submit()
+
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('Country is required')
+ })
+
+ /**
+ * Every other control type spreads the `aria` bundle, so a rejected dropdown
+ * that only rendered the message below itself would leave a screen-reader
+ * user told the field is invalid but never told why.
+ */
+ it('wires a rejected dropdown to its error message and marks it invalid', () => {
+ render(
+ formModule([
+ field('drop', 'dropdown', { label: 'Country', options: ['Germany'], required: true }),
+ ])
+ )
+
+ submit()
+
+ const trigger = dropdown('Country')
+ expect(trigger.getAttribute('aria-invalid')).toBe('true')
+ expect(trigger.getAttribute('aria-required')).toBe('true')
+
+ const describedBy = trigger.getAttribute('aria-describedby')
+ expect(describedBy).toBeTruthy()
+ expect(container.querySelector(`#${describedBy}`)?.textContent).toContain('Country is required')
+ })
+
+ it('lets the visible field label point at the dropdown trigger', () => {
+ render(formModule([field('drop', 'dropdown', { label: 'Country', options: ['Germany'] })]))
+
+ const trigger = dropdown('Country')
+ expect(trigger.id).toBeTruthy()
+ expect(container.querySelector(`label[for="${trigger.id}"]`)?.textContent).toContain('Country')
+ })
+})
+
+describe('FormModule — server-reported field errors', () => {
+ const module = formModule([
+ field('short', 'short-text', { label: 'Name' }),
+ field('long', 'long-text', { label: 'Bio' }),
+ ])
+
+ it('shows a rejection on the field the server named', () => {
+ render(module)
+ submit()
+
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ rejection([{ fieldId: 'short', message: 'Name must be one of the available options' }])
+ )
+
+ expect(messageFor('Name')).toEqual({
+ role: 'alert',
+ text: 'Name must be one of the available options',
+ })
+ expect(formAlert()).toBeNull()
+ })
+
+ /**
+ * The visitor's cached field list can be up to 30s behind the builder's, so a
+ * rejection can name a field this render does not draw. It must still surface.
+ */
+ it('surfaces a rejection for a field this render no longer draws', () => {
+ render(module)
+ submit()
+
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ rejection([{ fieldId: 'since-renamed', message: 'Email is required' }])
+ )
+
+ expect(formAlert()).toBe('Email is required')
+ expect(messageFor('Name')).toBeNull()
+ })
+
+ it('shows the rendered errors inline and the unrendered one on the form', () => {
+ render(module)
+ submit()
+
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ rejection([
+ { fieldId: 'short', message: 'Name is required' },
+ { fieldId: 'since-renamed', message: 'Email is required' },
+ ])
+ )
+
+ expect(messageFor('Name')?.text).toBe('Name is required')
+ expect(formAlert()).toBe('Email is required')
+ })
+
+ it('reports only the first message for a field named twice', () => {
+ render(module)
+ submit()
+
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ rejection([
+ { fieldId: 'short', message: 'Name is required' },
+ { fieldId: 'short', message: 'Name is too long' },
+ ])
+ )
+
+ expect(messageFor('Name')?.text).toBe('Name is required')
+ })
+
+ it('falls back to the mutation message when the failure carries no field details', () => {
+ render(module)
+ submit()
+
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ new ApiClientError({ status: 500, message: 'Workflow is not deployed', body: null })
+ )
+
+ expect(formAlert()).toBe('Workflow is not deployed')
+ expect(messageFor('Name')).toBeNull()
+ })
+
+ it('falls back to the mutation message when every detail entry is malformed', () => {
+ render(module)
+ submit()
+
+ fail(workspaceMutation, mockWorkspaceMutate, rejection([{ fieldId: 7, message: null }, null]))
+
+ expect(formAlert()).toBe('Invalid submission')
+ })
+
+ it('falls back to the mutation message for a failure that is not an API error', () => {
+ render(module)
+ submit()
+
+ fail(workspaceMutation, mockWorkspaceMutate, new Error('Network request failed'))
+
+ expect(formAlert()).toBe('Network request failed')
+ })
+
+ it('clears both the inline and the unrendered error when the visitor edits a field', () => {
+ render(module)
+ submit()
+ fail(
+ workspaceMutation,
+ mockWorkspaceMutate,
+ rejection([
+ { fieldId: 'short', message: 'Name is required' },
+ { fieldId: 'since-renamed', message: 'Email is required' },
+ ])
+ )
+
+ type('Name', 'Ada')
+
+ expect(messageFor('Name')).toBeNull()
+ expect(formAlert()).toBeNull()
+ })
+})
+
+describe('FormModule — run lifecycle', () => {
+ const module = formModule([field('short', 'short-text', { label: 'Name' })])
+
+ it('resets entered values once a run is accepted', () => {
+ render(formModule([field('short', 'short-text', { label: 'Name', defaultValue: 'Ada' })]))
+
+ type('Name', 'Grace')
+ submit()
+ succeed(workspaceMutation, mockWorkspaceMutate)
+
+ expect(textField('Name').value).toBe('Ada')
+ expect(container.textContent).toContain('Submitted')
+ })
+
+ it('disables the controls and swaps the label while the run is in flight', () => {
+ render(module)
+
+ workspaceMutation.isPending = true
+ rerender()
+
+ expect(textField('Name').disabled).toBe(true)
+ expect(submitButton().disabled).toBe(true)
+ expect(submitButton().textContent).toBe('Submitting…')
+ })
+
+ it('disables submitting in edit mode', () => {
+ render(module, { mode: 'edit' })
+
+ submit()
+
+ expect(submitButton().disabled).toBe(true)
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ })
+
+ it('disables submitting for a viewer without run access', () => {
+ render(module, { canRun: false })
+
+ submit()
+
+ expect(submitButton().disabled).toBe(true)
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('You do not have access to submit this form.')
+ })
+})
+
+/**
+ * The same module mounted against a share source. `useModuleFormSubmit` builds
+ * both mutations on every render, so these pin that only the source's own ever
+ * fires — and that the public route's rejections render identically.
+ */
+describe('FormModule — source routing', () => {
+ const module = formModule([field('short', 'short-text', { label: 'Name' })])
+
+ it('submits through the workspace route and never the token one', () => {
+ render(module)
+
+ type('Name', 'Ada')
+ submit()
+
+ expect(mockWorkspaceMutate).toHaveBeenCalledTimes(1)
+ expect(mockWorkspaceMutate.mock.calls[0][0]).toEqual({
+ interfaceId: INTERFACE_ID,
+ moduleId: MODULE_ID,
+ values: { short: 'Ada' },
+ })
+ expect(mockShareMutate).not.toHaveBeenCalled()
+ expect(mockUseSubmitInterfaceForm).toHaveBeenCalledWith(WORKSPACE_ID)
+ /** The idle mutation is still built, but with nothing to address. */
+ expect(mockUseSubmitPublicInterfaceForm).toHaveBeenCalledWith('')
+ })
+
+ it('submits through the token route and never the workspace one', () => {
+ render(module, { source: SHARE_SOURCE })
+
+ type('Name', 'Ada')
+ submit()
+
+ expect(mockShareMutate).toHaveBeenCalledTimes(1)
+ expect(mockShareMutate.mock.calls[0][0]).toEqual({
+ moduleId: MODULE_ID,
+ values: { short: 'Ada' },
+ })
+ expect(mockWorkspaceMutate).not.toHaveBeenCalled()
+ expect(mockUseSubmitPublicInterfaceForm).toHaveBeenCalledWith(TOKEN)
+ expect(mockUseSubmitInterfaceForm).toHaveBeenCalledWith('')
+ })
+
+ it('renders the token route rejections exactly like the workspace ones', () => {
+ render(module, { source: SHARE_SOURCE })
+ submit()
+
+ fail(
+ shareMutation,
+ mockShareMutate,
+ rejection([
+ { fieldId: 'short', message: 'Name is required' },
+ { fieldId: 'since-renamed', message: 'Email is required' },
+ ])
+ )
+
+ expect(messageFor('Name')?.text).toBe('Name is required')
+ expect(formAlert()).toBe('Email is required')
+ })
+
+ it('reads its status from the source mutation only', () => {
+ render(module, { source: SHARE_SOURCE })
+
+ workspaceMutation.isPending = true
+ rerender()
+ expect(submitButton().textContent).toBe('Submit')
+
+ shareMutation.isPending = true
+ rerender()
+ expect(submitButton().textContent).toBe('Submitting…')
+ })
+})
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.tsx
new file mode 100644
index 00000000000..754271939bc
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/form-module.tsx
@@ -0,0 +1,353 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+import { Chip, cn } from '@sim/emcn'
+import { CircleCheck, FormInput, Plus } from '@sim/emcn/icons'
+import { ModuleChooser } from '@/components/resources/interface-view/components/module-chooser'
+import { FormFieldControl } from '@/components/resources/interface-view/components/module-renderer/components/form-module/components/form-field-control'
+import { useModuleFormSubmit } from '@/components/resources/interface-view/components/module-renderer/components/form-module/hooks/use-module-form-submit'
+import { ModuleResourcePicker } from '@/components/resources/interface-view/components/module-renderer/components/module-resource-picker'
+import { MODULE_GUTTER_X } from '@/components/resources/interface-view/module-chrome'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type { SubmitInterfaceFormValues } from '@/lib/api/contracts/interfaces'
+import { INTERFACE_LAYOUT_LIMITS } from '@/lib/interfaces/constants'
+import { createFormField, isFormConfigValid } from '@/lib/interfaces/form-config'
+import type { FormSubmissionFieldError } from '@/lib/interfaces/form-submission'
+import { validateFormSubmission } from '@/lib/interfaces/form-submission'
+import type {
+ FormField,
+ FormModuleConfig,
+ InterfaceMode,
+ InterfaceModule,
+} from '@/lib/interfaces/types'
+import { reorderList, useDragReorder } from '@/hooks/use-drag-reorder'
+
+/** Values the visitor has entered, keyed by field id. Unset fields fall back to their default. */
+type FormValues = Record
+
+/** Per-field messages keyed by field id; `undefined` means the field is clean. */
+type FieldErrors = Record
+
+/**
+ * The value a field starts at. A dropdown default that no longer matches an
+ * option is ignored — submitting it would be rejected, and the builder can
+ * leave one behind by editing options after setting the default.
+ */
+function defaultFieldValue(field: FormField): string | boolean {
+ if (field.type === 'switch') {
+ return field.defaultValue === true
+ }
+ if (typeof field.defaultValue !== 'string') return ''
+ if (field.type === 'dropdown' && !field.options?.includes(field.defaultValue)) return ''
+ return field.defaultValue
+}
+
+function fieldValue(field: FormField, values: FormValues): string | boolean {
+ const entered = values[field.id]
+ return entered === undefined ? defaultFieldValue(field) : entered
+}
+
+function toFieldErrors(errors: readonly FormSubmissionFieldError[]): FieldErrors {
+ const map: FieldErrors = {}
+ for (const error of errors) {
+ if (map[error.fieldId] === undefined) map[error.fieldId] = error.message
+ }
+ return map
+}
+
+/**
+ * Pulls the typed per-field errors out of the submit route's 400 body
+ * (`{ error, details: FormSubmissionFieldError[] }`). Those details carry a
+ * `fieldId` rather than a Zod `path`, so `extractValidationIssues` does not
+ * recognise them.
+ */
+function extractServerFieldErrors(error: unknown): FieldErrors | null {
+ if (!isApiClientError(error)) return null
+ const body = error.body
+ if (!body || typeof body !== 'object') return null
+ const details = (body as { details?: unknown }).details
+ if (!Array.isArray(details)) return null
+
+ const map: FieldErrors = {}
+ for (const detail of details) {
+ if (!detail || typeof detail !== 'object') continue
+ const { fieldId, message } = detail as { fieldId?: unknown; message?: unknown }
+ if (typeof fieldId !== 'string' || typeof message !== 'string') continue
+ if (map[fieldId] === undefined) map[fieldId] = message
+ }
+ return Object.keys(map).length > 0 ? map : null
+}
+
+export interface FormModuleProps {
+ module: Extract
+ /** 'edit' → controls render but submit is disabled. 'preview' → live. */
+ mode: InterfaceMode
+ /**
+ * Whether this surface is live for the viewer. Submitting without it is
+ * rejected by the submit route, so the action is disabled rather than left
+ * live.
+ */
+ canRun?: boolean
+ /** Present only where this module may author itself — see `ModuleRenderer`. */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+/**
+ * Renders a form module's fields and runs its connected workflow on submit.
+ * Entered values are ephemeral local state — nothing is persisted, and the
+ * form resets to its defaults once a run is accepted.
+ *
+ * The submission goes through {@link useModuleFormSubmit}, which picks the
+ * workspace or token-scoped route from the surrounding resource; both
+ * validate against the stored field definitions, so the inline per-field errors
+ * below behave identically on a public share.
+ *
+ * Given `onConfigChange` the module authors itself: it binds its workflow from
+ * the same chooser column the empty cell offered, and its fields grow, reorder,
+ * and get renamed in place. The builder edits the form where the form is, at
+ * the size a visitor will meet it. Everything a rendered field does *not* show
+ * — type, name, placeholder, hint, required, options, default — stays in the
+ * inspector, which carries all of it.
+ */
+export function FormModule({ module, mode, canRun = true, onConfigChange }: FormModuleProps) {
+ const [values, setValues] = useState({})
+ const [fieldErrors, setFieldErrors] = useState({})
+ /**
+ * A rejection that no rendered field can show — the visitor's cached field
+ * list is up to 30s behind the builder's, so the server can reject a field id
+ * this render knows nothing about.
+ */
+ const [unrenderedError, setUnrenderedError] = useState(null)
+ const submitForm = useModuleFormSubmit(module.id)
+ const { source } = useResourceOfKind('interface')
+
+ const { fields, submitLabel, workflowId } = module.config
+ const isEditMode = mode === 'edit'
+ /** Authoring is edit mode plus write access — `onConfigChange` already means both. */
+ const authoring = isEditMode ? onConfigChange : undefined
+
+ /** Emits a config edit along with whether the result is safe to persist. */
+ function updateConfig(patch: Partial): void {
+ if (!authoring) return
+ const next: FormModuleConfig = { ...module.config, ...patch }
+ authoring(module.id, next, isFormConfigValid(next))
+ }
+
+ function handleAddField(): void {
+ if (fields.length >= INTERFACE_LAYOUT_LIMITS.MAX_FORM_FIELDS) return
+ updateConfig({ fields: [...fields, createFormField(fields)] })
+ }
+
+ function handleFieldLabelChange(index: number, label: string): void {
+ updateConfig({ fields: fields.map((field, i) => (i === index ? { ...field, label } : field)) })
+ }
+
+ function handleFieldRemove(index: number): void {
+ updateConfig({ fields: fields.filter((_, i) => i !== index) })
+ }
+
+ /**
+ * Stable identity so the drag hook's per-row handlers stay put across the
+ * re-render every keystroke in a label triggers.
+ */
+ const handleFieldReorder = useCallback(
+ (from: number, to: number) => {
+ const next: FormModuleConfig = {
+ ...module.config,
+ fields: reorderList(module.config.fields, from, to),
+ }
+ onConfigChange?.(module.id, next, isFormConfigValid(next))
+ },
+ [module.id, module.config, onConfigChange]
+ )
+
+ const fieldDrag = useDragReorder(handleFieldReorder, Boolean(authoring))
+
+ const addFieldChip = (
+ = INTERFACE_LAYOUT_LIMITS.MAX_FORM_FIELDS}
+ >
+ Add field
+
+ )
+
+ /**
+ * Only the workspace arm can tell an unwired module from a wired one by its
+ * config: the share arm is addressed by `(token, moduleId)` and carries no
+ * workflow id at all, because the server pruned every module a visitor could
+ * not run before the layout ever reached this client.
+ */
+ if (source.via === 'workspace' && workflowId === null) {
+ if (authoring) {
+ return (
+ updateConfig({ workflowId: next })}
+ />
+ )
+ }
+ return
+ }
+ if (fields.length === 0) {
+ if (authoring) return {addFieldChip}
+ return
+ }
+
+ const isSubmitDisabled = isEditMode || !canRun
+ /** Only errors a rendered control can show count as "handled inline". */
+ const hasFieldErrors = fields.some((field) => fieldErrors[field.id] !== undefined)
+ const formError = submitForm.isError
+ ? (unrenderedError ?? (hasFieldErrors ? null : (submitForm.error?.message ?? null)))
+ : null
+
+ function handleValueChange(fieldId: string, next: string | boolean): void {
+ setValues((previous) => ({ ...previous, [fieldId]: next }))
+ setFieldErrors((previous) =>
+ previous[fieldId] === undefined ? previous : { ...previous, [fieldId]: undefined }
+ )
+ setUnrenderedError(null)
+ if (submitForm.isSuccess || submitForm.isError) submitForm.reset()
+ }
+
+ function handleSubmit(): void {
+ const payload: SubmitInterfaceFormValues = {}
+ for (const field of fields) {
+ payload[field.id] = fieldValue(field, values)
+ }
+
+ setUnrenderedError(null)
+
+ const validation = validateFormSubmission(fields, payload)
+ if (!validation.valid) {
+ setFieldErrors(toFieldErrors(validation.errors))
+ return
+ }
+
+ setFieldErrors({})
+ submitForm.submit(payload, {
+ onSuccess: () => setValues({}),
+ onError: (error) => {
+ const serverErrors = extractServerFieldErrors(error)
+ if (!serverErrors) return
+
+ const renderedIds = new Set(fields.map((field) => field.id))
+ const rendered: FieldErrors = {}
+ let unrendered: string | null = null
+ for (const [fieldId, message] of Object.entries(serverErrors)) {
+ if (message === undefined) continue
+ if (renderedIds.has(fieldId)) rendered[fieldId] = message
+ else unrendered ??= message
+ }
+ setFieldErrors(rendered)
+ setUnrenderedError(unrendered)
+ },
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/hooks/use-module-form-submit.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/hooks/use-module-form-submit.ts
new file mode 100644
index 00000000000..e84a70b8bfa
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/hooks/use-module-form-submit.ts
@@ -0,0 +1,74 @@
+'use client'
+
+import { useCallback } from 'react'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
+import type { SubmitInterfaceFormValues } from '@/lib/api/contracts/interfaces'
+import { useSubmitInterfaceForm } from '@/hooks/queries/interfaces'
+import { useSubmitPublicInterfaceForm } from '@/hooks/queries/public-interfaces'
+
+export interface ModuleFormSubmitOptions {
+ onSuccess?: () => void
+ onError?: (error: unknown) => void
+}
+
+export interface UseModuleFormSubmitResult {
+ submit: (values: SubmitInterfaceFormValues, options?: ModuleFormSubmitOptions) => void
+ isPending: boolean
+ isSuccess: boolean
+ isError: boolean
+ error: Error | null
+ reset: () => void
+}
+
+/**
+ * Runs a form module's submission through whichever source the interface is
+ * mounted against, so `FormModule` renders and reports errors one way in both.
+ *
+ * The two routes differ only in how they authorize: the workspace route by
+ * session and workspace permission, the public route by share token — and both
+ * validate the submitted values against the **stored** field definitions, so
+ * the per-field `details` the form renders inline have the same shape either
+ * way.
+ *
+ * Both mutations are created on every render; only the source's own is ever
+ * fired, so the inactive one issues no request.
+ */
+export function useModuleFormSubmit(moduleId: string): UseModuleFormSubmitResult {
+ const { source } = useResourceOfKind('interface')
+ const isWorkspaceScope = source.via === 'workspace'
+
+ const workspaceSubmit = useSubmitInterfaceForm(
+ source.via === 'workspace' ? source.workspaceId : ''
+ )
+ const shareSubmit = useSubmitPublicInterfaceForm(source.via === 'share' ? source.token : '')
+
+ const interfaceId = source.via === 'workspace' ? source.resourceId : ''
+ const workspaceMutate = workspaceSubmit.mutate
+ const shareMutate = shareSubmit.mutate
+
+ const submit = useCallback(
+ (values: SubmitInterfaceFormValues, options?: ModuleFormSubmitOptions) => {
+ if (isWorkspaceScope) {
+ workspaceMutate({ interfaceId, moduleId, values }, options)
+ return
+ }
+ shareMutate({ moduleId, values }, options)
+ },
+ [isWorkspaceScope, workspaceMutate, shareMutate, interfaceId, moduleId]
+ )
+
+ /**
+ * The one scope's mutation every status read goes through. `reset` is
+ * returned directly — TanStack v5 mutation `reset` is stable.
+ */
+ const active = isWorkspaceScope ? workspaceSubmit : shareSubmit
+
+ return {
+ submit,
+ isPending: active.isPending,
+ isSuccess: active.isSuccess,
+ isError: active.isError,
+ error: active.error ?? null,
+ reset: active.reset,
+ }
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/index.ts
new file mode 100644
index 00000000000..e9484582830
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/form-module/index.ts
@@ -0,0 +1,2 @@
+export type { FormModuleProps } from './form-module'
+export { FormModule } from './form-module'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/index.ts
new file mode 100644
index 00000000000..26a47b6af3a
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/index.ts
@@ -0,0 +1,2 @@
+export type { ModuleResourceKind, ModuleResourcePickerProps } from './module-resource-picker'
+export { ModuleResourcePicker } from './module-resource-picker'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/module-resource-picker.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/module-resource-picker.tsx
new file mode 100644
index 00000000000..44d0084ee61
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/module-resource-picker/module-resource-picker.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import { useMemo } from 'react'
+import { ChipCombobox, type ComboboxOption } from '@sim/emcn'
+import { ModuleChooser } from '@/components/resources/interface-view/components/module-chooser'
+import {
+ MODULE_RESOURCE_COPY,
+ type ModuleResourceKind,
+} from '@/components/resources/interface-view/module-resource-copy'
+import { useTablesList } from '@/hooks/queries/tables'
+import { useWorkflows } from '@/hooks/queries/workflows'
+import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
+
+export type { ModuleResourceKind }
+
+/** The minimal shape every pickable workspace resource exposes. */
+interface PickableResource {
+ id: string
+ name: string
+}
+
+interface PickerProps {
+ kind: ModuleResourceKind
+ items: readonly PickableResource[] | undefined
+ isLoading: boolean
+ onSelect: (id: string) => void
+}
+
+/**
+ * The picker itself, once its list has been resolved. Kept separate from the
+ * three query wrappers below so the control, its copy, and its chrome are
+ * declared exactly once.
+ */
+function Picker({ kind, items, isLoading, onSelect }: PickerProps) {
+ const copy = MODULE_RESOURCE_COPY[kind]
+ const options = useMemo(
+ () => (items ?? []).map((item) => ({ label: item.name, value: item.id })),
+ [items]
+ )
+
+ return (
+
+
+
+ )
+}
+
+interface ScopedPickerProps {
+ workspaceId: string
+ onSelect: (id: string) => void
+}
+
+function WorkflowPicker({ workspaceId, onSelect }: ScopedPickerProps) {
+ const workflows = useWorkflows(workspaceId)
+ return (
+
+ )
+}
+
+function TablePicker({ workspaceId, onSelect }: ScopedPickerProps) {
+ const tables = useTablesList(workspaceId)
+ return (
+
+ )
+}
+
+function FilePicker({ workspaceId, onSelect }: ScopedPickerProps) {
+ const files = useWorkspaceFiles(workspaceId)
+ return
+}
+
+export interface ModuleResourcePickerProps {
+ kind: ModuleResourceKind
+ /** Always workspace scope — a share carries no unconfigured modules to bind. */
+ workspaceId: string
+ onSelect: (id: string) => void
+}
+
+/**
+ * Binds an unconfigured module to a workspace resource without leaving the
+ * canvas, so wiring a module reads like the module-type stack that preceded it
+ * rather than sending the builder to the inspector for the first edit.
+ *
+ * Exactly one branch mounts, and each branch owns its own list query, so
+ * picking a table never fetches workflows and a module that is already
+ * configured issues no list request at all.
+ */
+export function ModuleResourcePicker({ kind, workspaceId, onSelect }: ModuleResourcePickerProps) {
+ switch (kind) {
+ case 'workflow':
+ return
+ case 'table':
+ return
+ case 'file':
+ return
+ }
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/index.ts
new file mode 100644
index 00000000000..bc5ab798e4a
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/index.ts
@@ -0,0 +1,2 @@
+export type { TableModuleProps } from './table-module'
+export { TableModule } from './table-module'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/table-module.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/table-module.tsx
new file mode 100644
index 00000000000..b5d14d421d5
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/table-module.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { useMemo } from 'react'
+import { Table as TableIcon } from '@sim/emcn/icons'
+import { ModuleResourcePicker } from '@/components/resources/interface-view/components/module-renderer/components/module-resource-picker'
+import { interfaceModuleSeed } from '@/components/resources/interface-view/interface-scope'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { useResourceOfKind } from '@/components/resources/resource-provider'
+import { TableView } from '@/components/resources/table-view'
+import type { InterfaceModule } from '@/lib/interfaces/types'
+import { grantsForShare, shareSource, workspaceSource } from '@/resources'
+
+export interface TableModuleProps {
+ module: Extract
+ /**
+ * Present only where this module may bind its own table — see
+ * `ModuleRenderer`. There is no `mode` here: rows are read-only in every
+ * scope, so the only thing edit mode ever changed was whether the module
+ * could be bound, which is exactly what this prop's presence now says.
+ */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+/**
+ * A workspace table inside an interface.
+ *
+ * This module owns exactly two things: binding itself to a table, and turning
+ * the surrounding interface source into a *table* source. The table itself is
+ * the canonical {@link TableView}, so a module renders every cell kind the
+ * tables grid does — booleans, dates, JSON, links, currency, select pills —
+ * rather than the approximation it used to carry.
+ *
+ * The share arm addresses the table by `(token, moduleId)` and carries no table
+ * id at all: the server derives it from the stored layout on every request, so
+ * there is nothing here for a visitor to forge.
+ */
+export function TableModule({ module, onConfigChange }: TableModuleProps) {
+ const { source } = useResourceOfKind('interface')
+ const { tableId } = module.config
+
+ const seed = interfaceModuleSeed(source, module.id)
+ const sharedTable = seed?.kind === 'table' ? seed.seed : null
+
+ const tableSource = useMemo(() => {
+ if (source.via === 'workspace') {
+ return tableId
+ ? workspaceSource({ kind: 'table', workspaceId: source.workspaceId, resourceId: tableId })
+ : null
+ }
+ return sharedTable
+ ? shareSource({ kind: 'table', token: source.token, grantId: module.id, seed: sharedTable })
+ : null
+ }, [source, tableId, sharedTable, module.id])
+
+ if (!tableSource) {
+ /**
+ * Unbound in the editor: the module authors itself, rendering the same
+ * chooser the empty cell offered a moment earlier so picking the table
+ * happens where the module is rather than in the inspector.
+ */
+ if (source.via === 'workspace' && onConfigChange) {
+ return (
+ onConfigChange(module.id, { tableId: next }, true)}
+ />
+ )
+ }
+ return
+ }
+
+ return
+}
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/index.ts b/apps/sim/components/resources/interface-view/components/module-renderer/index.ts
new file mode 100644
index 00000000000..3ccd99ca9d7
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/index.ts
@@ -0,0 +1,2 @@
+export type { ModuleRendererProps } from './module-renderer'
+export { ModuleRenderer } from './module-renderer'
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.test.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.test.tsx
new file mode 100644
index 00000000000..76b8b937719
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.test.tsx
@@ -0,0 +1,788 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ApiClientError } from '@/lib/api/client/errors'
+import { PUBLIC_TABLE_PAGE_SIZE } from '@/lib/api/contracts/public-interfaces'
+
+const {
+ mockUseTable,
+ mockUseInfiniteTableRows,
+ mockUseWorkspaceFileRecord,
+ mockUsePublicInterfaceTableRows,
+ mockUseTablesList,
+ mockUseWorkspaceFiles,
+ mockUseWorkflows,
+} = vi.hoisted(() => ({
+ mockUseTable: vi.fn(),
+ mockUseInfiniteTableRows: vi.fn(),
+ mockUseWorkspaceFileRecord: vi.fn(),
+ mockUsePublicInterfaceTableRows: vi.fn(),
+ mockUseTablesList: vi.fn(),
+ mockUseWorkspaceFiles: vi.fn(),
+ mockUseWorkflows: vi.fn(),
+}))
+
+vi.mock('@/hooks/queries/tables', () => ({
+ useTable: mockUseTable,
+ useInfiniteTableRows: mockUseInfiniteTableRows,
+ useTablesList: mockUseTablesList,
+}))
+
+vi.mock('@/hooks/queries/public-interfaces', () => ({
+ usePublicInterfaceTableRows: mockUsePublicInterfaceTableRows,
+}))
+
+vi.mock('@/hooks/queries/workspace-files', () => ({
+ useWorkspaceFileRecord: mockUseWorkspaceFileRecord,
+ useWorkspaceFiles: mockUseWorkspaceFiles,
+}))
+
+vi.mock('@/hooks/queries/workflows', () => ({
+ useWorkflows: mockUseWorkflows,
+}))
+
+vi.mock('@/hooks/use-execution-stream', () => ({
+ useExecutionStream: () => ({ execute: vi.fn(), cancelExecute: vi.fn() }),
+}))
+
+vi.mock('@/components/resources/file-view/file-view', () => ({
+ FileView: ({
+ source,
+ readOnly,
+ }: {
+ source: {
+ via: string
+ resourceId?: string
+ grantId?: string
+ seed?: { name: string }
+ }
+ readOnly?: boolean
+ }) => (
+
+ ),
+}))
+
+vi.mock(
+ '@/components/resources/interface-view/components/module-renderer/components/form-module',
+ () => ({
+ FormModule: () =>
,
+ })
+)
+
+/**
+ * The chat module mounts the deployed chat's composer. Stubbed to the surface
+ * the module is responsible for wiring — its inert state and its attach
+ * policy — rather than re-testing a component the deployed chat owns.
+ */
+vi.mock('@/app/(interfaces)/chat/components/input/input', () => ({
+ ChatInput: ({
+ disabled,
+ docked,
+ allowAttachments,
+ placeholder,
+ }: {
+ disabled?: boolean
+ docked?: boolean
+ allowAttachments?: boolean
+ placeholder?: string
+ }) => (
+
+
+
+
+ ),
+}))
+
+import { ModuleRenderer } from '@/components/resources/interface-view/components/module-renderer'
+import { ResourceProvider } from '@/components/resources/resource-provider'
+import type { InterfaceLayout, InterfaceModule } from '@/lib/interfaces/types'
+import {
+ type InterfaceModuleSeed,
+ type ResourceGrants,
+ type ResourceSource,
+ shareSource,
+ workspaceSource,
+} from '@/resources'
+
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const WORKSPACE_ID = 'ws-1'
+const INTERFACE_ID = 'if-1'
+const TOKEN = 'tok-1'
+
+const EMPTY_LAYOUT: InterfaceLayout = { version: 1, grid: { rows: 1, cols: 1 }, modules: [] }
+
+const GRANTS: ResourceGrants = { write: true, run: true }
+
+const WORKSPACE_SOURCE: ResourceSource<'interface'> = workspaceSource({
+ kind: 'interface',
+ workspaceId: WORKSPACE_ID,
+ resourceId: INTERFACE_ID,
+})
+
+/**
+ * A shared interface is a container of grants: each table/file module mints its
+ * own child share source from the seed the server resolved for it.
+ */
+function shareInterface(
+ modules: Record = {}
+): ResourceSource<'interface'> {
+ return shareSource({
+ kind: 'interface',
+ token: TOKEN,
+ grantId: TOKEN,
+ seed: { name: 'Shared', layout: EMPTY_LAYOUT, modules },
+ })
+}
+
+let container: HTMLDivElement
+let root: Root
+
+function render(
+ module: InterfaceModule,
+ mode: 'edit' | 'preview' = 'preview',
+ canRun = true,
+ source: ResourceSource<'interface'> = WORKSPACE_SOURCE,
+ /** Present = this surface may author the module, exactly as the canvas decides. */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+) {
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+}
+
+/** Flushes the `lazy()` boundary the file module mounts its viewer behind. */
+async function flush() {
+ await act(async () => {})
+}
+
+function pendingQuery() {
+ return { isPending: true, isError: false, data: undefined }
+}
+
+/** A settled resource-list query, the shape the in-module pickers read. */
+function listQuery(data: unknown) {
+ return { data, isLoading: false }
+}
+
+function successQuery(data: unknown) {
+ return { isPending: false, isError: false, data }
+}
+
+/**
+ * A failed query carrying a real `ApiClientError`, so the module's
+ * deleted-vs-unreachable branch is driven by the status the boundary actually
+ * produces rather than a bare `isError` flag.
+ */
+function errorQuery(status: number) {
+ return {
+ isPending: false,
+ isError: true,
+ data: undefined,
+ error: new ApiClientError({ status, message: `Request failed with ${status}`, body: null }),
+ }
+}
+
+/** `useInfiniteQuery` shape — the table module pages in more rows as it scrolls. */
+function rowsQuery(
+ data: unknown,
+ page: { hasNextPage?: boolean; isFetchingNextPage?: boolean; fetchNextPage?: () => void } = {}
+) {
+ return {
+ isPending: false,
+ isError: false,
+ data,
+ hasNextPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ ...page,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTable.mockReturnValue(pendingQuery())
+ mockUseInfiniteTableRows.mockReturnValue(pendingQuery())
+ mockUsePublicInterfaceTableRows.mockReturnValue(pendingQuery())
+ mockUseWorkspaceFileRecord.mockReturnValue(pendingQuery())
+ mockUseTablesList.mockReturnValue(listQuery([{ id: 'tbl-1', name: 'Leads' }]))
+ mockUseWorkspaceFiles.mockReturnValue(listQuery([{ id: 'file-1', name: 'Report.pdf' }]))
+ mockUseWorkflows.mockReturnValue(listQuery([{ id: 'wf-1', name: 'Triage' }]))
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+const UNCONFIGURED: InterfaceModule[] = [
+ {
+ id: 'm-chat',
+ type: 'chat',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { workflowId: null, outputConfigs: [], showThinking: false, welcomeMessage: '' },
+ },
+ {
+ id: 'm-table',
+ type: 'table',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { tableId: null },
+ },
+ {
+ id: 'm-file',
+ type: 'file',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { fileId: null },
+ },
+]
+
+describe('ModuleRenderer', () => {
+ it('dispatches each module type to its renderer', () => {
+ render({
+ id: 'm-form',
+ type: 'form',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { workflowId: null, fields: [], submitLabel: 'Submit' },
+ })
+ expect(container.querySelector('[data-testid="form-module"]')).not.toBeNull()
+
+ render(UNCONFIGURED[0])
+ expect(container.textContent).toContain('This chat is not available.')
+
+ render(UNCONFIGURED[1])
+ expect(container.textContent).toContain('This table is not available.')
+
+ render(UNCONFIGURED[2])
+ expect(container.textContent).toContain('This file is not available.')
+ })
+
+ it('offers an in-module picker when the surface may author the module', () => {
+ const onConfigChange = vi.fn()
+
+ render(UNCONFIGURED[0], 'edit', true, WORKSPACE_SOURCE, onConfigChange)
+ expect(container.textContent).toContain('Select a workflow')
+
+ render(UNCONFIGURED[1], 'edit', true, WORKSPACE_SOURCE, onConfigChange)
+ expect(container.textContent).toContain('Select a table')
+
+ render(UNCONFIGURED[2], 'edit', true, WORKSPACE_SOURCE, onConfigChange)
+ expect(container.textContent).toContain('Select a file')
+ })
+
+ /**
+ * `onConfigChange` is what the canvas withholds from a read-only member, so
+ * its absence must never leave a picker the viewer cannot act on.
+ */
+ it('falls back to the unavailable copy without an authoring callback', () => {
+ render(UNCONFIGURED[0], 'edit')
+ expect(container.textContent).toContain('This chat is not available.')
+
+ render(UNCONFIGURED[1], 'edit')
+ expect(container.textContent).toContain('This table is not available.')
+
+ render(UNCONFIGURED[2], 'edit')
+ expect(container.textContent).toContain('This file is not available.')
+ })
+
+ it('never names an editing surface in preview', () => {
+ for (const module of UNCONFIGURED) {
+ render(module)
+ expect(container.textContent).not.toContain('properties panel')
+ expect(container.textContent).not.toContain('edit mode')
+ expect(container.textContent).not.toContain('Select a')
+ }
+ })
+})
+
+describe('ChatModule', () => {
+ const chatModule = (
+ config: Partial['config']> = {}
+ ): InterfaceModule => ({
+ id: 'm-chat',
+ type: 'chat',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: {
+ workflowId: 'wf-1',
+ outputConfigs: [],
+ showThinking: false,
+ welcomeMessage: '',
+ ...config,
+ },
+ })
+
+ it('renders the welcome message as the opening assistant turn', () => {
+ render(chatModule({ welcomeMessage: 'How can I help?' }))
+ expect(container.textContent).toContain('How can I help?')
+ })
+
+ it('prompts for a first message when no welcome message is configured', () => {
+ render(chatModule())
+ expect(container.textContent).toContain('Send a message to run this workflow.')
+ })
+
+ it('disables the composer in edit mode and enables it in preview', () => {
+ render(chatModule(), 'edit')
+ const editTextarea = container.querySelector('textarea')
+ expect(editTextarea?.disabled).toBe(true)
+ expect(container.querySelector('[aria-label="Send message"]')).toHaveProperty('disabled', true)
+
+ render(chatModule(), 'preview')
+ const previewTextarea = container.querySelector('textarea')
+ expect(previewTextarea?.disabled).toBe(false)
+ })
+
+ /** The deployed composer pins itself to the viewport; inside a pane it must not. */
+ it('mounts the composer undocked so it stays inside the module', () => {
+ render(chatModule(), 'preview')
+ expect(container.querySelector('[data-testid="chat-input"]')?.getAttribute('data-docked')).toBe(
+ 'false'
+ )
+ })
+
+ /**
+ * Attachments ride to `/api/workflows/[id]/execute`, which uploads them. The
+ * token-scoped chat route declares a text-only body, so a share must not
+ * offer an affordance whose files the contract would strip.
+ */
+ it('offers attachments in workspace scope and withholds them on a share', () => {
+ render(chatModule(), 'preview')
+ expect(
+ container.querySelector('[data-testid="chat-input"]')?.getAttribute('data-allow-attachments')
+ ).toBe('true')
+
+ render(chatModule(), 'preview', true, shareInterface())
+ expect(
+ container.querySelector('[data-testid="chat-input"]')?.getAttribute('data-allow-attachments')
+ ).toBe('false')
+ })
+
+ it('disables the composer for a viewer who cannot run the workflow', () => {
+ render(chatModule(), 'preview', false)
+ expect(container.querySelector('textarea')?.disabled).toBe(true)
+ expect(container.querySelector('[aria-label="Send message"]')).toHaveProperty('disabled', true)
+ })
+})
+
+describe('TableModule', () => {
+ const tableModule: InterfaceModule = {
+ id: 'm-table',
+ type: 'table',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { tableId: 'tbl-1' },
+ }
+
+ it('treats a deleted table as a dangling reference', () => {
+ mockUseTable.mockReturnValue(errorQuery(404))
+ render(tableModule)
+ expect(container.textContent).toContain('This table is no longer in the workspace.')
+ })
+
+ it('reports an unreachable table without claiming it was deleted', () => {
+ mockUseTable.mockReturnValue(errorQuery(500))
+ render(tableModule)
+ expect(container.textContent).toContain('This table could not be loaded.')
+ expect(container.textContent).not.toContain('no longer in the workspace')
+ })
+
+ it('does not claim deletion when the session has expired', () => {
+ mockUseTable.mockReturnValue(errorQuery(401))
+ render(tableModule)
+ expect(container.textContent).not.toContain('no longer in the workspace')
+ })
+
+ it('renders columns, rows, and the remaining-rows footer', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({
+ schema: {
+ columns: [
+ { id: 'col_a', name: 'Name' },
+ { id: 'col_b', name: 'Score' },
+ ],
+ },
+ })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(
+ rowsQuery({
+ pages: [
+ {
+ rows: [
+ { id: 'r1', data: { col_a: 'Ada', col_b: 42 } },
+ { id: 'r2', data: { col_a: 'Grace', col_b: null } },
+ ],
+ totalCount: 500,
+ },
+ ],
+ })
+ )
+ render(tableModule)
+
+ const headers = [...container.querySelectorAll('th')].map((th) => th.textContent)
+ expect(headers).toEqual(['Name', 'Score'])
+ const firstRow = [...container.querySelectorAll('tbody tr')][0]
+ expect([...firstRow.querySelectorAll('td')].map((td) => td.textContent)).toEqual(['Ada', '42'])
+ expect(container.textContent).toContain('Showing 2 of 500 rows.')
+ })
+
+ /**
+ * Cells used to be `String(value)` for every type. These pin the typed
+ * appearances so a boolean cannot silently regress to the text "false" and a
+ * URL cannot lose its link.
+ */
+ it('draws each column type with its own cell appearance', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({
+ schema: {
+ columns: [
+ { id: 'col_flag', name: 'Flag', type: 'boolean' },
+ { id: 'col_site', name: 'Site', type: 'string' },
+ { id: 'col_note', name: 'Note', type: 'string' },
+ { id: 'col_meta', name: 'Meta', type: 'json' },
+ ],
+ },
+ })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(
+ rowsQuery({
+ pages: [
+ {
+ rows: [
+ {
+ id: 'r1',
+ data: {
+ col_flag: false,
+ col_site: 'https://example.com/a',
+ col_note: 'plain',
+ col_meta: { a: 1 },
+ },
+ },
+ ],
+ totalCount: 1,
+ },
+ ],
+ })
+ )
+ render(tableModule)
+
+ /** A false boolean is a checkbox, not the word "false". */
+ expect(container.querySelector('[role="checkbox"]')).not.toBeNull()
+ expect(container.textContent).not.toContain('false')
+
+ const link = container.querySelector('a[href="https://example.com/a"]')
+ expect(link).not.toBeNull()
+ expect(link?.getAttribute('target')).toBe('_blank')
+ expect(container.querySelector('img')).not.toBeNull()
+
+ expect(container.textContent).toContain('plain')
+ expect(container.textContent).toContain('{"a":1}')
+ })
+
+ /** An all-empty row must not collapse to its padding and break the rhythm. */
+ it('keeps an empty cell the same height as a filled one', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A', type: 'string' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(
+ rowsQuery({
+ pages: [
+ {
+ rows: [
+ { id: 'r1', data: { col_a: '' } },
+ { id: 'r2', data: { col_a: 'x' } },
+ ],
+ totalCount: 2,
+ },
+ ],
+ })
+ )
+ render(tableModule)
+
+ const spans = [...container.querySelectorAll('tbody td > span')]
+ expect(spans).toHaveLength(2)
+ expect(spans.every((span) => span.className.includes('min-h-[20px]'))).toBe(true)
+ })
+
+ it('renders every fetched page, not just the first', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(
+ rowsQuery({
+ pages: [
+ { rows: [{ id: 'r1', data: { col_a: 'one' } }], totalCount: 2 },
+ { rows: [{ id: 'r2', data: { col_a: 'two' } }], totalCount: 2 },
+ ],
+ })
+ )
+ render(tableModule)
+
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
+ expect(container.textContent).not.toContain('Showing')
+ })
+
+ it('pages in the next batch when the scroller nears the end', () => {
+ const fetchNextPage = vi.fn()
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(
+ rowsQuery(
+ { pages: [{ rows: [{ id: 'r1', data: { col_a: 'x' } }], totalCount: 500 }] },
+ {
+ hasNextPage: true,
+ fetchNextPage,
+ }
+ )
+ )
+ render(tableModule)
+
+ const scroller = container.querySelector('[class*="overscroll-contain"]') as HTMLDivElement
+ Object.defineProperties(scroller, {
+ scrollHeight: { value: 1000, configurable: true },
+ clientHeight: { value: 400, configurable: true },
+ /** 500px from the end — outside the prefetch window. */
+ scrollTop: { value: 100, configurable: true },
+ })
+
+ act(() => scroller.dispatchEvent(new Event('scroll', { bubbles: true })))
+ expect(fetchNextPage).not.toHaveBeenCalled()
+
+ /** 50px from the end — inside it. */
+ Object.defineProperty(scroller, 'scrollTop', { value: 550, configurable: true })
+ act(() => scroller.dispatchEvent(new Event('scroll', { bubbles: true })))
+ expect(fetchNextPage).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the loaded rows when a scroll-triggered page fails', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue({
+ ...rowsQuery({ pages: [{ rows: [{ id: 'r1', data: { col_a: 'kept' } }], totalCount: 500 }] }),
+ isError: true,
+ })
+ render(tableModule)
+
+ expect(container.textContent).toContain('kept')
+ expect(container.textContent).not.toContain('no longer in the workspace')
+ })
+
+ it('reports a dangling reference when the first page fails', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue({
+ ...rowsQuery(undefined),
+ ...errorQuery(404),
+ })
+ render(tableModule)
+ expect(container.textContent).toContain('This table is no longer in the workspace.')
+ })
+
+ it('reports an unreachable first page without claiming the table was deleted', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue({
+ ...rowsQuery(undefined),
+ ...errorQuery(503),
+ })
+ render(tableModule)
+ expect(container.textContent).toContain('This table could not be loaded.')
+ })
+
+ it('shows an empty state for a table with no rows', () => {
+ mockUseTable.mockReturnValue(
+ successQuery({ schema: { columns: [{ id: 'col_a', name: 'A' }] } })
+ )
+ mockUseInfiniteTableRows.mockReturnValue(rowsQuery({ pages: [{ rows: [], totalCount: 0 }] }))
+ render(tableModule)
+ expect(container.textContent).toContain('This table has no rows yet.')
+ })
+
+ it('requests the module page size', () => {
+ render(tableModule)
+ expect(mockUseInfiniteTableRows).toHaveBeenCalledWith(
+ expect.objectContaining({ workspaceId: WORKSPACE_ID, tableId: 'tbl-1', pageSize: 100 })
+ )
+ })
+})
+
+describe('FileModule', () => {
+ const fileModule: InterfaceModule = {
+ id: 'm-file',
+ type: 'file',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { fileId: 'file-1' },
+ }
+
+ const unconfigured: InterfaceModule = { ...fileModule, config: { fileId: null } }
+
+ it('binds its own file from the module and reports the edit as valid', () => {
+ const onConfigChange = vi.fn()
+ render(unconfigured, 'edit', true, WORKSPACE_SOURCE, onConfigChange)
+ expect(container.textContent).toContain('Select a file')
+
+ render(unconfigured, 'preview')
+ expect(container.textContent).toContain('This file is not available.')
+ expect(container.textContent).not.toContain('Select a file')
+ })
+
+ it('addresses the file by id and never offers to change it', async () => {
+ render(fileModule, 'preview')
+ await flush()
+
+ const view = container.querySelector('[data-testid="file-view"]')
+ expect(view?.getAttribute('data-via')).toBe('workspace')
+ expect(view?.getAttribute('data-address')).toBe('file-1')
+ expect(view?.getAttribute('data-read-only')).toBe('true')
+ })
+
+ it('addresses the same file the same way in edit mode', async () => {
+ render(fileModule, 'edit')
+ await flush()
+
+ expect(container.querySelector('[data-testid="file-view"]')?.getAttribute('data-address')).toBe(
+ 'file-1'
+ )
+ })
+})
+
+/**
+ * The same renderers, mounted against a share source. These assert the seam
+ * itself: no module forks for the public page, and none of them reaches a
+ * workspace-scoped query while a share token is what authorizes the viewer.
+ */
+describe('share source', () => {
+ const tableModule: InterfaceModule = {
+ id: 'm-table',
+ type: 'table',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { tableId: 'tbl-1' },
+ }
+
+ const fileModule: InterfaceModule = {
+ id: 'm-file',
+ type: 'file',
+ placement: { row: 0, col: 0, rowSpan: 1, colSpan: 1 },
+ config: { fileId: 'file-1' },
+ }
+
+ it('reads table rows from the token route and never the workspace one', () => {
+ mockUsePublicInterfaceTableRows.mockReturnValue(
+ rowsQuery({ pages: [{ rows: [{ id: 'r1', data: { col_a: 'shared' } }], totalCount: 1 }] })
+ )
+ render(
+ tableModule,
+ 'preview',
+ true,
+ shareInterface({
+ 'm-table': {
+ kind: 'table',
+ seed: { name: 'People', columns: [{ id: 'col_a', name: 'A', type: 'string' }] },
+ },
+ })
+ )
+
+ expect(mockUsePublicInterfaceTableRows).toHaveBeenCalledWith(
+ expect.objectContaining({
+ token: TOKEN,
+ moduleId: 'm-table',
+ /** The public contract's hard `limit` ceiling — asking for more is a 400. */
+ pageSize: PUBLIC_TABLE_PAGE_SIZE,
+ enabled: true,
+ })
+ )
+ expect(mockUseInfiniteTableRows).toHaveBeenCalledWith(
+ expect.objectContaining({ enabled: false })
+ )
+ expect(mockUseTable).toHaveBeenCalledWith(undefined, undefined)
+ expect(container.textContent).toContain('shared')
+ })
+
+ it('renders the server-resolved columns without fetching a schema', () => {
+ mockUsePublicInterfaceTableRows.mockReturnValue(
+ rowsQuery({ pages: [{ rows: [{ id: 'r1', data: { col_a: 'x' } }], totalCount: 1 }] })
+ )
+ render(
+ tableModule,
+ 'preview',
+ true,
+ shareInterface({
+ 'm-table': {
+ kind: 'table',
+ seed: { name: 'People', columns: [{ id: 'col_a', name: 'Name', type: 'string' }] },
+ },
+ })
+ )
+
+ expect([...container.querySelectorAll('th')].map((th) => th.textContent)).toEqual(['Name'])
+ })
+
+ it('never tells a visitor the table lived in a workspace', () => {
+ mockUsePublicInterfaceTableRows.mockReturnValue({
+ ...rowsQuery(undefined),
+ ...errorQuery(404),
+ })
+ render(
+ tableModule,
+ 'preview',
+ true,
+ shareInterface({ 'm-table': { kind: 'table', seed: { name: 'People', columns: [] } } })
+ )
+
+ expect(container.textContent).toContain('This table is no longer available.')
+ expect(container.textContent).not.toContain('workspace')
+ })
+
+ it('renders a file from the server-resolved metadata without a workspace fetch', async () => {
+ render(
+ fileModule,
+ 'preview',
+ true,
+ shareInterface({
+ 'm-file': {
+ kind: 'file',
+ seed: {
+ name: 'quarterly-report.pdf',
+ type: 'application/pdf',
+ size: 2048,
+ version: 1700000000000,
+ },
+ },
+ })
+ )
+ await flush()
+
+ const view = container.querySelector('[data-testid="file-view"]')
+ expect(view?.getAttribute('data-via')).toBe('share')
+ expect(view?.getAttribute('data-address')).toBe('m-file')
+ expect(view?.getAttribute('data-name')).toBe('quarterly-report.pdf')
+ expect(view?.getAttribute('data-read-only')).toBe('true')
+ expect(mockUseWorkspaceFileRecord).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.tsx b/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.tsx
new file mode 100644
index 00000000000..fa2dd3e7e48
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/components/module-renderer/module-renderer.tsx
@@ -0,0 +1,75 @@
+'use client'
+
+import { memo } from 'react'
+import { ChatModule } from '@/components/resources/interface-view/components/module-renderer/components/chat-module'
+import { FileModule } from '@/components/resources/interface-view/components/module-renderer/components/file-module'
+import { FormModule } from '@/components/resources/interface-view/components/module-renderer/components/form-module'
+import { TableModule } from '@/components/resources/interface-view/components/module-renderer/components/table-module'
+import type { InterfaceMode, InterfaceModule } from '@/lib/interfaces/types'
+
+export interface ModuleRendererProps {
+ module: InterfaceModule
+ mode: InterfaceMode
+ /**
+ * Whether this surface is live for the viewer. In the workspace this is the
+ * workspace write permission; on a public share it is the share's run grant —
+ * the token routes are the authority, and a permanently-disabled composer on
+ * a public page is nonsense. Named `canRun` rather than `canEdit` so it stops
+ * reading as a permission claim.
+ */
+ canRun: boolean
+ /**
+ * Present only where the module may author itself: the canvas in edit mode,
+ * for a viewer who can write. Absent everywhere else, so a renderer's
+ * authoring affordances are not mounted rather than mounted and switched off
+ * — the same rule `InterfaceEditing` follows one level up.
+ *
+ * Takes `moduleId` rather than arriving pre-bound so it keeps a stable
+ * identity across renders and this component's memo holds.
+ */
+ onConfigChange?: (moduleId: string, config: InterfaceModule['config'], isValid: boolean) => void
+}
+
+/**
+ * Dispatches a placed module to its renderer — the single boundary the canvas
+ * cell crosses into the module implementations.
+ *
+ * Renderers draw the module's **interior only**: the cell owns the frame
+ * (border, radius, selection ring, drag handle, title bar), so every branch
+ * here fills the space it is given without adding chrome of its own.
+ *
+ * Nothing here carries a workspace or interface id: each renderer reads its
+ * data source from the surrounding `ResourceProvider`, so the same tree serves
+ * the authenticated editor and an anonymous public share without forking.
+ *
+ * The switch is exhaustive over `InterfaceModule['type']`, so adding a module
+ * type to the domain union surfaces here as a compile error rather than a blank
+ * cell at runtime.
+ *
+ * Memoized: every prop is a pure pass-through with no closure, and module
+ * identity is preserved across layout writes, so a drag tick, a selection
+ * click, or an autosave settling never re-reconciles a hundred-row table.
+ */
+function ModuleRendererComponent({ module, mode, canRun, onConfigChange }: ModuleRendererProps) {
+ switch (module.type) {
+ case 'chat':
+ return (
+
+ )
+ case 'form':
+ return (
+
+ )
+ /**
+ * No `mode`: a table and a file render identically in edit and preview, so
+ * the only thing the mode ever decided was whether they could be bound —
+ * which `onConfigChange`'s presence now says directly.
+ */
+ case 'table':
+ return
+ case 'file':
+ return
+ }
+}
+
+export const ModuleRenderer = memo(ModuleRendererComponent)
diff --git a/apps/sim/components/resources/interface-view/index.ts b/apps/sim/components/resources/interface-view/index.ts
new file mode 100644
index 00000000000..fce970c5f28
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/index.ts
@@ -0,0 +1,3 @@
+export type { InterfaceEditing, InterfaceViewProps } from './interface-view'
+export { InterfaceView } from './interface-view'
+export { MODULE_RESOURCE_COPY, type ModuleResourceKind } from './module-resource-copy'
diff --git a/apps/sim/components/resources/interface-view/interface-scope.ts b/apps/sim/components/resources/interface-view/interface-scope.ts
new file mode 100644
index 00000000000..8cbbece695f
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/interface-scope.ts
@@ -0,0 +1,13 @@
+import type { InterfaceModuleSeed, ResourceSource } from '@/resources'
+
+/**
+ * The server-resolved payload behind one module of a **shared** interface, or
+ * `null` in workspace scope (where a module resolves its own resource by id).
+ */
+export function interfaceModuleSeed(
+ source: ResourceSource<'interface'>,
+ moduleId: string
+): InterfaceModuleSeed | null {
+ if (source.via !== 'share') return null
+ return source.seed.modules[moduleId] ?? null
+}
diff --git a/apps/sim/components/resources/interface-view/interface-view.tsx b/apps/sim/components/resources/interface-view/interface-view.tsx
new file mode 100644
index 00000000000..d5825303163
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/interface-view.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import { InterfaceCanvas } from '@/components/resources/interface-view/components/interface-canvas'
+import { InterfacePreviewGrid } from '@/components/resources/interface-view/components/interface-preview-grid'
+import { ResourceProvider } from '@/components/resources/resource-provider'
+import type {
+ InterfaceCell,
+ InterfaceLayout,
+ InterfaceMode,
+ InterfaceModule,
+ InterfaceModuleType,
+} from '@/lib/interfaces/types'
+import { useInterface } from '@/hooks/queries/interfaces'
+import type { ResourceGrants, ResourceHost, ResourceSource } from '@/resources'
+
+/**
+ * Everything the surrounding editor wires into the authoring grid.
+ *
+ * Present only where the mounting surface draws an editor — the interface
+ * detail page and the mothership panel. Absent means the view renders the page
+ * as it ships: nothing to select, drag, add, or remove, because none of that
+ * chrome is mounted rather than mounted and switched off.
+ *
+ * One optional object rather than six loose props, so "is this authorable" is a
+ * single presence check instead of a convention about which props travel
+ * together.
+ */
+export interface InterfaceEditing {
+ mode: InterfaceMode
+ /** `null` = nothing selected. */
+ selectedModuleId: string | null
+ onSelectModule: (moduleId: string | null) => void
+ onAddModule: (type: InterfaceModuleType, cell: InterfaceCell) => void
+ onMoveModule: (moduleId: string, cell: InterfaceCell) => void
+ onRemoveModule: (moduleId: string) => void
+ /**
+ * Applies a config edit a module made about itself — binding its resource,
+ * renaming a form field — so the first edits a builder reaches for happen on
+ * the module rather than only in the inspector. Same choke point the
+ * inspector writes through, so both paths debounce and validate identically.
+ */
+ onUpdateModuleConfig: (
+ moduleId: string,
+ config: InterfaceModule['config'],
+ isValid: boolean
+ ) => void
+}
+
+export interface InterfaceViewProps {
+ source: ResourceSource<'interface'>
+ grants: ResourceGrants
+ host: ResourceHost
+ editing?: InterfaceEditing
+}
+
+/**
+ * One interface, wherever it is mounted: the editor page, the mothership panel,
+ * or a public share.
+ *
+ * The layout is resolved from the source and nowhere else — a share carries the
+ * server-pruned layout in its seed (the page proved every module before it
+ * rendered anything), a workspace source reads the interface detail query. Both
+ * arms then render the identical grid, so the preview a builder sees is the
+ * page a visitor gets.
+ */
+export function InterfaceView({ source, grants, host, editing }: InterfaceViewProps) {
+ const workspaceDefinition = useInterface(
+ source.via === 'workspace' ? source.workspaceId : undefined,
+ source.via === 'workspace' ? source.resourceId : undefined
+ )
+
+ const layout: InterfaceLayout | null =
+ source.via === 'share' ? source.seed.layout : (workspaceDefinition.data?.layout ?? null)
+
+ /**
+ * Whether the interactive modules are live for this viewer.
+ *
+ * One expression for both scopes: every interface run — workspace or share —
+ * executes the deployed workflow through a route that authorizes at `read`,
+ * so the grant alone decides. Anything narrower made a workspace reader less
+ * capable than an anonymous visitor holding a link to the same interface.
+ */
+ const canRun = grants.run
+
+ return (
+
+ {layout === null ? (
+
+ ) : editing ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/sim/components/resources/interface-view/module-chrome.ts b/apps/sim/components/resources/interface-view/module-chrome.ts
new file mode 100644
index 00000000000..e25b4014b83
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/module-chrome.ts
@@ -0,0 +1,102 @@
+/**
+ * Chrome the interface grid shares, so a module's header and the content
+ * beneath it line up instead of each carrying whatever padding its own
+ * implementation happened to bring — and so the authoring grid and the shipped
+ * page are laid out by one declaration rather than two that happen to agree.
+ *
+ * Strings and plain style objects — no components, and React only as a
+ * type-only `CSSProperties` import — so both the renderers and the canvas can
+ * read them.
+ */
+
+import type { CSSProperties } from 'react'
+import type { InterfaceGrid, InterfacePlacement } from '@/lib/interfaces/types'
+
+/**
+ * The horizontal gutter the module frame and its plain-content modules use.
+ *
+ * `px-4` rather than a smaller value because it is the gutter the chat
+ * transcript already has: `ClientChatMessage` is shared with the deployed chat
+ * page, so its `px-4` is the one value here that cannot be changed without
+ * touching a surface outside interfaces. The cell's header bar, the chat
+ * composer, the form, and the chooser column are all aligned to it.
+ *
+ * **Modules that own their own interface do not take this.** A table renders
+ * the EMCN `Table`, and a file renders `FileView` — both bring padding that a
+ * dozen other surfaces already depend on, and re-guttering them here would
+ * either fight those components or drift from them the next time they change.
+ * Their frame still lines up, because the frame is the header bar; only their
+ * interior keeps its own rhythm.
+ */
+export const MODULE_GUTTER_X = 'px-4'
+
+/**
+ * The scroll well the grid sits in — the element that owns the page padding and
+ * the overflow, in both the authoring canvas and the shipped page (including
+ * its empty state, so an interface with no modules is inset exactly like one
+ * with modules).
+ *
+ * Shared because the preview's whole promise is that it cannot lie about the
+ * page: a `p-4` nudged on one surface alone would move every module by 4px
+ * relative to the other and make the editor a slightly wrong picture of what
+ * ships.
+ */
+export const INTERFACE_SCROLL_WELL_CLASS = 'relative min-w-0 flex-1 overflow-auto p-4'
+
+/**
+ * The grid itself. Track counts come from the `--interface-cols` /
+ * `--interface-rows` custom properties {@link interfaceGridStyle} sets, so this
+ * string never assumes a shape and both surfaces can reuse it verbatim.
+ *
+ * Shared for the same reason as the well, and more sharply: the `gap-2` is the
+ * seam between modules, so changing it on one grid only is precisely the drift
+ * that would make the preview disagree with the page it previews.
+ */
+export const INTERFACE_GRID_CLASS =
+ 'grid h-full min-h-0 gap-2 [grid-template-columns:repeat(var(--interface-cols),minmax(0,1fr))] [grid-template-rows:repeat(var(--interface-rows),minmax(0,1fr))]'
+
+/**
+ * The phone layout, added to {@link INTERFACE_GRID_CLASS} by the shipped page
+ * only: below `sm` the authored tracks are dropped and the modules flow in DOM
+ * order at their own height.
+ *
+ * Not applied to the authoring canvas — an author is composing a shape, and a
+ * grid that silently restacks itself would make the placement they are dragging
+ * to unreadable.
+ */
+export const INTERFACE_GRID_STACK_SM_CLASS =
+ 'max-sm:h-auto max-sm:[grid-template-columns:minmax(0,1fr)] max-sm:[grid-template-rows:none]'
+
+/**
+ * The grid's track counts, as the custom properties
+ * {@link INTERFACE_GRID_CLASS} reads.
+ *
+ * Custom properties rather than inline `grid-template-*`: an inline `style`
+ * outranks every media query, so a literal template could not be dropped for
+ * {@link INTERFACE_GRID_STACK_SM_CLASS}. They are also SSR-safe and need no JS,
+ * so there is no hydration flash and no layout jump.
+ */
+export function interfaceGridStyle(grid: InterfaceGrid): CSSProperties {
+ return {
+ '--interface-cols': grid.cols,
+ '--interface-rows': grid.rows,
+ } as CSSProperties
+}
+
+/**
+ * One module's grid area, as the `--module-row` / `--module-col` custom
+ * properties both `InterfaceCell` and `InterfacePane` consume.
+ *
+ * Custom properties for the same reason as {@link interfaceGridStyle}: the pane
+ * has to be able to leave the grid below `sm`, which an inline `gridRow` would
+ * outrank. One function rather than one per surface because the `+ 1` is the
+ * conversion from the domain's 0-based {@link InterfacePlacement} to CSS's
+ * 1-based lines — an off-by-one that must be made identically on both sides or
+ * the preview would place modules a track away from where they were authored.
+ */
+export function modulePlacementStyle(placement: InterfacePlacement): CSSProperties {
+ return {
+ '--module-row': `${placement.row + 1} / span ${placement.rowSpan}`,
+ '--module-col': `${placement.col + 1} / span ${placement.colSpan}`,
+ } as CSSProperties
+}
diff --git a/apps/sim/components/resources/interface-view/module-resource-copy.ts b/apps/sim/components/resources/interface-view/module-resource-copy.ts
new file mode 100644
index 00000000000..76b168dc24f
--- /dev/null
+++ b/apps/sim/components/resources/interface-view/module-resource-copy.ts
@@ -0,0 +1,51 @@
+/**
+ * The copy every surface uses to bind a module to a workspace resource — the
+ * in-canvas picker an unconfigured module renders, and the inspector field the
+ * builder repoints it from. Spelling "Select a workflow" twice is how the two
+ * drift apart.
+ *
+ * Pure strings — no React — so both the renderers and the route tree can read
+ * them, and so the constant costs nothing to import.
+ *
+ * It lives here, beside `module-chrome.ts`, rather than under the interfaces
+ * route because the canvas side is the canonical view: an anonymous share page
+ * mounts `InterfaceView` with no workspace route above it. The route tree may
+ * import from here; nothing here may import from the route tree.
+ */
+
+/** The workspace resources a module can be bound to. */
+export type ModuleResourceKind = 'workflow' | 'table' | 'file'
+
+interface ModuleResourceCopy {
+ /** Chooser header and inspector field title; doubles as the `aria-label`. */
+ title: string
+ placeholder: string
+ searchPlaceholder: string
+ emptyMessage: string
+ /** Shown when a bound id no longer resolves against the workspace list. */
+ missingMessage: string
+}
+
+export const MODULE_RESOURCE_COPY: Record = {
+ workflow: {
+ title: 'Workflow',
+ placeholder: 'Select a workflow',
+ searchPlaceholder: 'Search workflows...',
+ emptyMessage: 'No workflows in this workspace',
+ missingMessage: 'This workflow is no longer in the workspace.',
+ },
+ table: {
+ title: 'Table',
+ placeholder: 'Select a table',
+ searchPlaceholder: 'Search tables...',
+ emptyMessage: 'No tables in this workspace',
+ missingMessage: 'This table is no longer in the workspace.',
+ },
+ file: {
+ title: 'File',
+ placeholder: 'Select a file',
+ searchPlaceholder: 'Search files...',
+ emptyMessage: 'No files in this workspace',
+ missingMessage: 'This file is no longer in the workspace.',
+ },
+}
diff --git a/apps/sim/components/resources/resource-empty-state/index.ts b/apps/sim/components/resources/resource-empty-state/index.ts
new file mode 100644
index 00000000000..dc7ff87381f
--- /dev/null
+++ b/apps/sim/components/resources/resource-empty-state/index.ts
@@ -0,0 +1,2 @@
+export type { ResourceEmptyStateProps } from '@/components/resources/resource-empty-state/resource-empty-state'
+export { ResourceEmptyState } from '@/components/resources/resource-empty-state/resource-empty-state'
diff --git a/apps/sim/components/resources/resource-empty-state/resource-empty-state.tsx b/apps/sim/components/resources/resource-empty-state/resource-empty-state.tsx
new file mode 100644
index 00000000000..1807beed5aa
--- /dev/null
+++ b/apps/sim/components/resources/resource-empty-state/resource-empty-state.tsx
@@ -0,0 +1,42 @@
+import type { ComponentType } from 'react'
+
+export interface ResourceEmptyStateProps {
+ icon: ComponentType<{ className?: string }>
+ /**
+ * The headline, when this fills a whole view — "File not found", "Couldn't
+ * load scheduled task". Omit it inside a cell that already has a title bar:
+ * a titled state is a page telling you why it is blank, an untitled one is a
+ * placeholder inside a frame that is already labelled.
+ */
+ title?: string
+ /** The one sentence explaining the state. Always present. */
+ description: string
+}
+
+/**
+ * The placeholder every resource view falls back to — nothing configured, a
+ * reference to something since deleted, no access, or an empty result.
+ *
+ * It draws its **interior only** and fills its parent: the frame (border,
+ * radius, selection ring, title bar) belongs to whatever mounted it.
+ */
+export function ResourceEmptyState({ icon: Icon, title, description }: ResourceEmptyStateProps) {
+ if (!title) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ )
+}
diff --git a/apps/sim/components/resources/resource-provider/index.ts b/apps/sim/components/resources/resource-provider/index.ts
new file mode 100644
index 00000000000..c5c178e0350
--- /dev/null
+++ b/apps/sim/components/resources/resource-provider/index.ts
@@ -0,0 +1,11 @@
+export type {
+ ResourceContextValue,
+ ResourceProviderProps,
+} from '@/components/resources/resource-provider/resource-provider'
+export {
+ ResourceProvider,
+ useOptionalResource,
+ useOptionalResourceOfKind,
+ useResource,
+ useResourceOfKind,
+} from '@/components/resources/resource-provider/resource-provider'
diff --git a/apps/sim/components/resources/resource-provider/resource-provider.tsx b/apps/sim/components/resources/resource-provider/resource-provider.tsx
new file mode 100644
index 00000000000..be92d0e56eb
--- /dev/null
+++ b/apps/sim/components/resources/resource-provider/resource-provider.tsx
@@ -0,0 +1,116 @@
+'use client'
+
+import { createContext, type ReactNode, useContext, useMemo } from 'react'
+import type { ResourceGrants, ResourceHost, ResourceKind, ResourceSource } from '@/resources'
+
+/** The three axes, resolved once by whoever mounts a resource view. */
+export interface ResourceContextValue {
+ readonly source: ResourceSource
+ readonly grants: ResourceGrants
+ readonly host: ResourceHost
+ /**
+ * Sends the viewer to an in-app path — the router half of {@link ResourceHost}.
+ *
+ * Targets come from `source.hrefFor`; this only performs the move, and only
+ * because a unit must never hold a router of its own: `useRouter()` inside a
+ * view silently assumes it is mounted under a workspace route, which is true
+ * in exactly two of the three hosts. A `'public'` host supplies nothing, so
+ * navigation is inert there by construction rather than by a check at every
+ * call site.
+ */
+ readonly navigate: (path: string) => void
+}
+
+/** A host that owns no router. Module scope, so the context value stays stable. */
+function noNavigation(): void {}
+
+/**
+ * Deliberately has no default that silently works. A default would let a
+ * renderer mounted outside a provider resolve one context's URLs while
+ * rendering in the other's — the exact failure this seam exists to make
+ * impossible — so the absence is a thrown error instead.
+ */
+const ResourceContext = createContext(null)
+
+export interface ResourceProviderProps
+ extends Omit, 'navigate'> {
+ children: ReactNode
+ /**
+ * How this host moves the viewer. Omit it when the host owns no router — a
+ * public share has no workspace route to send anyone to.
+ *
+ * Pass a stable reference (`router.push`, or a `useCallback`); an inline
+ * arrow re-creates the context value on every render of the host.
+ */
+ onNavigate?: (path: string) => void
+}
+
+export function ResourceProvider({
+ source,
+ grants,
+ host,
+ onNavigate,
+ children,
+}: ResourceProviderProps) {
+ const value = useMemo(
+ () => ({ source, grants, host, navigate: onNavigate ?? noNavigation }),
+ [source, grants, host, onNavigate]
+ )
+
+ return {children}
+}
+
+/**
+ * The axes of the resource this subtree is rendering, over every kind. Views
+ * that need a concrete {@link ResourceKind} use {@link useResourceOfKind};
+ * nothing here widens a share source into an addressable one.
+ */
+export function useResource(): ResourceContextValue {
+ const value = useContext(ResourceContext)
+ if (!value) {
+ throw new Error(
+ 'useResource must be rendered inside a . Mount the view with an explicit source, grants, and host.'
+ )
+ }
+ return value
+}
+
+/**
+ * The mounted resource, or `null` when there is none.
+ *
+ * For leaves that a resource view shares with surfaces that render no resource
+ * at all — the markdown image node view renders inside a file, and inside a
+ * standalone markdown field in a modal. Everything that only ever renders under
+ * a view uses {@link useResource} so a missing provider fails loudly.
+ */
+export function useOptionalResource(): ResourceContextValue | null {
+ return useContext(ResourceContext)
+}
+
+/**
+ * The mounted resource, narrowed to one kind.
+ *
+ * `ResourceSource`'s `kind` is not a discriminant across kinds, so nothing
+ * downstream can narrow on its own — this is the one place it happens. A
+ * renderer mounted against another kind fails loudly here rather than silently
+ * addressing the wrong routes.
+ */
+export function useResourceOfKind(kind: K): ResourceContextValue {
+ const value = useResource()
+ if (value.source.kind !== kind) {
+ throw new Error(`A ${kind} renderer was mounted against a ${value.source.kind} resource.`)
+ }
+ return value as ResourceContextValue
+}
+
+/**
+ * The mounted resource of one kind, or `null` when this subtree is not inside
+ * one — for leaves a view shares with surfaces that mount no resource at all.
+ */
+export function useOptionalResourceOfKind(
+ kind: K
+): ResourceContextValue | null {
+ const value = useOptionalResource()
+ if (!value || value.source.kind !== kind) return null
+ return value as ResourceContextValue
+}
diff --git a/apps/sim/components/resources/table-view/cell-formatting.test.ts b/apps/sim/components/resources/table-view/cell-formatting.test.ts
new file mode 100644
index 00000000000..7b30775bee7
--- /dev/null
+++ b/apps/sim/components/resources/table-view/cell-formatting.test.ts
@@ -0,0 +1,75 @@
+/**
+ * @vitest-environment node
+ *
+ * Every surface that draws a table resolves its cell text through the
+ * column-type registry, so a currency column reads `$1,234.50` and a select
+ * column shows its option *name* wherever it is mounted — the tables grid, an
+ * embedded panel, or a public share.
+ *
+ * This pins the registry contract, which is what stops a second surface from
+ * growing its own resolver. It regressed once exactly that way: the interface
+ * module carried a resolver handling only boolean/null/json/date/string and let
+ * currency and select fall through to `JSON.stringify`, so a module rendered
+ * `1234.5` and `opt_open` where the grid rendered `$1,234.50` and `Open`.
+ *
+ * Note the two halves reach the screen differently. Currency is the direct
+ * dependency — `resolveCellRender` calls `formatForDisplay` for it. Select is
+ * not: it resolves to the `select` kind and renders as pills, so these cases
+ * pin the id→name semantics `SelectPill` must agree with, not its render path.
+ */
+import { describe, expect, it } from 'vitest'
+import { columnTypeOf } from '@/lib/table/column-types'
+import type { ColumnDefinition } from '@/lib/table/types'
+
+function column(overrides: Partial & Pick) {
+ return { id: 'col_1', name: 'col', ...overrides } as ColumnDefinition
+}
+
+describe('table cell display formatting', () => {
+ it('formats currency through the registry, not as a bare number', () => {
+ const col = column({ type: 'currency', currencyCode: 'USD' })
+ const text = columnTypeOf(col).formatForDisplay(1234.5, col)
+
+ expect(text).not.toBe('1234.5')
+ expect(text).toContain('1,234.50')
+ })
+
+ it('resolves a select option id to its name', () => {
+ const col = column({
+ type: 'select',
+ options: [
+ { id: 'opt_open', name: 'Open' },
+ { id: 'opt_done', name: 'Done' },
+ ],
+ })
+
+ expect(columnTypeOf(col).formatForDisplay('opt_open', col)).toBe('Open')
+ })
+
+ it('joins a multi-select rather than emitting raw JSON', () => {
+ const col = column({
+ type: 'select',
+ multiple: true,
+ options: [
+ { id: 'opt_a', name: 'Alpha' },
+ { id: 'opt_b', name: 'Beta' },
+ ],
+ })
+ const text = columnTypeOf(col).formatForDisplay(['opt_a', 'opt_b'], col)
+
+ expect(text).not.toContain('opt_a')
+ expect(text).toBe('Alpha, Beta')
+ })
+
+ /**
+ * The registry's completeness gate means every column type has a formatter;
+ * that is what lets the cell layer use one fallback branch instead of a
+ * per-type switch that would drift from the grid's.
+ */
+ it('gives every column type a display formatter', () => {
+ for (const type of ['string', 'number', 'boolean', 'date', 'json', 'select', 'currency']) {
+ const col = column({ type: type as ColumnDefinition['type'] })
+ expect(typeof columnTypeOf(col).formatForDisplay, type).toBe('function')
+ }
+ })
+})
diff --git a/apps/sim/components/resources/table-view/cells/cell-content.tsx b/apps/sim/components/resources/table-view/cells/cell-content.tsx
new file mode 100644
index 00000000000..a512e1d440c
--- /dev/null
+++ b/apps/sim/components/resources/table-view/cells/cell-content.tsx
@@ -0,0 +1,82 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import type { RowExecutionMetadata } from '@/lib/table'
+import type { DisplayColumn } from '../types'
+import { CellRender, resolveCellRender } from './cell-render'
+
+interface CellContentProps {
+ value: unknown
+ exec?: RowExecutionMetadata
+ column: DisplayColumn
+ /**
+ * Current workspace id — lets string cells holding an in-workspace resource URL
+ * render as a tagged-resource chip instead of a plain external link.
+ *
+ * Optional, and that is the security seam: the chip's renderer mounts
+ * workspace-authenticated list queries, so a surface with no workspace identity
+ * (an anonymous share) passes `undefined` and the resolver never emits that kind.
+ * See `cell-render.test.ts`.
+ */
+ workspaceId?: string
+ isEditing: boolean
+ /**
+ * The editing surface, supplied by the host that owns the write path. Injected
+ * rather than imported so a read-only surface never pulls the inline editor —
+ * `apps/sim` has no `sideEffects: false`, so a static import would ship it
+ * regardless of `isEditing`.
+ */
+ editor?: ReactNode
+ /**
+ * Human-readable labels for unmet deps on this row+group, used to render a
+ * "Waiting" pill when the cell hasn't run because something it depends on
+ * is empty. `undefined` (or empty) means no waiting state.
+ */
+ waitingOnLabels?: string[]
+ /** Column is an enrichment output — a completed-but-empty cell renders "Not found". */
+ isEnrichmentOutput?: boolean
+}
+
+/**
+ * Glue layer: maps cell inputs to a typed `CellRenderKind` (via the pure
+ * resolver) and renders the corresponding JSX (via the dumb renderer). The host's
+ * `editor` sits on top when `isEditing` is true. Adding a new cell appearance is a
+ * three-step mechanical change in the colocated files.
+ */
+export function CellContent({
+ value,
+ exec,
+ column,
+ workspaceId,
+ isEditing,
+ editor,
+ waitingOnLabels,
+ isEnrichmentOutput,
+}: CellContentProps) {
+ const kind = resolveCellRender({
+ value,
+ exec,
+ column,
+ waitingOnLabels,
+ isEnrichmentOutput,
+ currentWorkspaceId: workspaceId,
+ })
+
+ /**
+ * `isEditing` alone is not enough to dim the underlying cell. `CellRender`
+ * hides most kinds while editing because an editor is covering them — so a
+ * host that reports editing without supplying one would render a cell that is
+ * neither readable nor editable. Deriving both from the editor's presence
+ * makes that state unreachable rather than merely unused.
+ */
+ const showingEditor = isEditing && Boolean(editor)
+
+ return (
+ <>
+ {showingEditor && (
+ {editor}
+ )}
+
+ >
+ )
+}
diff --git a/apps/sim/components/resources/table-view/cells/cell-render.test.ts b/apps/sim/components/resources/table-view/cells/cell-render.test.ts
new file mode 100644
index 00000000000..73973bd6d1b
--- /dev/null
+++ b/apps/sim/components/resources/table-view/cells/cell-render.test.ts
@@ -0,0 +1,161 @@
+/**
+ * @vitest-environment node
+ *
+ * The security property of `resolveCellRender`: the `sim-resource` cell kind —
+ * the only kind whose renderer mounts workspace-authenticated queries — is
+ * unreachable without a `currentWorkspaceId`. A share-scope consumer passes
+ * `undefined` and gets a plain favicon link instead.
+ *
+ * `SimResourceCell` mounts `useWorkflows`, `useTablesList`, `useKnowledgeBasesQuery`
+ * and `useWorkspaceFiles`, so emitting this kind on a public surface would fire
+ * cookie-authenticated `/api/…` reads from an anonymous viewer. The guard lives in
+ * `resolveSimResourceKind`; these tests are what keep it there.
+ */
+import { describe, expect, it } from 'vitest'
+import type { DisplayColumn } from '@/components/resources/table-view'
+import { resolveCellRender } from '@/components/resources/table-view'
+import type { RowExecutionMetadata } from '@/lib/table'
+
+const WORKSPACE_ID = 'ws_00000000'
+
+function column(overrides: Partial = {}): DisplayColumn {
+ return {
+ id: 'col_link',
+ name: 'link',
+ type: 'string',
+ key: 'col_link',
+ groupSize: 1,
+ groupStartColIndex: 0,
+ headerLabel: 'link',
+ isGroupStart: true,
+ ...overrides,
+ } as DisplayColumn
+}
+
+/** A workflow-output column — the second code path that promotes a value to a link. */
+const workflowColumn = column({ workflowGroupId: 'grp_1', type: 'json', outputBlockId: 'blk_1' })
+
+const completedExec = { status: 'completed' } as RowExecutionMetadata
+
+/**
+ * Every URL shape that addresses a sim resource in the current workspace, in
+ * both the absolute and relative spellings the resolver accepts. Each entry is
+ * asserted to be a *real* `sim-resource` URL (with a workspace id) before being
+ * asserted not to produce that kind without one — so the negative assertions
+ * can never pass vacuously against a URL the resolver simply doesn't recognise.
+ */
+const IN_WORKSPACE_URLS = [
+ `https://sim.ai/workspace/${WORKSPACE_ID}/w/wf_1`,
+ `https://sim.ai/workspace/${WORKSPACE_ID}/tables/tbl_1`,
+ `https://sim.ai/workspace/${WORKSPACE_ID}/knowledge/kb_1`,
+ `https://sim.ai/workspace/${WORKSPACE_ID}/files/file_1`,
+ `/workspace/${WORKSPACE_ID}/w/wf_1`,
+ `/workspace/${WORKSPACE_ID}/tables/tbl_1`,
+ `/workspace/${WORKSPACE_ID}/knowledge/kb_1`,
+ `/workspace/${WORKSPACE_ID}/files/file_1`,
+ `/workspace/${WORKSPACE_ID}/files/file_1?download=1`,
+ `https://sim.ai/workspace/${WORKSPACE_ID}/w/wf_1/`,
+]
+
+/** Both call sites that can promote a cell value to a link. */
+const CELL_PATHS = [
+ { label: 'string column', column: column(), exec: undefined },
+ { label: 'workflow-output column', column: workflowColumn, exec: completedExec },
+] as const
+
+describe('resolveCellRender — sim-resource requires a workspace id', () => {
+ it.each(IN_WORKSPACE_URLS)('emits sim-resource for %s when the workspace id matches', (url) => {
+ for (const path of CELL_PATHS) {
+ const kind = resolveCellRender({
+ value: url,
+ exec: path.exec,
+ column: path.column,
+ waitingOnLabels: undefined,
+ currentWorkspaceId: WORKSPACE_ID,
+ })
+ expect(kind.kind, `${path.label}: ${url}`).toBe('sim-resource')
+ }
+ })
+
+ it.each(IN_WORKSPACE_URLS)('never emits sim-resource for %s without a workspace id', (url) => {
+ for (const path of CELL_PATHS) {
+ const kind = resolveCellRender({
+ value: url,
+ exec: path.exec,
+ column: path.column,
+ waitingOnLabels: undefined,
+ currentWorkspaceId: undefined,
+ })
+ expect(kind.kind, `${path.label}: ${url}`).not.toBe('sim-resource')
+ }
+ })
+
+ it('falls through to a plain external link, not a resource chip', () => {
+ const kind = resolveCellRender({
+ value: `https://sim.ai/workspace/${WORKSPACE_ID}/tables/tbl_1`,
+ exec: undefined,
+ column: column(),
+ waitingOnLabels: undefined,
+ currentWorkspaceId: undefined,
+ })
+ expect(kind).toEqual({
+ kind: 'url',
+ text: `https://sim.ai/workspace/${WORKSPACE_ID}/tables/tbl_1`,
+ href: `https://sim.ai/workspace/${WORKSPACE_ID}/tables/tbl_1`,
+ domain: 'sim.ai',
+ })
+ })
+
+ it('does not emit sim-resource for a URL in a different workspace', () => {
+ const kind = resolveCellRender({
+ value: '/workspace/ws_other/tables/tbl_1',
+ exec: undefined,
+ column: column(),
+ waitingOnLabels: undefined,
+ currentWorkspaceId: WORKSPACE_ID,
+ })
+ expect(kind.kind).not.toBe('sim-resource')
+ })
+
+ it('emits no sim-resource kind for any cell shape when the workspace id is absent', () => {
+ const values: unknown[] = [
+ ...IN_WORKSPACE_URLS,
+ null,
+ undefined,
+ '',
+ 'plain text',
+ 'example.com',
+ 'https://example.com/path',
+ 42,
+ true,
+ { href: `/workspace/${WORKSPACE_ID}/w/wf_1` },
+ [`/workspace/${WORKSPACE_ID}/w/wf_1`],
+ ]
+ const columns: DisplayColumn[] = [
+ column({ type: 'string' }),
+ column({ type: 'number' }),
+ column({ type: 'boolean' }),
+ column({ type: 'date' }),
+ column({ type: 'json' }),
+ column({ type: 'currency', currencyCode: 'USD' }),
+ column({ type: 'select', options: [{ id: 'opt_a', name: 'A' }] }),
+ workflowColumn,
+ ]
+ const execs: Array = [undefined, completedExec]
+
+ for (const value of values) {
+ for (const col of columns) {
+ for (const exec of execs) {
+ const kind = resolveCellRender({
+ value,
+ exec,
+ column: col,
+ waitingOnLabels: undefined,
+ currentWorkspaceId: undefined,
+ })
+ expect(kind.kind, `${col.type} / ${String(value)}`).not.toBe('sim-resource')
+ }
+ }
+ }
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/components/resources/table-view/cells/cell-render.tsx
similarity index 98%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
rename to apps/sim/components/resources/table-view/cells/cell-render.tsx
index 4e16d03912b..27d737502c5 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
+++ b/apps/sim/components/resources/table-view/cells/cell-render.tsx
@@ -4,13 +4,13 @@ import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
import { parse } from 'tldts'
+import { StatusBadge } from '@/components/execution-status'
import { faviconUrl } from '@/lib/core/utils/favicon'
import type { RowExecutionMetadata, SelectOption } from '@/lib/table'
import { columnTypeOf } from '@/lib/table/column-types'
-import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
-import { storageToDisplay } from '../../../utils'
-import { resolveSelectOptions, SelectPill } from '../../select-field'
+import { resolveSelectOptions, SelectPill } from '../select-pill'
import type { DisplayColumn } from '../types'
+import { storageToDisplay } from '../values'
import { SimResourceCell, type SimResourceType } from './sim-resource-cell'
export type CellRenderKind =
diff --git a/apps/sim/components/resources/table-view/cells/index.ts b/apps/sim/components/resources/table-view/cells/index.ts
new file mode 100644
index 00000000000..e183e0bb175
--- /dev/null
+++ b/apps/sim/components/resources/table-view/cells/index.ts
@@ -0,0 +1,3 @@
+export { CellContent } from './cell-content'
+export { CellRender, type CellRenderKind, resolveCellRender } from './cell-render'
+export { SimResourceCell, type SimResourceType } from './sim-resource-cell'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx b/apps/sim/components/resources/table-view/cells/sim-resource-cell.tsx
similarity index 94%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx
rename to apps/sim/components/resources/table-view/cells/sim-resource-cell.tsx
index fdbd2776e97..f146ee1ada9 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell.tsx
+++ b/apps/sim/components/resources/table-view/cells/sim-resource-cell.tsx
@@ -2,8 +2,8 @@
import { useMemo } from 'react'
import { cn } from '@sim/emcn'
-import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
-import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
+import { ContextMentionIcon } from '@/components/chat/context-mention-icon'
+import type { ChatMessageContext } from '@/components/chat/types'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/components/resources/table-view/constants.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts
rename to apps/sim/components/resources/table-view/constants.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/components/resources/table-view/data-row.tsx
similarity index 91%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
rename to apps/sim/components/resources/table-view/data-row.tsx
index b73bf68dc49..887b93ea108 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx
+++ b/apps/sim/components/resources/table-view/data-row.tsx
@@ -1,12 +1,11 @@
'use client'
-import React from 'react'
+import React, { type ReactNode } from 'react'
import { Button, Checkbox, cn, handleKeyboardActivation } from '@sim/emcn'
import { PlayOutline, Square } from '@sim/emcn/icons'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getUnmetGroupDeps } from '@/lib/table/deps'
-import type { SaveReason } from '../../types'
import { CellContent } from './cells'
import {
CELL,
@@ -21,19 +20,24 @@ import { type NormalizedSelection, resolveCellExec } from './utils'
export interface DataRowProps {
row: TableRowType
columns: DisplayColumn[]
- /** Current workspace id — forwarded to cells so in-workspace resource URLs
- * render as tagged-resource chips. */
- workspaceId: string
+ /**
+ * Current workspace id — forwarded to cells so in-workspace resource URLs render
+ * as tagged-resource chips. Optional: a surface with no workspace identity (an
+ * anonymous share) omits it and those chips are never emitted.
+ */
+ workspaceId?: string
+ /**
+ * Builds the inline editing surface for a cell. Supplied by the host that owns
+ * the write path, so a read-only surface renders — and bundles — no editor.
+ */
+ renderCellEditor?: (cell: { row: TableRowType; column: DisplayColumn }) => ReactNode
rowIndex: number
isFirstRow: boolean
editingColumnName: string | null
- initialCharacter: string | null
pendingCellValue: Record | null
normalizedSelection: NormalizedSelection | null
onClick: (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => void
onDoubleClick: (rowId: string, columnName: string, columnKey: string) => void
- onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
- onCancel: () => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
@@ -111,8 +115,6 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.pendingCellValue !== next.pendingCellValue ||
prev.onClick !== next.onClick ||
prev.onDoubleClick !== next.onDoubleClick ||
- prev.onSave !== next.onSave ||
- prev.onCancel !== next.onCancel ||
prev.onContextMenu !== next.onContextMenu ||
prev.onCellMouseDown !== next.onCellMouseDown ||
prev.onCellMouseEnter !== next.onCellMouseEnter ||
@@ -132,9 +134,21 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
) {
return false
}
+
+ /**
+ * `renderCellEditor` is only ever read under `isEditing`, and `isEditing`
+ * requires this row's `editingColumnName` — so a row that is not editing does
+ * not care that the callback's identity changed.
+ *
+ * The gate is load-bearing, not a micro-optimisation: the callback closes over
+ * the grid's pending-update and initial-character state, so it is rebuilt on
+ * every keystroke that opens an editor and on every in-flight save. Comparing
+ * it unconditionally would re-render every mounted row on each of those,
+ * where exactly one row needs to.
+ */
if (
(prev.editingColumnName !== null || next.editingColumnName !== null) &&
- prev.initialCharacter !== next.initialCharacter
+ prev.renderCellEditor !== next.renderCellEditor
) {
return false
}
@@ -154,14 +168,12 @@ export const DataRow = React.memo(function DataRow({
rowIndex,
isFirstRow,
editingColumnName,
- initialCharacter,
pendingCellValue,
+ renderCellEditor,
normalizedSelection,
isRowChecked,
onClick,
onDoubleClick,
- onSave,
- onCancel,
onContextMenu,
onCellMouseDown,
onCellMouseEnter,
@@ -376,9 +388,7 @@ export const DataRow = React.memo(function DataRow({
)}
column={column}
isEditing={isEditing}
- initialCharacter={isEditing ? initialCharacter : undefined}
- onSave={(value, reason) => onSave(row.id, column.key, value, reason)}
- onCancel={onCancel}
+ editor={isEditing ? renderCellEditor?.({ row, column }) : undefined}
waitingOnLabels={
column.workflowGroupId
? (waitingByGroupId?.get(column.workflowGroupId) ?? undefined)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/components/resources/table-view/headers/column-header-menu.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx
rename to apps/sim/components/resources/table-view/headers/column-header-menu.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/components/resources/table-view/headers/column-type-icon.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx
rename to apps/sim/components/resources/table-view/headers/column-type-icon.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts b/apps/sim/components/resources/table-view/headers/index.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts
rename to apps/sim/components/resources/table-view/headers/index.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/components/resources/table-view/headers/workflow-group-meta-cell.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx
rename to apps/sim/components/resources/table-view/headers/workflow-group-meta-cell.tsx
diff --git a/apps/sim/components/resources/table-view/hooks/use-table-source.ts b/apps/sim/components/resources/table-view/hooks/use-table-source.ts
new file mode 100644
index 00000000000..420d4318a4b
--- /dev/null
+++ b/apps/sim/components/resources/table-view/hooks/use-table-source.ts
@@ -0,0 +1,139 @@
+'use client'
+
+import { useCallback } from 'react'
+import { isApiClientError } from '@/lib/api/client/errors'
+import { PUBLIC_TABLE_PAGE_SIZE } from '@/lib/api/contracts/public-interfaces'
+import type { ColumnDefinition, TableRow } from '@/lib/table/types'
+import { usePublicInterfaceTableRows } from '@/hooks/queries/public-interfaces'
+import { type TableRowsResponse, useInfiniteTableRows, useTable } from '@/hooks/queries/tables'
+import type { ResourceSource } from '@/resources'
+import { shareTableSchema } from '@/resources/table-source'
+
+/**
+ * Rows per request in workspace scope. Sized so the first paint fills a panel
+ * without a second round trip.
+ *
+ * Owned here rather than taken from the caller, so two hosts of this view cannot
+ * key differently: `pageSize` is part of the infinite-rows query key.
+ *
+ * Deliberately NOT the tables page's size, and the two should not be unified.
+ * `pageSize` is part of the infinite-rows key, so the page and this view keep
+ * separate row caches — which is correct, because they read differently. The
+ * page requests `TABLE_LIMITS.MAX_QUERY_LIMIT` (1000) so `ensureAllRowsLoaded`
+ * can drain a table for select-all, export and bulk delete in few round trips;
+ * a panel wants its first screen fast and never drains. Forcing one number
+ * would either make a chat panel fetch 1000 rows to show ten, or make every
+ * bulk operation ten times as many requests.
+ *
+ * The expensive shared half *is* shared: the schema is keyed on the table id
+ * alone (`tableKeys.detail`), so every surface showing a given table reads one
+ * entry for its columns.
+ *
+ * A share source uses {@link PUBLIC_TABLE_PAGE_SIZE} instead — the public
+ * contract's hard `limit` ceiling, read from the contract rather than restated.
+ */
+export const TABLE_VIEW_PAGE_SIZE = 100
+
+/** The one page shape both scopes produce. */
+type TablePage = Pick
+
+export interface UseTableSourceResult {
+ /** `null` while the schema is still resolving, or when it could not be resolved. */
+ columns: ColumnDefinition[] | null
+ rows: TableRow[]
+ /** Server-side `COUNT(*)` from the first page; `null` when the server did not report one. */
+ totalCount: number | null
+ isPending: boolean
+ /** The reference is dangling — the table is gone, not merely unreachable. */
+ isMissing: boolean
+ isError: boolean
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ fetchNextPage: () => void
+}
+
+/**
+ * Whether a load failure proves the table reference is dangling.
+ *
+ * Only a 404 establishes that. Every other failure — offline, a 5xx, an expired
+ * session — is transient and leaves the reference perfectly valid, so reporting
+ * it as a deleted table would send the viewer off to un-wire something still
+ * correct.
+ */
+function isMissingTable(error: unknown): boolean {
+ return isApiClientError(error) && error.status === 404
+}
+
+/**
+ * Resolves a table's schema and rows from whichever address the source carries,
+ * so {@link TableView} renders one way in every scope.
+ *
+ * Against a workspace source the schema comes from the shared table detail query
+ * — keyed on the table id alone, so every host of a given table shares one entry
+ * — and rows page in through the shared infinite-rows query. Rows are *not* yet
+ * fully shared with the tables page; see {@link TABLE_VIEW_PAGE_SIZE}.
+ *
+ * Against a share source the schema was already resolved server-side (the page
+ * proved the stored grant before rendering anything) and arrives in the seed;
+ * only rows are fetched, from a route addressed by `(token, grantId)` alone.
+ * There is no table id on the wire there, so a visitor cannot address a table
+ * the share does not grant.
+ *
+ * Both branches' hooks run on every render — the inactive one is
+ * `enabled: false` — so hook order is fixed regardless of source.
+ */
+export function useTableSource(source: ResourceSource<'table'>): UseTableSourceResult {
+ const isWorkspaceScope = source.via === 'workspace'
+
+ const tableQuery = useTable(
+ source.via === 'workspace' ? source.workspaceId : undefined,
+ source.via === 'workspace' ? source.resourceId : undefined
+ )
+ const workspaceRows = useInfiniteTableRows({
+ workspaceId: source.via === 'workspace' ? source.workspaceId : '',
+ tableId: source.via === 'workspace' ? source.resourceId : '',
+ pageSize: TABLE_VIEW_PAGE_SIZE,
+ enabled: isWorkspaceScope,
+ })
+ const shareRows = usePublicInterfaceTableRows({
+ token: source.via === 'share' ? source.token : '',
+ moduleId: source.via === 'share' ? source.grantId : '',
+ pageSize: PUBLIC_TABLE_PAGE_SIZE,
+ enabled: !isWorkspaceScope,
+ })
+
+ /** The one scope's rows query every scope-independent read goes through. */
+ const activeRows = isWorkspaceScope ? workspaceRows : shareRows
+
+ const fetchNextPageFn = activeRows.fetchNextPage
+ const fetchNextPage = useCallback(() => {
+ void fetchNextPageFn()
+ }, [fetchNextPageFn])
+
+ const pages: TablePage[] = activeRows.data?.pages ?? []
+ const rowsSettled = activeRows.data !== undefined
+ const rowsError = activeRows.isError ? activeRows.error : null
+
+ /**
+ * A rows failure is only fatal before anything has loaded. Once pages are in
+ * hand, an error means a *scroll-triggered* page failed — the loaded rows are
+ * still valid, and replacing them with a dangling-reference message would be
+ * both wrong and destructive.
+ */
+ const loadError =
+ isWorkspaceScope && tableQuery.isError ? tableQuery.error : rowsSettled ? null : rowsError
+
+ return {
+ columns: isWorkspaceScope
+ ? (tableQuery.data?.schema.columns ?? null)
+ : shareTableSchema(source).columns,
+ rows: pages.flatMap((page) => page.rows),
+ totalCount: pages[0]?.totalCount ?? null,
+ isPending: (isWorkspaceScope && tableQuery.isPending) || activeRows.isPending,
+ isMissing: isMissingTable(loadError),
+ isError: loadError !== null,
+ hasNextPage: activeRows.hasNextPage,
+ isFetchingNextPage: activeRows.isFetchingNextPage,
+ fetchNextPage,
+ }
+}
diff --git a/apps/sim/components/resources/table-view/index.ts b/apps/sim/components/resources/table-view/index.ts
new file mode 100644
index 00000000000..06fdd2289cc
--- /dev/null
+++ b/apps/sim/components/resources/table-view/index.ts
@@ -0,0 +1,98 @@
+/**
+ * The table resource's view layer — everything that draws a table and nothing
+ * that writes one.
+ *
+ * Moved out of `app/workspace/[workspaceId]/tables/[tableId]/` so it stops being
+ * addressable only from inside the workspace route tree. The editing shell
+ * (`TableGrid` and the mutation surfaces it owns) stays behind and mounts these;
+ * the dependency runs shell → unit and never back.
+ *
+ * The split line is the write path. Nothing exported here mounts a mutation, reads
+ * a permission context, or calls `useParams()`; the two props that would carry
+ * workspace identity — `CellContent.workspaceId` and `DataRow.workspaceId` — are
+ * optional precisely so a surface without one can render. See
+ * `cells/cell-render.test.ts` for the property that makes that safe.
+ *
+ * This is the unit barrel: import from `@/components/resources/table-view`, not
+ * from a file inside it. The exception is a `lazy()`/`dynamic()` split point,
+ * which must use a deep path — `apps/sim` has no `sideEffects: false`, so routing
+ * a split point through a barrel silently re-attaches the chunk.
+ */
+
+export { CellContent, CellRender, type CellRenderKind, resolveCellRender } from './cells'
+export {
+ ADD_COL_WIDTH,
+ CELL,
+ CELL_CHECKBOX,
+ CELL_CONTENT,
+ CELL_HEADER_CHECKBOX,
+ COL_WIDTH,
+ COLUMN_SIDEBAR_WIDTH,
+ SELECTION_OVERLAY,
+ SELECTION_TINT_BG,
+} from './constants'
+export { DataRow, type DataRowProps } from './data-row'
+export {
+ ColumnHeaderMenu,
+ ColumnOptionsMenu,
+ ColumnTypeIcon,
+ columnTypeIcon,
+ WorkflowGroupMetaCell,
+} from './headers'
+export { RemoteSelectionOverlay } from './remote-selection-overlay'
+export {
+ resolveSelectOptions,
+ SelectPill,
+ selectedOptionIds,
+ toSelectedIds,
+} from './select-pill'
+export { TableFind, type TableFindProps } from './table-find'
+export { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives'
+export { TableView, type TableViewProps } from './table-view'
+export type {
+ BlockIconInfo,
+ ColumnSourceInfo,
+ DisplayColumn,
+ EditingCell,
+ RemoteTableSelection,
+ SaveReason,
+} from './types'
+export {
+ buildHeaderGroups,
+ buildTableSelectionContext,
+ type CellCoord,
+ canWriteRowsWithChip,
+ checkboxColLayout,
+ chipRowCount,
+ classifyExecStatusMix,
+ collectRowSnapshots,
+ computeNormalizedSelection,
+ drainTargetForChip,
+ type ExecStatusMix,
+ expandToDisplayColumns,
+ type HeaderGroup,
+ isCellInSelection,
+ moveCell,
+ type NormalizedSelection,
+ ROW_SELECTION_ALL,
+ ROW_SELECTION_NONE,
+ type RowSelection,
+ readExecution,
+ resolveCellExec,
+ rowSelectionCoversAll,
+ rowSelectionIncludes,
+ rowSelectionIsEmpty,
+ rowSelectionMaterialize,
+ selectedColumnIds,
+} from './utils'
+export {
+ cleanCellValue,
+ type DateCellLocalParts,
+ dateValueToLocalParts,
+ displayToStorage,
+ formatValueForInput,
+ generateColumnName,
+ localPartsToDateValue,
+ storageToDisplay,
+ todayLocalCalendarDate,
+} from './values'
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx b/apps/sim/components/resources/table-view/remote-selection-overlay.tsx
similarity index 98%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx
rename to apps/sim/components/resources/table-view/remote-selection-overlay.tsx
index 5461082f3de..b60b3e7c2ea 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/remote-selection-overlay.tsx
+++ b/apps/sim/components/resources/table-view/remote-selection-overlay.tsx
@@ -3,11 +3,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getUserColor, withAlpha } from '@/lib/workspaces/colors'
-import {
- isCellInSelection,
- type NormalizedSelection,
-} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils'
-import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room'
+import type { RemoteTableSelection } from './types'
+import { isCellInSelection, type NormalizedSelection } from './utils'
/** A measured remote selection, positioned in the grid content wrapper's space. */
interface SelectionBox {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx b/apps/sim/components/resources/table-view/select-pill.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/select-field/select-pill.tsx
rename to apps/sim/components/resources/table-view/select-pill.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx b/apps/sim/components/resources/table-view/table-find.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx
rename to apps/sim/components/resources/table-view/table-find.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx b/apps/sim/components/resources/table-view/table-primitives.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx
rename to apps/sim/components/resources/table-view/table-primitives.tsx
diff --git a/apps/sim/components/resources/table-view/table-view.tsx b/apps/sim/components/resources/table-view/table-view.tsx
new file mode 100644
index 00000000000..77ee133dad0
--- /dev/null
+++ b/apps/sim/components/resources/table-view/table-view.tsx
@@ -0,0 +1,193 @@
+'use client'
+
+import { Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@sim/emcn'
+import { Table as TableIcon } from '@sim/emcn/icons'
+import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
+import { getColumnId } from '@/lib/table/column-keys'
+import type { ResourceGrants, ResourceHost, ResourceSource } from '@/resources'
+import { tableWorkspaceId } from '@/resources/table-source'
+import { CellContent } from './cells'
+import { useTableSource } from './hooks/use-table-source'
+import { expandToDisplayColumns } from './utils'
+
+/** Distance from the bottom of the scroller at which the next page is requested. */
+const PREFETCH_PX = 200
+
+/** Stable keys for the loading placeholder rows. */
+const LOADING_ROW_KEYS = ['row-1', 'row-2', 'row-3', 'row-4', 'row-5'] as const
+
+export interface TableViewProps {
+ source: ResourceSource<'table'>
+ /**
+ * Declared and deliberately unread. This view has no write or run path in any
+ * scope, so there is nothing for a grant to unlock — but the axis stays on the
+ * signature because it is the contract every canonical unit is mounted
+ * through, and because the day this grows an editor the capability must come
+ * from here rather than from a consumer inventing a flag.
+ */
+ grants: ResourceGrants
+ /**
+ * Declared and deliberately unread, for the same reason. This view writes no
+ * URL state and holds no router, which is what makes it safe in all three
+ * hosts; the prop records that rather than leaving it implicit.
+ */
+ host: ResourceHost
+}
+
+/**
+ * One table's real contents, from whichever address its {@link ResourceSource}
+ * carries — so the same view serves an embedded panel and an anonymous share.
+ *
+ * Deliberately not the table editor: no sorting, filtering, resizing, selection
+ * or cell editing. Those live in the route's `TableGrid`, which owns the write
+ * path. What this shares with the editor is the part that matters for reading —
+ * {@link CellContent} — so booleans, dates, JSON, links, currency, select pills
+ * and workflow-output state all render exactly as they do in the grid rather
+ * than being approximated per surface.
+ *
+ * `workspaceId` is threaded from the source and is `undefined` in share scope.
+ * That is the security seam, not an optimisation: the `sim-resource` chip's
+ * renderer mounts workspace-authenticated list queries, and without a workspace
+ * id the resolver never emits that kind. See `cells/cell-render.test.ts`.
+ */
+export function TableView({ source }: TableViewProps) {
+ const {
+ columns,
+ rows,
+ totalCount,
+ isPending,
+ isMissing,
+ isError,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = useTableSource(source)
+
+ /**
+ * Pages in the next batch as the scroller nears its end. Guarded on
+ * `hasNextPage` so the final page never fires a request, and on
+ * `isFetchingNextPage` so a fast scroll cannot queue duplicates.
+ */
+ function handleScroll(event: React.UIEvent): void {
+ if (!hasNextPage || isFetchingNextPage) return
+ const { scrollHeight, scrollTop, clientHeight } = event.currentTarget
+ if (scrollHeight - scrollTop - clientHeight > PREFETCH_PX) return
+ fetchNextPage()
+ }
+
+ if (isError) {
+ /**
+ * Deliberately not `source.unavailableCopy`. Two reasons: its wording is
+ * generic where these are specific ("no longer in the workspace" tells an
+ * owner what to fix), and the share arm here is an information-leak
+ * decision — a visitor is never told the table lived in a workspace,
+ * because naming one would leak that the share belongs to it.
+ */
+ const missingMessage =
+ source.via === 'workspace'
+ ? 'This table is no longer in the workspace.'
+ : 'This table is no longer available.'
+ return (
+
+ )
+ }
+
+ if (isPending) {
+ return (
+
+ {LOADING_ROW_KEYS.map((key) => (
+
+ ))}
+
+ )
+ }
+
+ if (!columns || columns.length === 0) {
+ return
+ }
+
+ if (rows.length === 0) {
+ return
+ }
+
+ /**
+ * Workflow groups are absent by design: a share's seed carries the column
+ * schema and nothing about runs, and the public rows contract reads with
+ * `withExecutions: false`. Workflow-output columns therefore resolve on the
+ * value path in every scope this view serves.
+ */
+ const displayColumns = expandToDisplayColumns(columns, [])
+ const workspaceId = tableWorkspaceId(source) ?? undefined
+ const remaining = totalCount !== null && totalCount > rows.length
+
+ return (
+
+
+
+ {/* Pinned so a long table keeps its column labels, as the grid does. */}
+
+
+ {displayColumns.map((column) => (
+
+ {column.headerLabel}
+
+ ))}
+
+
+ {/**
+ * EMCN's `TableBody` drops the last row's rule (`[&_tr:last-child]:border-0`)
+ * so a table can sit flush against a page. Inside a panel the rows end
+ * mid-pane instead, and without the closing line the list looks
+ * truncated — restored here. `!` because that rule targets the same
+ * `tr:last-child` and would otherwise out-specify a row-level class.
+ */}
+
+ {rows.map((row) => (
+
+ {displayColumns.map((column) => (
+
+ {/**
+ * The row rhythm is this view's concern, not the cell's. In the
+ * grid an empty cell renders `null` and the row keeps its height
+ * from the virtualizer; here there is nothing holding it open, so
+ * an all-empty row would collapse to its padding. The floor lives
+ * on a wrapper rather than in `CellRender` so the grid is
+ * unaffected.
+ *
+ * `whitespace-nowrap` is load-bearing, not decoration: the
+ * cell kinds already carry `overflow-clip text-ellipsis`,
+ * but an ellipsis needs a single non-wrapping line to
+ * appear. Without it those classes are inert, text wraps to
+ * several lines, and rows grow unevenly — where the grid
+ * gets the same rule from its own `CELL_CONTENT`.
+ * `min-w-0` lets the flex container shrink below that
+ * now-unwrapped content instead of forcing the column wide.
+ */}
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+ {remaining ? (
+
+ {isFetchingNextPage
+ ? 'Loading more rows…'
+ : `Showing ${rows.length} of ${totalCount} rows.`}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/components/resources/table-view/types.ts
similarity index 55%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts
rename to apps/sim/components/resources/table-view/types.ts
index af5cceea88c..501037f71e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts
+++ b/apps/sim/components/resources/table-view/types.ts
@@ -1,4 +1,5 @@
import type React from 'react'
+import type { TableCellSelection } from '@sim/realtime-protocol/table-presence'
import type { ColumnDefinition } from '@/lib/table'
export interface BlockIconInfo {
@@ -35,3 +36,35 @@ export interface DisplayColumn extends ColumnDefinition {
/** True when this is the leftmost sibling of its group (or non-grouped). */
isGroupStart: boolean
}
+
+/**
+ * A remote viewer's current cell selection, ready to render as a presence overlay.
+ *
+ * Declared beside the grid's own view types rather than in the presence hook: the
+ * overlay that draws it is presentational, while the hook that produces it holds an
+ * authenticated socket session. Keeping the type here lets the overlay stay free of
+ * any dependency on the hook.
+ */
+export interface RemoteTableSelection {
+ socketId: string
+ userId: string
+ userName: string
+ cell: NonNullable
+}
+
+/**
+ * Reason the inline editor completed, used to determine navigation after save
+ */
+export type SaveReason = 'enter' | 'tab' | 'shift-tab' | 'blur'
+
+/**
+ * Tracks which cell is currently being edited inline. `columnKey` distinguishes
+ * fanned-out workflow visual columns (which share the same `columnName`) — set
+ * when the interaction targets a specific visual column (e.g. expanded view),
+ * omitted for plain cells.
+ */
+export interface EditingCell {
+ rowId: string
+ columnName: string
+ columnKey?: string
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/components/resources/table-view/utils.test.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts
rename to apps/sim/components/resources/table-view/utils.test.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/components/resources/table-view/utils.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts
rename to apps/sim/components/resources/table-view/utils.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts b/apps/sim/components/resources/table-view/values.test.ts
similarity index 99%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
rename to apps/sim/components/resources/table-view/values.test.ts
index 5945be6c388..c858116be87 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.test.ts
+++ b/apps/sim/components/resources/table-view/values.test.ts
@@ -9,7 +9,7 @@ import {
formatValueForInput,
localPartsToDateValue,
storageToDisplay,
-} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
+} from '@/components/resources/table-view'
describe('dateValueToLocalParts / localPartsToDateValue', () => {
it('splits calendar dates without a time part and round-trips', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/components/resources/table-view/values.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts
rename to apps/sim/components/resources/table-view/values.ts
diff --git a/apps/sim/components/ui/generated-password-input.test.tsx b/apps/sim/components/ui/chip-password-input.test.tsx
similarity index 79%
rename from apps/sim/components/ui/generated-password-input.test.tsx
rename to apps/sim/components/ui/chip-password-input.test.tsx
index e50ad2d4823..6a2c0d18c4a 100644
--- a/apps/sim/components/ui/generated-password-input.test.tsx
+++ b/apps/sim/components/ui/chip-password-input.test.tsx
@@ -1,45 +1,16 @@
/**
* @vitest-environment jsdom
+ *
+ * Behavioural coverage for the saved-password disclosure flow, carried over
+ * verbatim when `GeneratedPasswordInput` was retired in favour of the canonical
+ * {@link ChipPasswordInput}. The component moved packages; the guarantees did
+ * not, so these assertions still address it the way a user does — by aria-label
+ * and by what the field actually renders.
*/
-import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react'
+import { act } from 'react'
+import { ChipPasswordInput } from '@sim/emcn'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { GeneratedPasswordInput } from '@/components/ui/generated-password-input'
-
-const { mockCopy } = vi.hoisted(() => ({
- mockCopy: vi.fn(async () => true),
-}))
-
-vi.mock('@sim/emcn', () => ({
- Button: ({
- children,
- variant: _variant,
- ...props
- }: {
- children?: ReactNode
- variant?: string
- } & ButtonHTMLAttributes) => {children} ,
- ChipInput: ({
- endAdornment,
- error: _error,
- ...props
- }: {
- endAdornment?: ReactNode
- error?: boolean
- } & InputHTMLAttributes) => (
-
-
- {endAdornment}
-
- ),
- Loader: () => ,
- Tooltip: {
- Root: ({ children }: { children?: ReactNode }) => children,
- Trigger: ({ children }: { children?: ReactNode }) => children,
- Content: () => null,
- },
- useCopyToClipboard: () => ({ copied: false, copy: mockCopy }),
-}))
let container: HTMLDivElement
let root: Root
@@ -47,22 +18,23 @@ let root: Root
interface RenderInputOptions {
fetchCurrentPassword?: () => Promise
onChange?: (value: string) => void
- showGenerate?: boolean
+ onGenerate?: boolean
value?: string
}
function renderInput({
fetchCurrentPassword,
onChange = vi.fn(),
- showGenerate = false,
+ onGenerate = false,
value = '',
}: RenderInputOptions = {}) {
act(() => {
root.render(
- 'g'.repeat(24) : undefined}
fetchCurrentPassword={fetchCurrentPassword}
/>
)
@@ -81,7 +53,7 @@ function passwordButton(label: string): HTMLButtonElement {
return button
}
-describe('GeneratedPasswordInput', () => {
+describe('ChipPasswordInput', () => {
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
@@ -169,7 +141,7 @@ describe('GeneratedPasswordInput', () => {
it('keeps a generated password hidden when the field is hidden', () => {
const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret')
const onChange = vi.fn()
- renderInput({ fetchCurrentPassword, onChange, showGenerate: true })
+ renderInput({ fetchCurrentPassword, onChange, onGenerate: true })
act(() => passwordButton('Generate password').click())
@@ -181,7 +153,7 @@ describe('GeneratedPasswordInput', () => {
it('keeps a generated password visible when the field is visible', async () => {
const fetchCurrentPassword = vi.fn().mockResolvedValue('saved-secret')
const onChange = vi.fn()
- renderInput({ fetchCurrentPassword, onChange, showGenerate: true })
+ renderInput({ fetchCurrentPassword, onChange, onGenerate: true })
await act(async () => passwordButton('Show password').click())
act(() => passwordButton('Generate password').click())
diff --git a/apps/sim/components/ui/generated-password-input.tsx b/apps/sim/components/ui/generated-password-input.tsx
deleted file mode 100644
index b8211479969..00000000000
--- a/apps/sim/components/ui/generated-password-input.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Button, ChipInput, Loader, Tooltip, useCopyToClipboard } from '@sim/emcn'
-import { Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
-import { generatePassword } from '@/lib/core/security/encryption'
-
-const MASKED_PASSWORD = '••••••••'
-
-interface GeneratedPasswordInputProps {
- value: string
- onChange: (value: string) => void
- disabled?: boolean
- placeholder?: string
- /** Show the Generate (random password) action. Off for consumer-facing entry forms. */
- showGenerate?: boolean
- required?: boolean
- autoComplete?: string
- error?: boolean
- /**
- * Resolves the currently saved password when the Show toggle is clicked.
- * While hidden, an empty field displays a masked placeholder.
- */
- fetchCurrentPassword?: () => Promise
-}
-
-/**
- * Password field with reveal / copy / (optional) generate adornments, used by the
- * deploy-as-chat access controls and the file-share modal. Owns its show/copy UI
- * state; the caller owns the value.
- */
-export function GeneratedPasswordInput({
- value,
- onChange,
- disabled = false,
- placeholder,
- showGenerate = true,
- required = false,
- autoComplete = 'new-password',
- error = false,
- fetchCurrentPassword,
-}: GeneratedPasswordInputProps) {
- const [showPassword, setShowPassword] = useState(false)
- const [currentPassword, setCurrentPassword] = useState(null)
- const [isFetchingCurrent, setIsFetchingCurrent] = useState(false)
- const { copied, copy } = useCopyToClipboard()
-
- const displayValue = currentPassword ?? value
- const displayPlaceholder = fetchCurrentPassword && !displayValue ? MASKED_PASSWORD : placeholder
-
- const handleChange = (nextValue: string) => {
- setCurrentPassword(null)
- onChange(nextValue)
- }
-
- const handleGeneratePassword = () => {
- handleChange(generatePassword(24))
- }
-
- const toggleShowPassword = async () => {
- if (showPassword) {
- setShowPassword(false)
- /**
- * Discard the fetched password instead of masking it. Keeping it would
- * leave the plaintext in the input's DOM value and keep Copy armed while
- * the field reads as hidden. A later reveal re-fetches, which also keeps
- * the audit log at one entry per disclosure. An edited value lives in
- * `value` and is deliberately untouched.
- */
- setCurrentPassword(null)
- return
- }
-
- if (!displayValue && fetchCurrentPassword) {
- setIsFetchingCurrent(true)
- try {
- setCurrentPassword(await fetchCurrentPassword())
- } catch {
- return
- } finally {
- setIsFetchingCurrent(false)
- }
- }
-
- setShowPassword(true)
- }
-
- return (
- handleChange(e.target.value)}
- disabled={disabled}
- required={required}
- autoComplete={autoComplete}
- error={error}
- endAdornment={
-
- {showGenerate ? (
-
-
-
-
-
-
-
- Generate
-
-
- ) : null}
-
-
- copy(displayValue)}
- disabled={!displayValue || disabled}
- aria-label='Copy password'
- className='!p-1.5'
- >
- {copied ? : }
-
-
-
- {copied ? 'Copied' : 'Copy'}
-
-
-
-
-
- {isFetchingCurrent ? (
-
- ) : showPassword ? (
-
- ) : (
-
- )}
-
-
-
- {showPassword ? 'Hide' : 'Show'}
-
-
-
- }
- />
- )
-}
diff --git a/apps/sim/components/ui/index.ts b/apps/sim/components/ui/index.ts
index 234f6f50a60..38c0867decf 100644
--- a/apps/sim/components/ui/index.ts
+++ b/apps/sim/components/ui/index.ts
@@ -1,5 +1,4 @@
export { Button, buttonVariants } from './button'
-export { GeneratedPasswordInput } from './generated-password-input'
export { Progress } from './progress'
export {
Select,
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index 5e47ebee4d2..fa6e8287fac 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -85,7 +85,7 @@ type ConfigTab = 'general' | 'providers' | 'blocks' | 'platform'
/** Hoisted: rebuilding this per comparison allocated once per sort step. */
const BLOCK_CATEGORY_ORDER: Record = { triggers: 0, blocks: 1, tools: 2 }
-/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
+/** Public-share auth modes an admin can allow/disallow. `null` config = all allowed. */
const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
{ value: 'public', label: 'Anyone with link' },
{ value: 'password', label: 'Password' },
@@ -308,8 +308,9 @@ const PLATFORM_FEATURES = [
configKey: 'disablePublicApi' as const,
hint: 'Disable public API access to deployed workflows.',
},
- // Chat and Files get a category of their own so their nested auth-mode
- // dropdown (see `featureExtras`) reads as part of the toggle it qualifies.
+ // Chat, Files and Interfaces get a category of their own so their nested
+ // auth-mode dropdown (see `featureExtras`) reads as part of the toggle it
+ // qualifies.
{
id: 'hide-deploy-chatbot',
label: 'Deployment',
@@ -324,6 +325,13 @@ const PLATFORM_FEATURES = [
configKey: 'disablePublicFileSharing' as const,
hint: 'Disable public file-share links.',
},
+ {
+ id: 'disable-public-interface-sharing',
+ label: 'Public Sharing',
+ category: 'Interfaces',
+ configKey: 'disablePublicInterfaceSharing' as const,
+ hint: 'Disable public interface-share links.',
+ },
]
interface OrganizationMemberOption {
@@ -1311,6 +1319,19 @@ export function GroupDetail({
}))
}, [])
+ const interfaceShareAuthValue = useMemo(
+ () => editingConfig.allowedInterfaceShareAuthTypes ?? ALL_FILE_SHARE_AUTH_TYPES,
+ [editingConfig.allowedInterfaceShareAuthTypes]
+ )
+
+ const setInterfaceShareAuthTypes = useCallback((values: string[]) => {
+ setEditingConfig((prev) => ({
+ ...prev,
+ allowedInterfaceShareAuthTypes:
+ values.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : (values as ShareAuthType[]),
+ }))
+ }, [])
+
const chatDeployAuthValue = useMemo(
() => editingConfig.allowedChatDeployAuthTypes ?? ALL_CHAT_DEPLOY_AUTH_TYPES,
[editingConfig.allowedChatDeployAuthTypes]
@@ -1351,6 +1372,15 @@ export function GroupDetail({
disabled={editingConfig.disablePublicFileSharing}
/>
),
+ 'disable-public-interface-sharing': (
+
+ ),
}
/** Persists the editing buffer — name/description are only sent when they changed. */
diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts
index d83b16e8f62..8282abd9e0d 100644
--- a/apps/sim/ee/access-control/utils/permission-check.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.ts
@@ -99,6 +99,13 @@ export class PublicFileSharingNotAllowedError extends Error {
}
}
+export class PublicInterfaceSharingNotAllowedError extends Error {
+ constructor() {
+ super('Public interface sharing is not allowed based on your permission group settings')
+ this.name = 'PublicInterfaceSharingNotAllowedError'
+ }
+}
+
export class ChatDeployAuthNotAllowedError extends Error {
constructor() {
super('This chat authentication mode is not allowed based on your permission group settings')
@@ -268,39 +275,90 @@ export async function getUserPermissionConfig(
return mergeEnvAllowlist(resolved?.config ?? null)
}
+interface PublicSharingPolicy {
+ /** Config key for the master on/off switch. */
+ disableKey: 'disablePublicFileSharing' | 'disablePublicInterfaceSharing'
+ /** Config key for the per-auth-mode allow-list; `null` allows every mode. */
+ allowKey: 'allowedFileShareAuthTypes' | 'allowedInterfaceShareAuthTypes'
+ /** Thrown on either denial. */
+ ErrorClass: new () => Error
+ /** Resource family, for the denial log. */
+ resource: 'file' | 'interface'
+}
+
/**
- * Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
- * group for the workspace disables public file sharing, or — when `authType` is
- * given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
- * allow-list (`null` allows all). No-op when access control doesn't apply
- * (non-enterprise / disabled), so non-governed orgs are unaffected.
+ * Shared body behind {@link validatePublicFileSharing} and
+ * {@link validatePublicInterfaceSharing} so the two gates cannot drift. No-op
+ * when access control doesn't apply (non-enterprise / disabled), so non-governed
+ * orgs are unaffected.
*/
-export async function validatePublicFileSharing(
+async function validatePublicSharing(
userId: string,
workspaceId: string,
- authType?: ShareAuthType
+ authType: ShareAuthType | undefined,
+ policy: PublicSharingPolicy
): Promise {
const config = await getUserPermissionConfig(userId, workspaceId)
if (!config) {
return
}
- if (config.disablePublicFileSharing) {
- throw new PublicFileSharingNotAllowedError()
+ if (config[policy.disableKey]) {
+ throw new policy.ErrorClass()
}
- if (
- authType &&
- config.allowedFileShareAuthTypes !== null &&
- !config.allowedFileShareAuthTypes.includes(authType)
- ) {
- logger.warn('File share auth type blocked by permission group', {
+ const allowedAuthTypes = config[policy.allowKey]
+ if (authType && allowedAuthTypes !== null && !allowedAuthTypes.includes(authType)) {
+ logger.warn('Share auth type blocked by permission group', {
+ resource: policy.resource,
userId,
workspaceId,
authType,
})
- throw new PublicFileSharingNotAllowedError()
+ throw new policy.ErrorClass()
}
}
+const FILE_SHARING_POLICY: PublicSharingPolicy = {
+ disableKey: 'disablePublicFileSharing',
+ allowKey: 'allowedFileShareAuthTypes',
+ ErrorClass: PublicFileSharingNotAllowedError,
+ resource: 'file',
+}
+
+const INTERFACE_SHARING_POLICY: PublicSharingPolicy = {
+ disableKey: 'disablePublicInterfaceSharing',
+ allowKey: 'allowedInterfaceShareAuthTypes',
+ ErrorClass: PublicInterfaceSharingNotAllowedError,
+ resource: 'interface',
+}
+
+/**
+ * Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
+ * group for the workspace disables public file sharing, or — when `authType` is
+ * given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
+ * allow-list (`null` allows all).
+ */
+export async function validatePublicFileSharing(
+ userId: string,
+ workspaceId: string,
+ authType?: ShareAuthType
+): Promise {
+ return validatePublicSharing(userId, workspaceId, authType, FILE_SHARING_POLICY)
+}
+
+/**
+ * Interface counterpart of {@link validatePublicFileSharing}, gated on its own
+ * config keys. A public interface runs workflows and accepts form submissions on
+ * the workspace's compute and billing account, so it carries a switch separate
+ * from the file one rather than inheriting an admin's file-only decision.
+ */
+export async function validatePublicInterfaceSharing(
+ userId: string,
+ workspaceId: string,
+ authType?: ShareAuthType
+): Promise {
+ return validatePublicSharing(userId, workspaceId, authType, INTERFACE_SHARING_POLICY)
+}
+
/**
* Throws {@link ChatDeployAuthNotAllowedError} if the user's effective permission
* group for the workspace doesn't allow the chat deployment's `authType` (i.e. it
diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts
index 4cf18a9f3a2..c45172d3fdf 100644
--- a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts
+++ b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { execFile } from 'node:child_process'
-import { mkdir, mkdtemp, rm, symlink, writeFile as writeLocalFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile as writeLocalFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
@@ -61,70 +61,6 @@ describe('cloud review tools', () => {
expect(source).not.toContain('--unified=20')
})
- it('enforces read-size and canonical path bounds in the actual helper', async () => {
- await installCloudReviewTools(runner)
- const source = writeFile.mock.calls[0][1] as string
- const testDir = await mkdtemp(join(tmpdir(), 'sim-review-tools-'))
- const repoDir = join(testDir, 'repo')
- const scriptPath = join(testDir, 'review-tools.py')
- const outsidePath = join(testDir, 'outside.txt')
-
- try {
- await mkdir(repoDir)
- await writeLocalFile(
- scriptPath,
- source.replace(
- "pathlib.Path('/workspace/repo')",
- `pathlib.Path(${JSON.stringify(repoDir)})`
- )
- )
- await writeLocalFile(join(repoDir, 'safe.txt'), 'one\ntwo\n')
- await writeLocalFile(outsidePath, 'secret')
- await symlink(outsidePath, join(repoDir, 'escape.txt'))
- await mkdir(join(repoDir, '.git'))
- await writeLocalFile(join(repoDir, '.git', 'secret.txt'), 'DO_NOT_EXPOSE')
-
- const execute = (operation: string, args: Record) =>
- execFileAsync('python3', [scriptPath], {
- env: {
- ...process.env,
- REVIEW_TOOL_OPERATION: operation,
- REVIEW_TOOL_ARGS: JSON.stringify(args),
- },
- })
-
- await expect(
- execute('read', { path: 'safe.txt', offset: 1, limit: 2 })
- ).resolves.toMatchObject({
- stdout: '1: one\n2: two',
- })
- await expect(execute('read', { path: '../outside.txt' })).rejects.toMatchObject({
- stderr: expect.stringContaining('path must stay within the repository'),
- })
- await expect(execute('read', { path: 'escape.txt' })).rejects.toMatchObject({
- stderr: expect.stringContaining('path resolves outside the repository'),
- })
-
- const found = await execute('find', { path: '.', pattern: '**/*', limit: 20 })
- expect(found.stdout).toContain('safe.txt')
- expect(found.stdout).not.toContain('.git')
- const searched = await execute('search', {
- path: '.',
- pattern: 'DO_NOT_EXPOSE',
- glob: '**/*',
- literal: true,
- })
- expect(searched.stdout).toBe('No matches found')
-
- await writeLocalFile(join(repoDir, 'large.bin'), Buffer.alloc(5_000_001))
- await expect(execute('read', { path: 'large.bin' })).rejects.toMatchObject({
- stderr: expect.stringContaining('exceeds the 5 MB read limit'),
- })
- } finally {
- await rm(testDir, { recursive: true, force: true })
- }
- })
-
it('validates inline coordinates against an exact local diff', async () => {
await installCloudReviewTools(runner)
const source = writeFile.mock.calls[0][1] as string
diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts
index 98b5c80d15d..2a311ae44d3 100644
--- a/apps/sim/executor/utils/start-block.test.ts
+++ b/apps/sim/executor/utils/start-block.test.ts
@@ -119,6 +119,41 @@ describe('start-block utilities', () => {
expect(output.files).toEqual(files)
})
+ it.concurrent(
+ 'resolves the unified start block for form submissions and coerces values per inputFormat',
+ () => {
+ const startBlock = createBlock('start_trigger', 'start', {
+ subBlocks: {
+ inputFormat: {
+ value: [
+ { name: 'quantity', type: 'number' },
+ { name: 'subscribed', type: 'boolean' },
+ { name: 'notes', type: 'string' },
+ ],
+ },
+ },
+ })
+
+ const resolution = resolveExecutorStartBlock([startBlock], {
+ execution: 'api',
+ isChildWorkflow: false,
+ })
+
+ expect(resolution?.blockId).toBe('start')
+ expect(resolution?.path).toBe(StartBlockPath.UNIFIED)
+ if (!resolution) return
+
+ const output = buildStartBlockOutput({
+ resolution,
+ workflowInput: { quantity: '5', subscribed: 'true', notes: 'hello' },
+ })
+
+ expect(output.quantity).toBe(5)
+ expect(output.subscribed).toBe(true)
+ expect(output.notes).toBe('hello')
+ }
+ )
+
it.concurrent('buildStartBlockOutput normalizes Start files from internal serve URLs', () => {
const block = createBlock('start_trigger', 'start')
const resolution = {
@@ -490,6 +525,93 @@ describe('start-block utilities', () => {
)
})
+ describe('form trigger submissions', () => {
+ it.concurrent('lands every submitted field as a top-level Start output', () => {
+ const block = createBlock('start_trigger', 'start')
+ const resolution = {
+ blockId: 'start',
+ block,
+ path: StartBlockPath.UNIFIED,
+ } as const
+
+ const output = buildStartBlockOutput({
+ resolution,
+ workflowInput: { email: 'ada@sim.ai', message: 'hello', subscribed: false },
+ })
+
+ expect(output.email).toBe('ada@sim.ai')
+ expect(output.message).toBe('hello')
+ expect(output.subscribed).toBe(false)
+ expect(output.input).toBeUndefined()
+ expect(output).not.toHaveProperty('conversationId')
+ })
+
+ it.concurrent('passes undeclared fields through alongside inputFormat-coerced ones', () => {
+ const block = createBlock('start_trigger', 'start', {
+ subBlocks: {
+ inputFormat: {
+ value: [{ name: 'quantity', type: 'number' }],
+ },
+ },
+ })
+ const resolution = {
+ blockId: 'start',
+ block,
+ path: StartBlockPath.UNIFIED,
+ } as const
+
+ const output = buildStartBlockOutput({
+ resolution,
+ workflowInput: { quantity: '7', notes: 'ship fast', subscribed: false },
+ })
+
+ expect(output.quantity).toBe(7)
+ expect(output.notes).toBe('ship fast')
+ expect(output.subscribed).toBe(false)
+ })
+
+ it.concurrent('keeps a submitted false switch value over the inputFormat default', () => {
+ const block = createBlock('start_trigger', 'start', {
+ subBlocks: {
+ inputFormat: {
+ value: [{ name: 'subscribed', type: 'boolean', value: true }],
+ },
+ },
+ })
+ const resolution = {
+ blockId: 'start',
+ block,
+ path: StartBlockPath.UNIFIED,
+ } as const
+
+ const output = buildStartBlockOutput({
+ resolution,
+ workflowInput: { subscribed: false },
+ })
+
+ expect(output.subscribed).toBe(false)
+ })
+
+ it.concurrent('enters a legacy API-trigger workflow at its API trigger', () => {
+ const resolution = resolveExecutorStartBlock([createBlock('api_trigger', 'api')], {
+ execution: 'api',
+ isChildWorkflow: false,
+ })
+
+ expect(resolution?.blockId).toBe('api')
+ expect(resolution?.path).toBe(StartBlockPath.SPLIT_API)
+ })
+
+ it.concurrent('resolves no start block for a chat-only workflow', () => {
+ const resolution = resolveExecutorStartBlock([createBlock('chat_trigger', 'chat')], {
+ execution: 'api',
+ isChildWorkflow: false,
+ })
+
+ expect(resolution).toBeNull()
+ })
+ })
+
describe('EXTERNAL_TRIGGER path', () => {
it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => {
const block = createBlock('webhook', 'start')
diff --git a/apps/sim/hooks/queries/interfaces.ts b/apps/sim/hooks/queries/interfaces.ts
new file mode 100644
index 00000000000..96bbeea50d4
--- /dev/null
+++ b/apps/sim/hooks/queries/interfaces.ts
@@ -0,0 +1,382 @@
+'use client'
+
+/**
+ * React Query hooks for workspace interfaces.
+ */
+
+import { toast } from '@sim/emcn'
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { isApiClientError, isValidationError } from '@/lib/api/client/errors'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type CreateInterfaceBodyInput,
+ createInterfaceContract,
+ deleteInterfaceContract,
+ getInterfaceContract,
+ listInterfacesContract,
+ restoreInterfaceContract,
+ type SubmitInterfaceFormValues,
+ submitInterfaceFormContract,
+ updateInterfaceContract,
+} from '@/lib/api/contracts/interfaces'
+import type { InterfaceDefinition, InterfaceLayout } from '@/lib/interfaces'
+import {
+ INTERFACE_DETAIL_STALE_TIME,
+ INTERFACE_LIST_STALE_TIME,
+ type InterfaceQueryScope,
+ interfaceKeys,
+} from '@/hooks/queries/utils/interface-keys'
+import {
+ toastMutationError,
+ toastNonValidationError,
+} from '@/hooks/queries/utils/mutation-error-toast'
+
+async function fetchInterfaces(
+ workspaceId: string,
+ scope: InterfaceQueryScope,
+ signal?: AbortSignal
+): Promise {
+ const response = await requestJson(listInterfacesContract, {
+ query: { workspaceId, scope },
+ signal,
+ })
+ return response.data.interfaces
+}
+
+async function fetchInterface(
+ workspaceId: string,
+ interfaceId: string,
+ signal?: AbortSignal
+): Promise {
+ const response = await requestJson(getInterfaceContract, {
+ params: { interfaceId },
+ query: { workspaceId },
+ signal,
+ })
+ return response.data
+}
+
+/** Patch an interface across every cached list (any scope) it appears in. */
+function patchCachedLists(
+ queryClient: ReturnType,
+ interfaceId: string,
+ patch: (definition: InterfaceDefinition) => InterfaceDefinition
+) {
+ queryClient.setQueriesData({ queryKey: interfaceKeys.lists() }, (old) =>
+ old?.map((definition) => (definition.id === interfaceId ? patch(definition) : definition))
+ )
+}
+
+/**
+ * Fetch all interfaces for a workspace.
+ */
+export function useInterfacesList(
+ workspaceId?: string,
+ scope: InterfaceQueryScope = 'active',
+ options?: {
+ /** Defer the fetch (e.g. until a menu that needs the list is open). Defaults to `true`. */
+ enabled?: boolean
+ }
+) {
+ return useQuery({
+ queryKey: interfaceKeys.list(workspaceId, scope),
+ queryFn: ({ signal }) => fetchInterfaces(workspaceId as string, scope, signal),
+ enabled: Boolean(workspaceId) && (options?.enabled ?? true),
+ staleTime: INTERFACE_LIST_STALE_TIME,
+ placeholderData: keepPreviousData,
+ })
+}
+
+/**
+ * Fetch a single interface by id.
+ *
+ * Errors are rethrown to the nearest error boundary: a deleted or
+ * cross-workspace id resolves to no data at all, and the editor rendered
+ * against `undefined` is a permanently blank canvas with a `…` breadcrumb.
+ * The route's co-located `error.tsx` is the surface for that case.
+ */
+export function useInterface(workspaceId: string | undefined, interfaceId: string | undefined) {
+ // rq-lint-allow: interfaceId is a globally-unique id; workspaceId is only an authz scope on the fetch and cannot collide across workspaces
+ return useQuery({
+ queryKey: interfaceKeys.detail(interfaceId ?? ''),
+ queryFn: ({ signal }) => fetchInterface(workspaceId as string, interfaceId as string, signal),
+ enabled: Boolean(workspaceId && interfaceId),
+ staleTime: INTERFACE_DETAIL_STALE_TIME,
+ throwOnError: true,
+ })
+}
+
+/**
+ * Create a new interface in a workspace.
+ */
+export function useCreateInterface(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (params: Omit) =>
+ requestJson(createInterfaceContract, {
+ body: { ...params, workspaceId },
+ }),
+ // Create has no inline validation surface — the issue message (or the 409
+ // name-conflict message) must reach the user as a toast.
+ onError: toastMutationError,
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.lists() })
+ },
+ })
+}
+
+interface RenameInterfaceVariables {
+ interfaceId: string
+ name: string
+}
+
+interface RenameInterfaceContext {
+ previousLists: Array<[readonly unknown[], InterfaceDefinition[] | undefined]>
+ previousDetail: InterfaceDefinition | undefined
+}
+
+/**
+ * Rename an interface, optimistically patching the cached lists and detail so
+ * the row and breadcrumb update instantly.
+ */
+export function useRenameInterface(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ interfaceId, name }: RenameInterfaceVariables) =>
+ requestJson(updateInterfaceContract, {
+ params: { interfaceId },
+ body: { workspaceId, name },
+ }),
+ onMutate: async ({ interfaceId, name }): Promise => {
+ await Promise.all([
+ queryClient.cancelQueries({ queryKey: interfaceKeys.lists() }),
+ queryClient.cancelQueries({ queryKey: interfaceKeys.detail(interfaceId) }),
+ ])
+ const previousLists = queryClient.getQueriesData({
+ queryKey: interfaceKeys.lists(),
+ })
+ const previousDetail = queryClient.getQueryData(
+ interfaceKeys.detail(interfaceId)
+ )
+ patchCachedLists(queryClient, interfaceId, (definition) => ({ ...definition, name }))
+ if (previousDetail) {
+ queryClient.setQueryData(interfaceKeys.detail(interfaceId), {
+ ...previousDetail,
+ name,
+ })
+ }
+ return { previousLists, previousDetail }
+ },
+ // Inline rename reverts the field on failure with no message of its own,
+ // so the validation issue (or the 409 name-conflict message) must surface
+ // as a toast.
+ onError: (error, { interfaceId }, context) => {
+ for (const [queryKey, data] of context?.previousLists ?? []) {
+ queryClient.setQueryData(queryKey, data)
+ }
+ if (context?.previousDetail) {
+ queryClient.setQueryData(interfaceKeys.detail(interfaceId), context.previousDetail)
+ }
+ toastMutationError(error)
+ },
+ onSettled: (_data, _error, { interfaceId }) => {
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ },
+ })
+}
+
+interface UpdateInterfaceVariables {
+ interfaceId: string
+ name?: string
+ /** Omitted = unchanged; `null` = clear. */
+ description?: string | null
+ layout?: InterfaceLayout
+ /**
+ * Optimistic-concurrency precondition — the `updatedAt` this layout was
+ * derived from. The server rejects the write with a 409 when the stored row
+ * has moved on. Layout writes only; name and description are last-write-wins.
+ */
+ expectedUpdatedAt?: string
+}
+
+interface UpdateInterfaceContext {
+ previousDetail: InterfaceDefinition | undefined
+}
+
+/**
+ * `INTERFACE_STALE_WRITE` shares its 409 with the name-conflict error, so the
+ * body's `code` is what tells them apart.
+ */
+function isStaleWriteError(error: unknown): boolean {
+ return isApiClientError(error) && error.status === 409 && error.code === 'INTERFACE_STALE_WRITE'
+}
+
+/**
+ * Shown instead of the server's message on a stale write: the server tells the
+ * caller to reload, but the `onSettled` invalidation below already refetches
+ * the record, so the editor re-renders on the latest version on its own.
+ */
+const STALE_WRITE_MESSAGE =
+ 'This interface was changed elsewhere. Your edit was not saved — loading the latest version.'
+
+/**
+ * Patch an interface's name, description, and/or layout. The cached detail is
+ * patched optimistically so the editor's local draft and the cache never
+ * visibly diverge while a save is in flight.
+ */
+export function useUpdateInterface(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({
+ interfaceId,
+ name,
+ description,
+ layout,
+ expectedUpdatedAt,
+ }: UpdateInterfaceVariables) =>
+ requestJson(updateInterfaceContract, {
+ params: { interfaceId },
+ body: { workspaceId, name, description, layout, expectedUpdatedAt },
+ }),
+ onMutate: async ({
+ interfaceId,
+ name,
+ description,
+ layout,
+ }): Promise => {
+ await queryClient.cancelQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ const previousDetail = queryClient.getQueryData(
+ interfaceKeys.detail(interfaceId)
+ )
+ if (previousDetail) {
+ queryClient.setQueryData(interfaceKeys.detail(interfaceId), {
+ ...previousDetail,
+ ...(name !== undefined ? { name } : {}),
+ ...(description !== undefined ? { description } : {}),
+ ...(layout !== undefined ? { layout } : {}),
+ })
+ }
+ return { previousDetail }
+ },
+ /**
+ * Adopt the server's record straight away rather than waiting for the
+ * `onSettled` refetch: it carries the new `updatedAt`, which the next
+ * layout write sends as its precondition. Without this, an edit made inside
+ * the refetch window would assert a superseded `updatedAt` and 409 against
+ * this client's own previous write.
+ */
+ onSuccess: (response, { interfaceId }) => {
+ queryClient.setQueryData(interfaceKeys.detail(interfaceId), response.data)
+ },
+ // The editor has no inline validation surface for layout writes (e.g. a
+ // cross-workspace reference rejection), so the issue message must surface
+ // as a toast.
+ onError: (error, { interfaceId }, context) => {
+ /**
+ * The rollback runs on a stale write too — the optimistic layout was
+ * never persisted, so it must come off the cache either way. What it
+ * restores is itself out of date, which is why the `onSettled`
+ * invalidation below is the thing that actually repairs the editor; the
+ * toast only explains why the edit disappeared.
+ */
+ if (context?.previousDetail) {
+ queryClient.setQueryData(interfaceKeys.detail(interfaceId), context.previousDetail)
+ }
+ if (isStaleWriteError(error)) {
+ toast.error(STALE_WRITE_MESSAGE, { duration: 5000 })
+ return
+ }
+ toastMutationError(error)
+ },
+ onSettled: (_data, error, { interfaceId }) => {
+ /**
+ * On success `onSuccess` already adopted the server's record, so a refetch
+ * here would be a second GET for data the client is holding — on the
+ * editor's debounced config path that doubles the requests per edit. A
+ * failure leaves the rolled-back cache out of date, and that is the case
+ * this invalidation exists to repair.
+ */
+ if (error) queryClient.invalidateQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.lists() })
+ },
+ })
+}
+
+/**
+ * Archive (soft-delete) an interface.
+ */
+export function useDeleteInterface(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (interfaceId: string) =>
+ requestJson(deleteInterfaceContract, {
+ params: { interfaceId },
+ query: { workspaceId },
+ }),
+ onError: toastNonValidationError,
+ onSettled: (_data, _error, interfaceId) => {
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.lists() })
+ queryClient.removeQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ },
+ })
+}
+
+/**
+ * Restore an archived interface.
+ */
+export function useRestoreInterface(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (interfaceId: string) =>
+ requestJson(restoreInterfaceContract, {
+ params: { interfaceId },
+ body: { workspaceId },
+ }),
+ onError: toastNonValidationError,
+ onSettled: (_data, _error, interfaceId) => {
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ },
+ })
+}
+
+interface SubmitInterfaceFormVariables {
+ interfaceId: string
+ moduleId: string
+ /** Keyed by field id (stable across renames), not field name. */
+ values: SubmitInterfaceFormValues
+}
+
+/**
+ * Submit a form module's values, executing its connected workflow. Per-field
+ * validation errors (400 with `details`) are surfaced inline by the form
+ * module, not toasted here.
+ */
+export function useSubmitInterfaceForm(workspaceId: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ interfaceId, moduleId, values }: SubmitInterfaceFormVariables) =>
+ requestJson(submitInterfaceFormContract, {
+ params: { interfaceId, moduleId },
+ body: { workspaceId, values },
+ }),
+ // Per-field errors render inline, so they carry no toast — but the server
+ // validated against field definitions the visitor's cached copy may have
+ // fallen behind, so refetch the definition to re-render the form against
+ // the fields that actually rejected it.
+ onError: (error, { interfaceId }) => {
+ if (isValidationError(error)) {
+ queryClient.invalidateQueries({ queryKey: interfaceKeys.detail(interfaceId) })
+ return
+ }
+ toast.error(error.message, { duration: 5000 })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/public-interface-shares.ts b/apps/sim/hooks/queries/public-interface-shares.ts
new file mode 100644
index 00000000000..fdf7481b84c
--- /dev/null
+++ b/apps/sim/hooks/queries/public-interface-shares.ts
@@ -0,0 +1,59 @@
+import { useMutation } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type AuthenticatePublicInterfaceResponse,
+ authenticatePublicInterfaceContract,
+ requestPublicInterfaceOtpContract,
+ type VerifyPublicInterfaceOtpResponse,
+ verifyPublicInterfaceOtpContract,
+} from '@/lib/api/contracts/public-shares'
+
+/**
+ * Auth-exchange mutations for the public interface page (`/i/[token]`).
+ *
+ * These mint the `interface_auth_{shareId}` cookie the server gate reads; there
+ * is no cached server state behind them, so this module declares no query keys.
+ * The interface's own data never flows through here — every module's payload is
+ * derived server-side from the stored layout.
+ */
+
+/**
+ * Exchanges a share password for an `interface_auth_{shareId}` cookie. On
+ * success the page should `router.refresh()` to re-render the now-authorized
+ * interface.
+ */
+export function usePublicInterfaceAuth(token: string) {
+ return useMutation({
+ mutationFn: ({ password }) =>
+ requestJson(authenticatePublicInterfaceContract, {
+ params: { token },
+ body: { password },
+ }),
+ })
+}
+
+/** Requests a verification code for an email-gated interface share (initial send + resend). */
+export function usePublicInterfaceOtpRequest(token: string) {
+ return useMutation<{ message: string }, Error, { email: string }>({
+ mutationFn: ({ email }) =>
+ requestJson(requestPublicInterfaceOtpContract, {
+ params: { token },
+ body: { email },
+ }),
+ })
+}
+
+/**
+ * Verifies the OTP for an email-gated interface share. On success the server
+ * sets the `interface_auth_{shareId}` cookie; the page should then
+ * `router.refresh()`.
+ */
+export function usePublicInterfaceOtpVerify(token: string) {
+ return useMutation({
+ mutationFn: ({ email, otp }) =>
+ requestJson(verifyPublicInterfaceOtpContract, {
+ params: { token },
+ body: { email, otp },
+ }),
+ })
+}
diff --git a/apps/sim/hooks/queries/public-interfaces.ts b/apps/sim/hooks/queries/public-interfaces.ts
new file mode 100644
index 00000000000..4aef47bd6e8
--- /dev/null
+++ b/apps/sim/hooks/queries/public-interfaces.ts
@@ -0,0 +1,124 @@
+'use client'
+
+/**
+ * React Query hooks for a publicly shared interface's module data.
+ *
+ * Every call here is addressed by `(token, moduleId)` and carries no workspace,
+ * table, file, or workflow identifier — the server derives the resource from the
+ * interface's stored layout. There is deliberately no "list files", "list
+ * tables", or "get interface" hook: everything a visitor can reach is either
+ * resolved server-side into the page's props or fetched through one of these two
+ * module-scoped calls.
+ */
+
+import { useInfiniteQuery, useMutation } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import type { SubmitInterfaceFormValues } from '@/lib/api/contracts/interfaces'
+import {
+ getPublicInterfaceTableRowsContract,
+ type PublicInterfaceTableRowsResponse,
+ submitPublicInterfaceFormContract,
+} from '@/lib/api/contracts/public-interfaces'
+import { countLoadedTableRows } from '@/hooks/queries/utils/table-rows-pagination'
+
+export const publicInterfaceKeys = {
+ all: ['publicInterface'] as const,
+ tableRows: () => [...publicInterfaceKeys.all, 'tableRows'] as const,
+ /** Page size scopes the fetch, so it scopes the cache entry too. */
+ moduleTableRows: (token: string, moduleId: string, pageSize: number) =>
+ [...publicInterfaceKeys.tableRows(), token, moduleId, pageSize] as const,
+}
+
+/** Matches the in-workspace rows query so a shared table feels no staler than an open one. */
+export const PUBLIC_INTERFACE_TABLE_ROWS_STALE_TIME = 30 * 1000
+
+interface FetchPublicInterfaceTableRowsArgs {
+ token: string
+ moduleId: string
+ limit: number
+ offset: number
+ includeTotal: boolean
+ signal?: AbortSignal
+}
+
+async function fetchPublicInterfaceTableRows({
+ token,
+ moduleId,
+ limit,
+ offset,
+ includeTotal,
+ signal,
+}: FetchPublicInterfaceTableRowsArgs): Promise {
+ return requestJson(getPublicInterfaceTableRowsContract, {
+ params: { token, moduleId },
+ query: { limit, offset, includeTotal },
+ signal,
+ })
+}
+
+export interface PublicInterfaceTableRowsParams {
+ token: string
+ moduleId: string
+ pageSize: number
+ enabled?: boolean
+}
+
+/**
+ * Pages a shared table module's rows.
+ *
+ * Continuation is driven by the server's `hasMore` rather than derived from the
+ * page length or the row count: only the server knows where the public row
+ * ceiling cuts the drain off, so a client-side rule would either stop early or
+ * keep asking past the cap. The next offset is the number of rows actually
+ * loaded — not pages × pageSize — so a short page resumes without a gap.
+ *
+ * Page 0 pays for the server-side `COUNT(*)`; later pages skip it, and the
+ * module reads the total off the first page.
+ */
+export function usePublicInterfaceTableRows({
+ token,
+ moduleId,
+ pageSize,
+ enabled = true,
+}: PublicInterfaceTableRowsParams) {
+ return useInfiniteQuery({
+ queryKey: publicInterfaceKeys.moduleTableRows(token, moduleId, pageSize),
+ queryFn: ({ pageParam, signal }) =>
+ fetchPublicInterfaceTableRows({
+ token,
+ moduleId,
+ limit: pageSize,
+ offset: pageParam,
+ includeTotal: pageParam === 0,
+ signal,
+ }),
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.hasMore ? countLoadedTableRows(allPages) : undefined,
+ enabled: Boolean(token && moduleId) && enabled,
+ staleTime: PUBLIC_INTERFACE_TABLE_ROWS_STALE_TIME,
+ })
+}
+
+export interface SubmitPublicInterfaceFormVariables {
+ moduleId: string
+ values: SubmitInterfaceFormValues
+}
+
+/**
+ * Submits a shared form module's values.
+ *
+ * No toast on failure, unlike the in-workspace mutation: per-field errors render
+ * inline from the 400's `details`, and everything else renders in the form's own
+ * footer — a public page should not narrate a workspace's run failures in a
+ * corner notification.
+ */
+export function useSubmitPublicInterfaceForm(token: string) {
+ return useMutation({
+ mutationFn: ({ moduleId, values }: SubmitPublicInterfaceFormVariables) =>
+ requestJson(submitPublicInterfaceFormContract, {
+ params: { token, moduleId },
+ body: { values },
+ }),
+ })
+}
diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts
index 8793405055d..8e3c1e49b78 100644
--- a/apps/sim/hooks/queries/public-shares.ts
+++ b/apps/sim/hooks/queries/public-shares.ts
@@ -5,25 +5,35 @@ import {
type AuthenticatePublicFileResponse,
authenticatePublicFileContract,
getFileShareContract,
+ getInterfaceShareContract,
requestPublicFileOtpContract,
type ShareRecord,
+ type ShareResourceType,
type UpsertFileShareBody,
upsertFileShareContract,
+ upsertInterfaceShareContract,
type VerifyPublicFileOtpResponse,
verifyPublicFileOtpContract,
} from '@/lib/api/contracts/public-shares'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
-export const FILE_SHARE_STALE_TIME = 30 * 1000
+export const RESOURCE_SHARE_STALE_TIME = 30 * 1000
+
+/** The resource families the share modal can publish. Folders ride the file page and have no share UI. */
+export type ShareableResourceType = Extract
/**
- * Query key factories for public shares
+ * Query key factories for public shares.
+ *
+ * One namespace covers every shared resource family: `scopeId` is the workspace
+ * the resource belongs to and `resourceId` the resource itself, so a workspace's
+ * shares invalidate together under a single prefix.
*/
export const shareKeys = {
all: ['publicShares'] as const,
details: () => [...shareKeys.all, 'detail'] as const,
- detail: (workspaceId: string, fileId: string) =>
- [...shareKeys.details(), workspaceId, fileId] as const,
+ detail: (resourceType: ShareResourceType, scopeId: string, resourceId: string) =>
+ [...shareKeys.details(), resourceType, scopeId, resourceId] as const,
}
async function fetchFileShare(
@@ -38,31 +48,91 @@ async function fetchFileShare(
return data.share
}
-export function useFileShare(workspaceId: string, fileId: string, options?: { enabled?: boolean }) {
+async function fetchInterfaceShare(
+ workspaceId: string,
+ interfaceId: string,
+ signal?: AbortSignal
+): Promise {
+ const data = await requestJson(getInterfaceShareContract, {
+ params: { interfaceId },
+ query: { workspaceId },
+ signal,
+ })
+ return data.share
+}
+
+/**
+ * The share record for any shareable resource. One hook serves every resource
+ * family so the shared share modal cannot fork per resource: the query key and
+ * the fetch both branch on the same `resourceType`, which is part of the key.
+ */
+export function useResourceShare(
+ resourceType: ShareableResourceType,
+ workspaceId: string,
+ resourceId: string,
+ options?: { enabled?: boolean }
+) {
return useQuery({
- queryKey: shareKeys.detail(workspaceId, fileId),
- queryFn: ({ signal }) => fetchFileShare(workspaceId, fileId, signal),
- enabled: Boolean(workspaceId) && Boolean(fileId) && (options?.enabled ?? true),
- staleTime: FILE_SHARE_STALE_TIME,
+ queryKey: shareKeys.detail(resourceType, workspaceId, resourceId),
+ queryFn: ({ signal }) =>
+ resourceType === 'file'
+ ? fetchFileShare(workspaceId, resourceId, signal)
+ : fetchInterfaceShare(workspaceId, resourceId, signal),
+ enabled: Boolean(workspaceId) && Boolean(resourceId) && (options?.enabled ?? true),
+ staleTime: RESOURCE_SHARE_STALE_TIME,
})
}
-interface UpsertFileShareVariables extends UpsertFileShareBody {
+interface UpsertResourceShareVariables extends UpsertFileShareBody {
+ resourceType: ShareableResourceType
workspaceId: string
- fileId: string
+ resourceId: string
}
-export function useUpsertFileShare() {
+/**
+ * Saves a share for any shareable resource. Both routes accept the same body
+ * shape ({@link UpsertFileShareBody}); the interface route additionally carries
+ * `workspaceId` in the body. On success the detail cache is seeded with the
+ * saved record; file saves also refresh the files list, whose rows carry a
+ * share badge (the interfaces list carries none, so it needs no invalidation).
+ */
+export function useUpsertResourceShare() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: ({ workspaceId, fileId, ...body }: UpsertFileShareVariables) =>
- requestJson(upsertFileShareContract, {
- params: { id: workspaceId, fileId },
- body,
- }),
- onSuccess: (data, { workspaceId, fileId }) => {
- queryClient.setQueryData(shareKeys.detail(workspaceId, fileId), data.share)
- queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
+ mutationFn: ({
+ resourceType,
+ workspaceId,
+ resourceId,
+ ...body
+ }: UpsertResourceShareVariables) =>
+ resourceType === 'file'
+ ? requestJson(upsertFileShareContract, {
+ params: { id: workspaceId, fileId: resourceId },
+ body,
+ })
+ : requestJson(upsertInterfaceShareContract, {
+ params: { interfaceId: resourceId },
+ body: { ...body, workspaceId },
+ }),
+ onSuccess: (data, { resourceType, workspaceId, resourceId }) => {
+ queryClient.setQueryData(shareKeys.detail(resourceType, workspaceId, resourceId), data.share)
+ },
+ /**
+ * Both the share record and the file row's share badge are reconciled on
+ * failure: a partial failure — share written, response lost — would
+ * otherwise leave the modal showing a pre-save record and the badge stale
+ * until something else happened to invalidate them. On success `onSuccess`
+ * already adopted the server's record, so only the list needs refreshing.
+ */
+ onSettled: (_data, error, { resourceType, workspaceId, resourceId }) => {
+ if (error) {
+ queryClient.invalidateQueries({
+ queryKey: shareKeys.detail(resourceType, workspaceId, resourceId),
+ })
+ }
+ if (resourceType === 'file') {
+ queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
+ }
},
onError: (error) => {
toast.error(error.message)
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index a1f13eba635..8fdadaeaa31 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -16,12 +16,7 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
-import {
- ApiClientError,
- extractValidationIssues,
- isApiClientError,
- isValidationError,
-} from '@/lib/api/client/errors'
+import { ApiClientError, isApiClientError, isValidationError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type { ContractJsonResponse } from '@/lib/api/contracts'
import {
@@ -109,6 +104,10 @@ import {
} from '@/lib/table/deps'
import { runUploadStrategy } from '@/lib/uploads/client/direct-upload'
import { useTimezone } from '@/hooks/queries/general-settings'
+import {
+ toastMutationError,
+ toastNonValidationError,
+} from '@/hooks/queries/utils/mutation-error-toast'
import {
TABLE_LIST_STALE_TIME,
TABLE_VIEWS_STALE_TIME,
@@ -539,9 +538,7 @@ export function useCreateTable(workspaceId: string) {
},
// Unlike row writes, table naming has no inline validation surface — the
// issue message (e.g. the NAME_PATTERN rule) must reach the user as a toast.
- onError: (error) => {
- toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 })
- },
+ onError: toastMutationError,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
@@ -563,8 +560,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateTableSchemaOnly(queryClient, tableId)
@@ -587,9 +583,7 @@ export function useRenameTable(workspaceId: string) {
},
// Inline rename reverts the field on failure with no message of its own, so
// the validation issue (e.g. the NAME_PATTERN rule) must surface as a toast.
- onError: (error) => {
- toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 })
- },
+ onError: toastMutationError,
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(variables.tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
@@ -698,8 +692,7 @@ export function useDeleteTable(workspaceId: string) {
},
onError: (error, tableId) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: (_data, _error, tableId) => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
@@ -1067,8 +1060,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
})
}
@@ -1141,8 +1133,7 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
})
}
@@ -1162,8 +1153,7 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext)
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
@@ -1211,8 +1201,7 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext)
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
@@ -1313,8 +1302,7 @@ export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationCon
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
})
}
@@ -1382,8 +1370,7 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: (_data, _error, variables, context) => {
// A type change, a select single↔multi toggle, or removing an option
@@ -1705,8 +1692,7 @@ export function useRestoreTable() {
},
onError: (error, tableId) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSuccess: (response, tableId) => {
queryClient.setQueryData(tableKeys.detail(tableId), response.data.table)
@@ -2067,8 +2053,7 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
})
}
@@ -2179,8 +2164,7 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
}
}
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
@@ -2480,8 +2464,7 @@ export function useAddWorkflowGroup({ workspaceId, tableId }: RowMutationContext
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
@@ -2514,8 +2497,7 @@ export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationCont
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
@@ -2539,8 +2521,7 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
- if (isValidationError(error)) return
- toast.error(error.message, { duration: 5000 })
+ toastNonValidationError(error)
},
onSettled: () => {
invalidateTableSchema(queryClient, tableId)
diff --git a/apps/sim/hooks/queries/utils/interface-keys.ts b/apps/sim/hooks/queries/utils/interface-keys.ts
new file mode 100644
index 00000000000..c256d9f912e
--- /dev/null
+++ b/apps/sim/hooks/queries/utils/interface-keys.ts
@@ -0,0 +1,24 @@
+/**
+ * React Query key factory for workspace interfaces.
+ *
+ * Lives in this standalone (non-`'use client'`) module — like
+ * {@link file://./table-keys.ts} — so it can be imported from server
+ * components (e.g. the interfaces page prefetch) without pulling in the
+ * `'use client'` `@/hooks/queries/interfaces` module, whose exports would
+ * otherwise resolve to client-reference stubs on the server.
+ */
+
+/** List scope for workspace interfaces — active (default) or archived (soft-deleted). */
+export type InterfaceQueryScope = 'active' | 'archived'
+
+export const interfaceKeys = {
+ all: ['interfaces'] as const,
+ lists: () => [...interfaceKeys.all, 'list'] as const,
+ list: (workspaceId?: string, scope: InterfaceQueryScope = 'active') =>
+ [...interfaceKeys.lists(), workspaceId ?? '', scope] as const,
+ details: () => [...interfaceKeys.all, 'detail'] as const,
+ detail: (id?: string) => [...interfaceKeys.details(), id ?? ''] as const,
+}
+
+export const INTERFACE_LIST_STALE_TIME = 30 * 1000
+export const INTERFACE_DETAIL_STALE_TIME = 30 * 1000
diff --git a/apps/sim/hooks/queries/utils/mutation-error-toast.ts b/apps/sim/hooks/queries/utils/mutation-error-toast.ts
new file mode 100644
index 00000000000..e0072261d81
--- /dev/null
+++ b/apps/sim/hooks/queries/utils/mutation-error-toast.ts
@@ -0,0 +1,24 @@
+import { toast } from '@sim/emcn'
+import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors'
+
+const ERROR_TOAST_DURATION_MS = 5000
+
+/**
+ * Toasts a mutation failure for surfaces with no inline validation UI: the
+ * first validation issue's message when the error is a validation failure,
+ * otherwise the error's own message.
+ */
+export function toastMutationError(error: Error): void {
+ toast.error(extractValidationIssues(error)[0]?.message ?? error.message, {
+ duration: ERROR_TOAST_DURATION_MS,
+ })
+}
+
+/**
+ * Toasts a mutation failure unless it is a validation error — for mutations
+ * whose validation failures are surfaced inline by the calling UI.
+ */
+export function toastNonValidationError(error: Error): void {
+ if (isValidationError(error)) return
+ toast.error(error.message, { duration: ERROR_TOAST_DURATION_MS })
+}
diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx
index db51e9fc452..94dfb9ad2bb 100644
--- a/apps/sim/hooks/queries/workspace-files.test.tsx
+++ b/apps/sim/hooks/queries/workspace-files.test.tsx
@@ -13,6 +13,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
+import { workspaceSource } from '@/resources'
+
+const SOURCE = workspaceSource({ kind: 'file', workspaceId: 'ws-1', resourceId: 'file-1' })
let fetchCount = 0
@@ -40,7 +43,7 @@ function renderContentHook(options?: {
const root: Root = createRoot(container)
function Probe() {
- useWorkspaceFileContent('ws-1', 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options)
+ useWorkspaceFileContent(SOURCE, 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options)
return null
}
diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts
index ad49ba3e283..859a9f3d727 100644
--- a/apps/sim/hooks/queries/workspace-files.ts
+++ b/apps/sim/hooks/queries/workspace-files.ts
@@ -23,7 +23,8 @@ import {
} from '@/lib/uploads/client/direct-upload'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { UserFile } from '@/executor/types'
-import { useFileContentSource } from '@/hooks/use-file-content-source'
+import type { ResourceSource } from '@/resources'
+import { fileCacheScope, fileContentUrl } from '@/resources/file-source'
const logger = createLogger('WorkspaceFilesQuery')
@@ -150,18 +151,18 @@ async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Pro
* as it flips — no re-render required.
*/
export function useWorkspaceFileContent(
- workspaceId: string,
+ source: ResourceSource<'file'>,
fileId: string,
key: string,
raw?: boolean,
options?: { refetchInterval?: number | false | (() => number | false) }
) {
- const source = useFileContentSource()
+ const scope = fileCacheScope(source)
return useQuery({
- queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text', key),
+ queryKey: workspaceFilesKeys.content(scope, fileId, raw ? 'raw' : 'text', key),
queryFn: ({ signal }) =>
- fetchWorkspaceFileContent(source.buildUrl(key, { raw, bust: true }), signal),
- enabled: !!workspaceId && !!fileId && !!key,
+ fetchWorkspaceFileContent(fileContentUrl(source, key, { raw, bust: true }), signal),
+ enabled: !!scope && !!fileId && !!key,
staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME,
refetchOnWindowFocus: 'always',
refetchInterval: options?.refetchInterval ?? false,
@@ -218,20 +219,20 @@ async function fetchWorkspaceFileBinary(
* open, keyed to the current content rather than a stale cached entry).
*/
export function useWorkspaceFileBinary(
- workspaceId: string,
+ source: ResourceSource<'file'>,
fileId: string,
key: string,
options?: { enabled?: boolean; version?: string | number }
) {
- const source = useFileContentSource()
+ const scope = fileCacheScope(source)
return useQuery({
queryKey:
options?.version != null
- ? [...workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), options.version]
- : workspaceFilesKeys.content(workspaceId, fileId, 'binary', key),
+ ? [...workspaceFilesKeys.content(scope, fileId, 'binary', key), options.version]
+ : workspaceFilesKeys.content(scope, fileId, 'binary', key),
queryFn: ({ signal }) =>
fetchWorkspaceFileBinary(
- source.buildUrl(key, { version: options?.version, bust: true }),
+ fileContentUrl(source, key, { version: options?.version, bust: true }),
options?.version,
signal
),
@@ -239,7 +240,7 @@ export function useWorkspaceFileBinary(
// content) so we don't 409-poll the serve route for a generated doc whose
// compiled artifact hasn't been written yet — the doc is fetched once, when
// it's actually ready, instead of hammering the serve URL through generation.
- enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true),
+ enabled: !!scope && !!fileId && !!key && (options?.enabled ?? true),
staleTime: WORKSPACE_FILE_BINARY_STALE_TIME,
refetchOnWindowFocus: 'always',
placeholderData: keepPreviousData,
diff --git a/apps/sim/hooks/use-drag-reorder.ts b/apps/sim/hooks/use-drag-reorder.ts
new file mode 100644
index 00000000000..f54646e69e8
--- /dev/null
+++ b/apps/sim/hooks/use-drag-reorder.ts
@@ -0,0 +1,105 @@
+'use client'
+
+import { type DragEvent, useCallback, useState } from 'react'
+
+/** Props spread onto one reorderable row. */
+export interface DragReorderItemProps {
+ draggable: boolean
+ onDragStart: (event: DragEvent) => void
+ onDragEnd: () => void
+ onDragOver: (event: DragEvent) => void
+ onDrop: (event: DragEvent) => void
+}
+
+export interface DragReorder {
+ /** Index being dragged, or `null` when idle — for dimming the source row. */
+ draggingIndex: number | null
+ /** Index currently hovered by the drag, or `null` — for the drop indicator. */
+ overIndex: number | null
+ itemProps: (index: number) => DragReorderItemProps
+}
+
+/**
+ * Reorder a list by dragging its rows, using native HTML5 drag-and-drop.
+ *
+ * Native DnD rather than a pointer-event implementation for one concrete
+ * reason: the browser auto-scrolls the nearest scrollable ancestor while a
+ * drag is near its edge. A hand-rolled version has to reimplement that, and a
+ * form long enough to need reordering is exactly the one that needs to scroll
+ * while doing it.
+ *
+ * Shared by both surfaces that reorder form fields — the module on the canvas
+ * and the inspector panel — so the two behave identically rather than each
+ * growing its own drag state.
+ *
+ * @param onReorder receives source and destination indices. Called only for a
+ * real move, so a drop on the row being dragged is already filtered out.
+ * @param enabled `false` leaves every row inert — a read-only viewer gets no
+ * drag affordance at all rather than one that silently does nothing.
+ */
+export function useDragReorder(
+ onReorder: (from: number, to: number) => void,
+ enabled = true
+): DragReorder {
+ const [draggingIndex, setDraggingIndex] = useState(null)
+ const [overIndex, setOverIndex] = useState(null)
+
+ const itemProps = useCallback(
+ (index: number): DragReorderItemProps => ({
+ draggable: enabled,
+ onDragStart: (event) => {
+ if (!enabled) {
+ event.preventDefault()
+ return
+ }
+ /**
+ * A reorderable row can sit inside another draggable — a form field
+ * inside an interface module's cell. Without this the native event
+ * bubbles, the cell overwrites the drag payload with its own module id,
+ * and dropping the field relocates the whole module instead.
+ */
+ event.stopPropagation()
+ event.dataTransfer.effectAllowed = 'move'
+ /** Firefox refuses to start a drag with an empty data transfer. */
+ event.dataTransfer.setData('text/plain', String(index))
+ setDraggingIndex(index)
+ },
+ /** Fires for both a completed and a cancelled drag — the only teardown needed. */
+ onDragEnd: () => {
+ setDraggingIndex(null)
+ setOverIndex(null)
+ },
+ onDragOver: (event) => {
+ if (draggingIndex === null) return
+ event.stopPropagation()
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setOverIndex((previous) => (previous === index ? previous : index))
+ },
+ onDrop: (event) => {
+ event.stopPropagation()
+ event.preventDefault()
+ const from = draggingIndex
+ setDraggingIndex(null)
+ setOverIndex(null)
+ if (from === null || from === index) return
+ onReorder(from, index)
+ },
+ }),
+ [enabled, draggingIndex, onReorder]
+ )
+
+ return { draggingIndex, overIndex, itemProps }
+}
+
+/**
+ * Moves `from` to `to`, returning a new array. The one definition of what a
+ * reorder does, so the canvas and the inspector cannot disagree about where a
+ * dropped field lands.
+ */
+export function reorderList