From e5ead97d3ade79f02b9e557c5aaf67e257f79436 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 10:18:55 -0700 Subject: [PATCH 01/58] ui improvements --- .../resource-header/resource-header.tsx | 4 + .../column-sidebar/column-sidebar.tsx | 494 +++++++----------- .../components/table/cells/cell-content.tsx | 48 +- .../table/headers/column-header-menu.tsx | 2 +- .../headers/workflow-group-meta-cell.tsx | 39 +- .../[tableId]/components/table/table.tsx | 50 +- apps/sim/background/resume-execution.ts | 140 +++++ .../background/workflow-column-execution.ts | 46 +- apps/sim/hooks/queries/tables.ts | 41 +- apps/sim/lib/table/cell-write.ts | 22 + apps/sim/lib/table/service.ts | 64 +-- apps/sim/lib/table/types.ts | 2 +- apps/sim/lib/table/workflow-columns.ts | 98 +++- apps/sim/tools/exa/search.ts | 2 +- 14 files changed, 637 insertions(+), 415 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 22686115782..9b4392d0110 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -55,6 +55,8 @@ interface ResourceHeaderProps { breadcrumbs?: BreadcrumbItem[] create?: CreateAction actions?: HeaderAction[] + /** Arbitrary content rendered in the right-aligned actions row, before `actions`. */ + leadingActions?: React.ReactNode /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */ trailingActions?: React.ReactNode /** @@ -71,6 +73,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, create, actions, + leadingActions, trailingActions, createTrigger, }: ResourceHeaderProps) { @@ -106,6 +109,7 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
+ {leadingActions} {actions?.map((action) => { const ActionIcon = action.icon return ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx index cd72fe26d57..3e20cec22c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx @@ -5,24 +5,15 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - ChevronDown, - ChevronRight, - ExternalLink, - Loader2, - Plus, - RepeatIcon, - SplitIcon, - X, -} from 'lucide-react' +import { ExternalLink, Loader2, RepeatIcon, SplitIcon, X } from 'lucide-react' import { Button, - Checkbox, Combobox, - Expandable, - ExpandableContent, + type ComboboxOption, + type ComboboxOptionGroup, Input, Label, + Loader, Switch, Tooltip, toast, @@ -206,175 +197,117 @@ function FieldError({ message }: { message: string }) { * up. Color mirrors the group-header deploy badge: `red` for blocking states, * `amber` for soft warnings. */ -function WarningRow({ - tone, - message, - action, -}: { - tone: 'red' | 'amber' - message: string - action: React.ReactNode -}) { - return ( -
- - {message} - -
{action}
-
- ) -} +const DEP_VALUE_PREFIX_COLUMN = 'col:' +const DEP_VALUE_PREFIX_GROUP = 'group:' /** - * Collapsible "Run settings" section. Collapsed by default since outputs are - * the primary focus of the workflow flow — most users never need to touch - * the trigger conditions. The header shows a one-line summary of when the - * group will fire so the current state is visible without expanding. + * "Run after" picker: which upstream columns and workflow groups must be + * filled before this group fires. Same Combobox shape as the Output columns + * picker. Empty selection = the group fires on any row change. */ function RunSettingsSection({ - open, - onOpenChange, - summary, scalarDepColumns, groupDepOptions, deps, groupDeps, workflows, - onToggleDep, - onToggleGroupDep, + onChangeDeps, + onChangeGroupDeps, }: { - open: boolean - onOpenChange: (open: boolean) => void - summary: string scalarDepColumns: ColumnDefinition[] groupDepOptions: WorkflowGroup[] deps: string[] groupDeps: string[] workflows: WorkflowMetadata[] | undefined - onToggleDep: (name: string) => void - onToggleGroupDep: (groupId: string) => void + onChangeDeps: (next: string[]) => void + onChangeGroupDeps: (next: string[]) => void }) { + const groups = useMemo(() => { + const result: ComboboxOptionGroup[] = [] + if (scalarDepColumns.length > 0) { + result.push({ + section: 'Columns', + items: scalarDepColumns.map((c) => ({ + label: c.name, + value: `${DEP_VALUE_PREFIX_COLUMN}${c.name}`, + })), + }) + } + if (groupDepOptions.length > 0) { + result.push({ + section: 'Workflow groups', + items: groupDepOptions.map((g) => { + const wf = workflows?.find((w) => w.id === g.workflowId) + const color = wf?.color ?? 'var(--text-muted)' + const label = g.name ?? wf?.name ?? 'Workflow' + return { + label, + value: `${DEP_VALUE_PREFIX_GROUP}${g.id}`, + iconElement: ( +
-

- Reject duplicate values across rows. -

)} @@ -1092,7 +1011,28 @@ export function ColumnSidebar({
- +
+ + {startBlockInputs.blockId && missingInputColumnNames.length > 0 && ( + + + + + + Adds {missingInputColumnNames.join(', ')} to the workflow's Start block + + + )} +
{workflowState.isLoading ? (
@@ -1163,103 +1103,36 @@ export function ColumnSidebar({ {showValidation && !selectedWorkflowId && ( )} - {selectedWorkflowId && - startBlockInputs.blockId && - missingInputColumnNames.length > 0 && ( - - - - - - Adds {missingInputColumnNames.join(', ')} to the workflow's Start block - - - } - /> - )}
Output columns -

- Each picked output becomes a column filled with the workflow's value for that - field. -

-
- {workflowState.isLoading ? ( -
- Loading workflow… -
- ) : blockOutputGroups.length === 0 ? ( -
- No outputs found. -
- ) : ( - blockOutputGroups.map((group, gi) => ( -
-
- - - {group.blockName} - -
- {group.paths.map((path) => { - const encoded = encodeOutputValue(group.blockId, path) - const checked = selectedOutputs.includes(encoded) - return ( -
toggleOutput(encoded)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - toggleOutput(encoded) - } - }} - className='flex h-[28px] flex-shrink-0 cursor-pointer items-center gap-2 px-2.5 hover:bg-[var(--surface-2)]' - > - - - {path} - -
- ) - })} -
- )) - )} -
+ + {selectedOutputs.length === 0 + ? 'Select outputs' + : `${selectedOutputs.length} selected`} + + } + /> {showValidation && selectedWorkflowId && selectedOutputs.length === 0 && ( )} @@ -1268,32 +1141,19 @@ export function ColumnSidebar({ )}
-
- {saveError ? ( -

- {saveError} -

- ) : ( - - )} +
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx index 57457056af0..69092ab6615 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx @@ -1,11 +1,10 @@ 'use client' import type React from 'react' -import { Circle } from 'lucide-react' import { Checkbox } from '@/components/emcn' -import { Loader } from '@/components/emcn/icons/loader' import { cn } from '@/lib/core/utils/cn' import type { RowExecutionMetadata } from '@/lib/table' +import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' import type { SaveReason } from '../../../types' import { storageToDisplay } from '../../../utils' import type { DisplayColumn } from '../types' @@ -59,11 +58,8 @@ export function CellContent({ const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) if (blockError) { displayContent = ( - - Error + + ) } else if (hasValue) { @@ -73,36 +69,22 @@ export function CellContent({ ) } else if ( - (exec?.status === 'running' || exec?.status === 'pending') && + (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending') && !(groupHasBlockErrors && !blockRunning) ) { - // Motion only when this cell's own block is in flight. Pending and - // upstream-blocked Waiting render as static dots — the moving spinner - // is reserved for "right now, actually running". - if (blockRunning) { - displayContent = ( -
- - - Running - -
- ) - } else { - const label = exec.status === 'pending' ? 'Pending' : 'Waiting' - displayContent = ( -
- - - {label} - -
- ) - } + // Treat queued / pending / waiting (running but this block hasn't + // started) as a single "Pending" state — only the actively-executing + // block flips to "Running". + displayContent = } else if (exec?.status === 'cancelled') { + displayContent = + } else if (exec?.status === 'error') { + // Group-level failure (executor blew up, missing credentials, validation) + // — no specific block produced the error so `blockErrors` is empty. + // Surface the top-level error on every output cell in the group. displayContent = ( - - Cancelled + + ) } else { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx index da955ee1322..cde63c50dcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx @@ -315,7 +315,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ draggable={false} aria-label='Column options' > - + void onRunGroupIncomplete?: () => void + /** When set, surfaces a "Run N selected rows" item above Run all. */ + onRunGroupSelected?: () => void + selectedRowCount?: number } /** @@ -64,8 +67,11 @@ export function ColumnOptionsMenu({ onDeleteGroup, onRunGroupAll, onRunGroupIncomplete, + onRunGroupSelected, + selectedRowCount = 0, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunGroupAll && onRunGroupIncomplete) + const showRunSelected = Boolean(onRunGroupSelected) && selectedRowCount > 0 return ( @@ -97,6 +103,11 @@ export function ColumnOptionsMenu({ Run + {showRunSelected && ( + onRunGroupSelected?.()}> + {`Run ${selectedRowCount} selected ${selectedRowCount === 1 ? 'row' : 'rows'}`} + + )} onRunGroupAll?.()}>Run all rows onRunGroupIncomplete?.()}> Run empty rows @@ -143,12 +154,20 @@ interface WorkflowGroupMetaCellProps { isGroupSelected: boolean onSelectGroup: (startColIndex: number, size: number) => void onOpenConfig: (columnName: string) => void - onRunGroup?: (groupId: string, workflowId: string, mode?: 'all' | 'incomplete') => void + onRunGroup?: ( + groupId: string, + workflowId: string, + mode?: 'all' | 'incomplete', + rowIds?: string[] + ) => void onInsertLeft?: (columnName: string) => void onInsertRight?: (columnName: string) => void onDeleteColumn?: (columnName: string) => void /** Right-click delete on the group header drops the entire workflow group. */ onDeleteGroup?: (groupId: string) => void + /** Row ids in the user's current multi-row selection; when non-empty the + * run menu adds a "Run N selected rows" option. */ + selectedRowIds?: string[] | null } /** @@ -172,6 +191,7 @@ export function WorkflowGroupMetaCell({ onInsertRight, onDeleteColumn, onDeleteGroup, + selectedRowIds, }: WorkflowGroupMetaCellProps) { const wf = workflows?.find((w) => w.id === workflowId) const color = wf?.color ?? 'var(--text-muted)' @@ -181,6 +201,8 @@ export function WorkflowGroupMetaCell({ const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 }) const [runMenuOpen, setRunMenuOpen] = useState(false) + const selectedCount = selectedRowIds?.length ?? 0 + const handleRunAll = useCallback(() => { if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'all') }, [groupId, workflowId, onRunGroup]) @@ -189,6 +211,12 @@ export function WorkflowGroupMetaCell({ if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'incomplete') }, [groupId, workflowId, onRunGroup]) + const handleRunSelected = useCallback(() => { + if (groupId && workflowId && selectedRowIds && selectedRowIds.length > 0) { + onRunGroup?.(groupId, workflowId, 'all', selectedRowIds) + } + }, [groupId, workflowId, onRunGroup, selectedRowIds]) + const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (!column) return @@ -267,6 +295,11 @@ export function WorkflowGroupMetaCell({ sideOffset={4} onCloseAutoFocus={(e) => e.preventDefault()} > + {selectedCount > 0 && ( + + {`Run ${selectedCount} selected ${selectedCount === 1 ? 'row' : 'rows'}`} + + )} Run all rows Run empty rows @@ -286,6 +319,10 @@ export function WorkflowGroupMetaCell({ onDeleteGroup={onDeleteGroup ? () => onDeleteGroup(groupId) : undefined} onRunGroupAll={onRunGroup ? handleRunAll : undefined} onRunGroupIncomplete={onRunGroup ? handleRunIncomplete : undefined} + onRunGroupSelected={ + onRunGroup && selectedCount > 0 ? handleRunSelected : undefined + } + selectedRowCount={selectedCount} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 6d624c8dd7d..cb1e4b6c3dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -226,8 +226,13 @@ export function Table({ const updateWorkflowGroupMutation = useUpdateWorkflowGroup({ workspaceId, tableId }) const handleRunGroup = useCallback( - (groupId: string, workflowId: string, runMode: 'all' | 'incomplete' = 'all') => { - runGroupMutation.mutate({ groupId, workflowId, runMode }) + ( + groupId: string, + workflowId: string, + runMode: 'all' | 'incomplete' = 'all', + rowIds?: string[] + ) => { + runGroupMutation.mutate({ groupId, workflowId, runMode, rowIds }) }, // mutate is stable; intentionally excluded from deps // eslint-disable-next-line react-hooks/exhaustive-deps @@ -576,20 +581,30 @@ export function Table({ const contextMenuColumnInfo = useMemo<{ isWorkflowColumn: boolean executionId: string | null + hasStartedRun: boolean }>(() => { if (!contextMenu.row || !contextMenu.columnName) { - return { isWorkflowColumn: false, executionId: null } + return { isWorkflowColumn: false, executionId: null, hasStartedRun: false } } const column = columnsRef.current.find((c) => c.name === contextMenu.columnName) const groupId = column?.workflowGroupId if (!column || !groupId) { - return { isWorkflowColumn: false, executionId: null } + return { isWorkflowColumn: false, executionId: null, hasStartedRun: false } } const exec = contextMenu.row.executions?.[groupId] - return { isWorkflowColumn: true, executionId: exec?.executionId ?? null } + // `queued` / `pending` rows have an executionId reserved but no execution + // row in the logs DB yet — the worker hasn't started, so View execution + // would 404. + const hasStartedRun = exec?.status !== 'queued' && exec?.status !== 'pending' + return { + isWorkflowColumn: true, + executionId: exec?.executionId ?? null, + hasStartedRun, + } }, [contextMenu.row, contextMenu.columnName]) const contextMenuExecutionId = contextMenuColumnInfo.executionId const contextMenuIsWorkflowColumn = contextMenuColumnInfo.isWorkflowColumn + const contextMenuHasStartedRun = contextMenuColumnInfo.hasStartedRun const handleViewExecution = useCallback(() => { if (!contextMenuExecutionId) return @@ -2403,6 +2418,21 @@ export function Table({ ) const hasWorkflowColumns = workflowColumnNames.length > 0 + /** + * Row ids for the current multi-row selection. Drives "Run N selected rows" + * in the workflow-group run menu — `null` when there's no multi-selection so + * the menu collapses to "Run all rows". + */ + const selectedRowIds = useMemo(() => { + if (checkedRows.size === 0) return null + const ids: string[] = [] + for (const pos of checkedRows) { + const row = positionMap.get(pos) + if (row) ids.push(row.id) + } + return ids.length > 0 ? ids : null + }, [checkedRows, positionMap]) + const { runningByRowId, totalRunning } = useMemo(() => { const byRow = new Map() let total = 0 @@ -2410,7 +2440,8 @@ export function Table({ let count = 0 const executions = row.executions ?? {} for (const gid in executions) { - if (executions[gid]?.status === 'running') count++ + const status = executions[gid]?.status + if (status === 'running' || status === 'queued') count++ } if (count > 0) { byRow.set(row.id, count) @@ -2481,7 +2512,7 @@ export function Table({ breadcrumbs={breadcrumbs} createTrigger={createTrigger} actions={headerActions} - trailingActions={ + leadingActions={ totalRunning > 0 ? ( - + Stop all
diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index 9b04463aa4f..81a67be294f 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { task } from '@trigger.dev/sdk' +import type { RowData, RowExecutionMetadata } from '@/lib/table/types' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' const logger = createLogger('TriggerResumeExecution') @@ -33,6 +34,134 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { throw new Error(`Paused execution not found: ${pausedExecutionId}`) } + // If this paused execution belongs to a table cell, rehydrate the cell + // context so post-resume block outputs land on the same row + group as + // the original cell task. Without this, blocks that run after the human + // approves write nothing back to the table — the row silently truncates + // at the pause boundary. The original `parentExecutionId` is preserved + // on the cell's `executions[gid]` so it stays one logical execution + // across the pause/resume boundary. + const { findCellContextByExecutionId } = await import('@/lib/table/workflow-columns') + const cellContext = await findCellContextByExecutionId(parentExecutionId) + + let cellOnBlockComplete: + | ((blockId: string, output: unknown) => Promise) + | undefined + let writeCellTerminal: + | ((status: 'completed' | 'error' | 'paused', error: string | null) => Promise) + | undefined + + if (cellContext) { + const { getTableById } = await import('@/lib/table/service') + const { writeWorkflowGroupState, buildOutputsByBlockId } = await import( + '@/lib/table/cell-write' + ) + const { pluckByPath } = await import('@/lib/table/pluck') + + const table = await getTableById(cellContext.tableId) + const group = table?.schema.workflowGroups?.find((g) => g.id === cellContext.groupId) + if (group) { + const outputsByBlockId = buildOutputsByBlockId(group) + const accumulatedData: RowData = {} + const blockErrors: Record = {} + const writeCtx = { + tableId: cellContext.tableId, + rowId: cellContext.rowId, + workspaceId: cellContext.workspaceId, + groupId: cellContext.groupId, + executionId: parentExecutionId, + requestId: `wfgrp-resume-${parentExecutionId}`, + } + let writeChain: Promise = Promise.resolve() + let terminalWritten = false + + cellOnBlockComplete = async (blockId, output) => { + const outputs = outputsByBlockId.get(blockId) + if (!outputs) return + const blockResult = + output && typeof output === 'object' && 'output' in (output as object) + ? (output as { output: unknown }).output + : output + const errorMessage = + blockResult && + typeof blockResult === 'object' && + typeof (blockResult as { error?: unknown }).error === 'string' + ? (blockResult as { error: string }).error + : null + if (errorMessage) { + blockErrors[blockId] = errorMessage + } else { + for (const out of outputs) { + const plucked = pluckByPath(blockResult, out.path) + if (plucked === undefined) continue + accumulatedData[out.columnName] = plucked as RowData[string] + } + } + const dataSnapshot: RowData = { ...accumulatedData } + const blockErrorsSnapshot = { ...blockErrors } + writeChain = writeChain + .then(async () => { + if (terminalWritten) return + const partial: RowExecutionMetadata = { + status: 'running', + executionId: parentExecutionId, + jobId: null, + workflowId: cellContext.workflowId, + error: null, + blockErrors: blockErrorsSnapshot, + } + await writeWorkflowGroupState(writeCtx, { + executionState: partial, + dataPatch: dataSnapshot, + }) + }) + .catch((err) => { + logger.warn( + `Resume per-block partial write failed (table=${cellContext.tableId} row=${cellContext.rowId} group=${cellContext.groupId}):`, + err + ) + }) + } + + writeCellTerminal = async (status, error) => { + terminalWritten = true + await writeChain.catch(() => {}) + // Paused → keep `pending` + sentinel jobId so eligibility predicates + // continue treating the row as in-flight while we wait on another + // pause. Mirrors the initial cell-task pause branch. + const terminal: RowExecutionMetadata = + status === 'paused' + ? { + status: 'pending', + executionId: parentExecutionId, + jobId: `paused-${parentExecutionId}`, + workflowId: cellContext.workflowId, + error: null, + blockErrors, + } + : { + status, + executionId: parentExecutionId, + jobId: null, + workflowId: cellContext.workflowId, + error, + runningBlockIds: [], + blockErrors, + } + await writeWorkflowGroupState(writeCtx, { + executionState: terminal, + dataPatch: accumulatedData, + }) + } + } else { + logger.warn('Cell context found but table or group missing — falling back to plain resume', { + parentExecutionId, + tableId: cellContext.tableId, + groupId: cellContext.groupId, + }) + } + } + const result = await PauseResumeManager.startResumeExecution({ resumeEntryId: payload.resumeEntryId, resumeExecutionId: payload.resumeExecutionId, @@ -40,8 +169,19 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { contextId: payload.contextId, resumeInput: payload.resumeInput, userId: payload.userId, + ...(cellOnBlockComplete ? { onBlockComplete: cellOnBlockComplete } : {}), }) + if (writeCellTerminal) { + if (result.status === 'paused') { + await writeCellTerminal('paused', null) + } else if (result.success) { + await writeCellTerminal('completed', null) + } else { + await writeCellTerminal('error', result.error ?? 'Workflow execution failed') + } + } + logger.info('Background resume execution completed', { resumeExecutionId, workflowId, diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index ba0b03519e3..38b3d01a66a 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -36,9 +36,9 @@ export async function executeWorkflowGroupCellJob( const { getTableById, getRowById, updateRow } = await import('@/lib/table/service') const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils') - const { writeWorkflowGroupState, buildOutputsByBlockId } = await import( - '@/lib/table/cell-write' - ) + const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } = + await import('@/lib/table/cell-write') + const { stashCellContextForResume } = await import('@/lib/table/workflow-columns') const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId } const writeState = (executionState: RowExecutionMetadata, dataPatch?: RowData) => @@ -112,6 +112,16 @@ export async function executeWorkflowGroupCellJob( return } + // Flip `queued` → `running` to signal the worker has actually started. + // Bail out if the cancel-sticky guard rejects the write (a stop click + // landed between enqueue and pickup). + const queuedExec = row.executions?.[groupId] as RowExecutionMetadata | undefined + const pickedUp = await markWorkflowGroupPickedUp(cellCtx, { + workflowId, + jobId: queuedExec?.jobId ?? null, + }) + if (pickedUp === 'skipped') return + // Output columns produced by THIS group are skipped on input — they're // populated by the run we're starting. Other group's outputs ARE // included (they're plain primitives in `row.data` thanks to the @@ -267,6 +277,36 @@ export async function executeWorkflowGroupCellJob( terminalWritten = true await writeChain.catch(() => {}) + if (result.status === 'paused') { + // HITL pause: keep the row in `pending` so the renderer surfaces it + // the same way logs do, but stamp a sentinel jobId so the scheduler's + // eligibility predicate keeps treating the row as in-flight (no + // re-enqueue while we wait on a human). Resume worker rewrites this + // back to `completed`/`error` once the pause clears. + await writeState( + { + status: 'pending', + executionId, + jobId: `paused-${executionId}`, + workflowId, + error: null, + runningBlockIds: [], + blockErrors, + }, + accumulatedData + ) + await stashCellContextForResume({ + executionId, + tableId, + tableName, + rowId, + groupId, + workflowId, + workspaceId, + }) + return + } + await writeState( { status: result.success ? 'completed' : 'error', diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 34055ea83ee..8835c6c8765 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -78,7 +78,8 @@ function hasRunningGroupExecution(rows: TableRow[] | undefined): boolean { const executions = row.executions ?? {} for (const key in executions) { const exec = executions[key] - if (exec?.status === 'running' || exec?.status === 'pending') return true + if (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending') + return true } } return false @@ -378,6 +379,7 @@ export function useInfiniteTableRows({ sort, enabled = true, }: InfiniteTableRowsParams) { + const queryClient = useQueryClient() const paramsKey = JSON.stringify({ pageSize, filter: filter ?? null, @@ -404,6 +406,21 @@ export function useInfiniteTableRows({ }, enabled: Boolean(workspaceId && tableId) && enabled, staleTime: 30 * 1000, + /** + * Poll while any row has a `pending` or `running` group execution. + * Realtime sockets push every cell write, but cross-network paths + * (trigger.dev workers → realtime ECS, client through CloudFront/proxy) + * occasionally drop events. Polling at the running cadence is the + * safety net so cells reach their terminal state without a refresh. + * No polling when nothing is running and no polling while a mutation + * is in flight (optimistic-update guard). + */ + refetchInterval: (query) => { + if (queryClient.isMutating() > 0) return false + const rows = query.state.data?.pages.flatMap((p) => p.rows) + return hasRunningGroupExecution(rows) ? ROWS_POLL_INTERVAL_WHILE_RUNNING_MS : false + }, + refetchIntervalInBackground: false, }) } @@ -870,7 +887,8 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext) const nextExecutions: RowExecutions = { ...executions } for (const gid in executions) { const exec = executions[gid] - if (exec.status !== 'running' && exec.status !== 'pending') continue + if (exec.status !== 'running' && exec.status !== 'queued' && exec.status !== 'pending') + continue // Preserve blockErrors so cells that already errored keep their // Error rendering after the stop — only cells without a value or // error should flip to "Cancelled". @@ -1086,6 +1104,10 @@ interface RunGroupVariables { * `failed`/`aborted`. Mirrored by the server-side filter. */ runMode?: 'all' | 'incomplete' + /** When set, restricts the run to these row ids. Server still applies the + * same eligibility predicate; passed-in rows mid-run / unmet-deps are + * silently skipped. Omit to run across the whole table. */ + rowIds?: string[] } type InfiniteRowsCache = { pages: TableRowsResponse[]; pageParams: number[] } @@ -1178,16 +1200,23 @@ export function useRunGroup({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ groupId, runMode = 'all' }: RunGroupVariables) => { + mutationFn: async ({ groupId, runMode = 'all', rowIds }: RunGroupVariables) => { return requestJson(runWorkflowGroupContract, { params: { tableId, groupId }, - body: { workspaceId, runMode }, + body: { + workspaceId, + runMode, + ...(rowIds && rowIds.length > 0 ? { rowIds } : {}), + }, }) }, - onMutate: async ({ groupId, workflowId, runMode = 'all' }) => { + onMutate: async ({ groupId, workflowId, runMode = 'all', rowIds }) => { + const targetIds = rowIds && rowIds.length > 0 ? new Set(rowIds) : null const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { + if (targetIds && !targetIds.has(r.id)) return null const exec = r.executions?.[groupId] as RowExecutionMetadata | undefined - if (exec?.status === 'running' || exec?.status === 'pending') return null + if (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending') + return null // Mirror the server-side `incomplete` filter so the optimistic update // doesn't flash `pending` on rows the server is going to skip. if (runMode === 'incomplete' && exec?.status === 'completed') return null diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 73f4fda251c..5dea52aa361 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -92,6 +92,28 @@ export async function writeWorkflowGroupState( return 'wrote' } +/** + * Flips `queued` → `running` to signal the cell task body has actually been + * picked up by a worker. The renderer uses the `queued` vs `running` distinction + * to label cells "Queued" vs "Waiting" (worker started, this block hasn't run + * yet) — without this marker we couldn't tell if a row was sitting in the + * trigger.dev queue or actively executing. + */ +export async function markWorkflowGroupPickedUp( + ctx: WriteWorkflowGroupContext, + prev: Pick +): Promise<'wrote' | 'skipped'> { + return writeWorkflowGroupState(ctx, { + executionState: { + status: 'running', + executionId: ctx.executionId, + jobId: prev.jobId, + workflowId: prev.workflowId, + error: null, + }, + }) +} + /** Builds the canonical `cancelled` execution state used by every cancel path. * Preserves `blockErrors` from the prior state so errored cells keep * rendering Error after a stop click — only cells that hadn't yet produced diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2d8b0a7c96e..106c1d5df22 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -74,50 +74,52 @@ const logger = createLogger('TableService') * is the fallback. Each helper sends a single row delta so the realtime * server can broadcast to subscribed clients in the table room. */ +async function postRealtimeBridge(path: string, body: unknown, label: string): Promise { + try { + const res = await fetch(`${getSocketServerUrl()}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify(body), + }) + if (!res.ok) { + logger.warn(`${label} bridge non-OK response`, { + status: res.status, + statusText: res.statusText, + }) + } + } catch (err) { + logger.warn(`${label} bridge failed:`, err) + } +} + function notifyTableRowUpdated(tableId: string, row: TableRow): void { - void fetch(`${getSocketServerUrl()}/api/table-row-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ + void postRealtimeBridge( + '/api/table-row-updated', + { tableId, rowId: row.id, data: row.data, executions: row.executions, position: row.position, updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, - }), - }).catch((err) => { - logger.warn(`table-row-updated bridge failed for ${tableId}/${row.id}:`, err) - }) + }, + `table-row-updated ${tableId}/${row.id}` + ) } function notifyTableRowDeleted(tableId: string, rowId: string): void { - void fetch(`${getSocketServerUrl()}/api/table-row-deleted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ tableId, rowId }), - }).catch((err) => { - logger.warn(`table-row-deleted bridge failed for ${tableId}/${rowId}:`, err) - }) + void postRealtimeBridge( + '/api/table-row-deleted', + { tableId, rowId }, + `table-row-deleted ${tableId}/${rowId}` + ) } function notifyTableDeleted(tableId: string): void { - void fetch(`${getSocketServerUrl()}/api/table-deleted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ tableId }), - }).catch((err) => { - logger.warn(`table-deleted bridge failed for ${tableId}:`, err) - }) + void postRealtimeBridge('/api/table-deleted', { tableId }, `table-deleted ${tableId}`) } export class TableConflictError extends Error { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index e8976275aef..54c4d921405 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -70,7 +70,7 @@ export interface WorkflowGroup { * values land in `row.data` directly. */ export interface RowExecutionMetadata { - status: 'pending' | 'running' | 'completed' | 'error' | 'cancelled' + status: 'pending' | 'queued' | 'running' | 'completed' | 'error' | 'cancelled' executionId: string | null /** * Async-job id (e.g. trigger.dev run id) for the in-flight execution. diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index b50ae47d081..222bdbd95bb 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -6,7 +6,7 @@ */ import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' +import { pausedExecutions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -45,15 +45,16 @@ export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): bool /** * Per-(row, group) eligibility: returns true if a cell job should be enqueued - * for this pair right now. Skip when the group is in flight (`running`, or - * `pending` with a `jobId` already stamped) or in a terminal state. Plain - * `pending` without a jobId is the "ready to dispatch" state — the run route - * sets it and the scheduler is what actually enqueues the job. + * for this pair right now. Skip when the group is in flight (`queued`, + * `running`, or `pending` with a `jobId` already stamped) or in a terminal + * state. Plain `pending` without a jobId is the "ready to dispatch" state — + * the run route sets it and the scheduler is what actually enqueues the job. */ export function isGroupEligible(group: WorkflowGroup, row: TableRow): boolean { const exec = row.executions?.[group.id] const status = exec?.status if ( + status === 'queued' || status === 'running' || status === 'completed' || status === 'error' || @@ -180,14 +181,15 @@ export async function runWorkflowGroupCell(opts: RunGroupCellOptions): Promise${groupId}->>'status') IS DISTINCT FROM 'running'`, + sql`(executions->${groupId}->>'status') IS DISTINCT FROM 'queued'`, sql`((executions->${groupId}->>'status') IS DISTINCT FROM 'pending' OR (executions->${groupId}->>'jobId') IS NULL)`, ] if (rowIds && rowIds.length > 0) { @@ -590,6 +595,75 @@ interface SplitGroupReport { actual: number[] } +/** + * Cell context stored on `paused_executions.metadata` so the resume worker + * can route post-resume block outputs back to the same `(tableId, rowId, + * groupId)` cell — i.e., one logical cell execution across pause/resume + * cycles instead of two. + */ +export interface CellResumeContext { + tableId: string + tableName: string + rowId: string + groupId: string + workspaceId: string + workflowId: string +} + +interface PausedMetadataPatch { + cellContext?: CellResumeContext + [key: string]: unknown +} + +/** + * Stash the cell context on the matching `paused_executions` row. Called + * by the cell task right after it writes the `pending`/paused state. The + * pause record was written by `PauseResumeManager.persistPauseResult` + * before `executeWorkflow` returned, so the row exists. + */ +export async function stashCellContextForResume( + ctx: CellResumeContext & { executionId: string } +): Promise { + const { executionId, ...cellContext } = ctx + try { + const patch: PausedMetadataPatch = { cellContext } + await db + .update(pausedExecutions) + .set({ + metadata: sql`coalesce(${pausedExecutions.metadata}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`, + updatedAt: new Date(), + }) + .where(eq(pausedExecutions.executionId, executionId)) + } catch (err) { + logger.error( + `Failed to stash cell context on paused_executions (executionId=${executionId}):`, + err + ) + } +} + +/** + * Returns the cell context for an execution if one was stashed at pause + * time. Used by the resume worker to know whether the workflow it's about + * to resume belongs to a table cell — and if so, where to write outputs. + */ +export async function findCellContextByExecutionId( + executionId: string +): Promise { + try { + const [row] = await db + .select({ metadata: pausedExecutions.metadata }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1) + const meta = row?.metadata as PausedMetadataPatch | null + return meta?.cellContext ?? null + } catch (err) { + logger.error(`Failed to read cell context for executionId=${executionId}:`, err) + return null + } +} + /** * Returns groups whose output columns occupy non-contiguous positions in the * given columnOrder. Empty array means all groups are cohesive. diff --git a/apps/sim/tools/exa/search.ts b/apps/sim/tools/exa/search.ts index c0dfe2baf2f..9d495332e43 100644 --- a/apps/sim/tools/exa/search.ts +++ b/apps/sim/tools/exa/search.ts @@ -102,7 +102,7 @@ export const searchTool: ToolConfig = { }, rateLimit: { mode: 'per_request', - requestsPerMinute: 5, + requestsPerMinute: 60, }, }, From b38c3b668eb26d8de4fef9c7ab28881e79392c87 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 13:44:30 -0700 Subject: [PATCH 02/58] Update status pils, make checkbox column sticky --- .../components/table/cells/cell-content.tsx | 32 +++++++++---------- .../[tableId]/components/table/table.tsx | 20 ++++++++++-- apps/sim/lib/core/config/feature-flags.ts | 2 +- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx index 69092ab6615..7ae3ccee1df 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { Checkbox } from '@/components/emcn' +import { Badge, Checkbox } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { RowExecutionMetadata } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' @@ -57,11 +57,7 @@ export function CellContent({ // reach a clean terminal state. const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) if (blockError) { - displayContent = ( - - - - ) + displayContent = } else if (hasValue) { displayContent = ( @@ -72,21 +68,25 @@ export function CellContent({ (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending') && !(groupHasBlockErrors && !blockRunning) ) { - // Treat queued / pending / waiting (running but this block hasn't - // started) as a single "Pending" state — only the actively-executing - // block flips to "Running". - displayContent = + // `queued` is our own state with no logs analog: cell sits in trigger.dev's + // queue waiting for a worker. Gray makes it visually distinct from + // `pending`/`running` (amber via StatusBadge), so a row of side-by-side + // cells reads at a glance which are queued vs actively running. + if (exec?.status === 'queued') { + displayContent = ( + + Queued + + ) + } else { + displayContent = + } } else if (exec?.status === 'cancelled') { displayContent = } else if (exec?.status === 'error') { // Group-level failure (executor blew up, missing credentials, validation) // — no specific block produced the error so `blockErrors` is empty. - // Surface the top-level error on every output cell in the group. - displayContent = ( - - - - ) + displayContent = } else { displayContent = } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index cb1e4b6c3dc..7267ebcf80d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -102,11 +102,11 @@ const ROW_HEIGHT_ESTIMATE = 35 const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none' const CELL_CHECKBOX = - 'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none' + 'sticky left-0 z-[3] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none' const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' const CELL_HEADER_CHECKBOX = - 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle' + 'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle' const CELL_CONTENT = 'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small' const SELECTION_OVERLAY = @@ -1211,6 +1211,20 @@ export function Table({ setIsColumnSelection(false) const row = rowsRef.current.find((r) => r.id === rowId) + const column = columnsRef.current.find((c) => c.key === columnKey) + + // Workflow-output cell with no value (status pill showing) → enter edit + // mode with a blank input so the user can write a value over the status. + // Escape cancels without persisting. + if (column?.workflowGroupId && row && canEditRef.current) { + const cellValue = row.data[columnName] + if (cellValue === null || cellValue === undefined || cellValue === '') { + setEditingCell({ rowId, columnName }) + setInitialCharacter('') + return + } + } + const colIndex = columnsRef.current.findIndex((c) => c.key === columnKey) let overflows = true if (row && colIndex !== -1) { @@ -2610,7 +2624,7 @@ export function Table({ <> {hasWorkflowGroup && ( - + {headerGroups.map((g) => g.kind === 'workflow' ? ( Date: Mon, 4 May 2026 13:48:37 -0700 Subject: [PATCH 03/58] add Run workflow to context menu --- .../components/context-menu/context-menu.tsx | 24 ++++++- .../[tableId]/components/table/table.tsx | 64 +++++++++++++------ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dfe0523ba8d..956c35e3a89 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -5,7 +5,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowDown, ArrowUp, Duplicate, Eye, Pencil, Trash } from '@/components/emcn/icons' +import { + ArrowDown, + ArrowUp, + Duplicate, + Eye, + Pencil, + PlayOutline, + Trash, +} from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' interface ContextMenuProps { @@ -20,6 +28,10 @@ interface ContextMenuProps { canViewExecution?: boolean canEditCell?: boolean selectedRowCount?: number + /** Fires every workflow group on the row(s) the context menu is acting on. */ + onRunWorkflows?: () => void + /** Whether the table has any workflow columns; gates the run-workflows item. */ + hasWorkflowColumns?: boolean disableEdit?: boolean disableInsert?: boolean disableDelete?: boolean @@ -37,11 +49,15 @@ export function ContextMenu({ canViewExecution = false, canEditCell = true, selectedRowCount = 1, + onRunWorkflows, + hasWorkflowColumns = false, disableEdit = false, disableInsert = false, disableDelete = false, }: ContextMenuProps) { const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const runLabel = + selectedRowCount > 1 ? `Run workflows on ${selectedRowCount} rows` : 'Run workflows on row' return ( )} + {hasWorkflowColumns && onRunWorkflows && ( + + + {runLabel} + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 7267ebcf80d..ac66c93244c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -2398,32 +2398,40 @@ export function Table({ [columnOptions, activeSortState, handleSortChange, handleSortClear] ) - const selectedRowCount = useMemo(() => { - if (!contextMenu.isOpen || !contextMenu.row) return 1 - + /** + * Row ids the context menu acts on. If the right-clicked row is checked, all + * checked rows; if it's inside the active range selection, the range; + * otherwise just the row itself. Used by both the count label and the + * multi-row "Run workflows" action. + */ + const contextMenuRowIds = useMemo(() => { + if (!contextMenu.isOpen || !contextMenu.row) return [] if (checkedRows.size > 0 && checkedRows.has(contextMenu.row.position)) { - let count = 0 + const ids: string[] = [] for (const pos of checkedRows) { - if (positionMap.has(pos)) count++ + const row = positionMap.get(pos) + if (row) ids.push(row.id) } - return Math.max(count, 1) + return ids.length > 0 ? ids : [contextMenu.row.id] } - const sel = normalizedSelection - if (!sel) return 1 - - const isInSelection = - contextMenu.row.position >= sel.startRow && contextMenu.row.position <= sel.endRow - - if (!isInSelection) return 1 - - let count = 0 - for (let r = sel.startRow; r <= sel.endRow; r++) { - if (positionMap.has(r)) count++ + if (sel) { + const isInSelection = + contextMenu.row.position >= sel.startRow && contextMenu.row.position <= sel.endRow + if (isInSelection) { + const ids: string[] = [] + for (let r = sel.startRow; r <= sel.endRow; r++) { + const row = positionMap.get(r) + if (row) ids.push(row.id) + } + return ids.length > 0 ? ids : [contextMenu.row.id] + } } - return Math.max(count, 1) + return [contextMenu.row.id] }, [contextMenu.isOpen, contextMenu.row, checkedRows, normalizedSelection, positionMap]) + const selectedRowCount = contextMenuRowIds.length || 1 + const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null const workflowColumnNames = useMemo( @@ -2479,6 +2487,22 @@ export function Table({ [cancelRunsMutate] ) + const handleRunWorkflowsOnSelection = useCallback(() => { + if (tableWorkflowGroups.length === 0) return + if (contextMenuRowIds.length === 0) return + for (const group of tableWorkflowGroups) { + runGroupMutation.mutate({ + groupId: group.id, + workflowId: group.workflowId, + runMode: 'all', + rowIds: contextMenuRowIds, + }) + } + closeContextMenu() + // mutate is stable in v5 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextMenuRowIds, tableWorkflowGroups, closeContextMenu]) + const handleRunRow = useCallback( (rowId: string) => { if (tableWorkflowGroups.length === 0) return @@ -2873,6 +2897,10 @@ export function Table({ canViewExecution={Boolean(contextMenuExecutionId) && contextMenuHasStartedRun} canEditCell={!contextMenuIsWorkflowColumn} selectedRowCount={selectedRowCount} + onRunWorkflows={ + userPermissions.canEdit && hasWorkflowColumns ? handleRunWorkflowsOnSelection : undefined + } + hasWorkflowColumns={hasWorkflowColumns} disableEdit={!userPermissions.canEdit} disableInsert={!userPermissions.canEdit} disableDelete={!userPermissions.canEdit} From cb563c593b45354fe0090c02bc366881bb19b751 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 17:25:52 -0700 Subject: [PATCH 04/58] Refactor dispatching logic --- .../column-sidebar/column-sidebar.tsx | 38 +++- .../components/table/cells/cell-content.tsx | 17 +- .../[tableId]/components/table/table.tsx | 12 +- .../import-csv-dialog/import-csv-dialog.tsx | 10 +- apps/sim/hooks/queries/tables.ts | 15 +- apps/sim/lib/api/client/errors.ts | 80 +++++++++ apps/sim/lib/billing/cleanup-dispatcher.ts | 69 ++------ .../lib/core/async-jobs/backends/database.ts | 129 +++++++++++++- .../core/async-jobs/backends/trigger-dev.ts | 17 ++ apps/sim/lib/core/async-jobs/inline-abort.ts | 35 ---- apps/sim/lib/core/async-jobs/types.ts | 28 ++- apps/sim/lib/table/validation.ts | 13 +- apps/sim/lib/table/workflow-columns.ts | 164 +++++++----------- 13 files changed, 410 insertions(+), 217 deletions(-) delete mode 100644 apps/sim/lib/core/async-jobs/inline-abort.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx index 3e20cec22c3..9ff7d9c6265 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx @@ -18,6 +18,7 @@ import { Tooltip, toast, } from '@/components/emcn' +import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { AddWorkflowGroupBodyInput, @@ -78,6 +79,9 @@ interface ColumnSidebarProps { workflows: WorkflowMetadata[] | undefined workspaceId: string tableId: string + /** Notify parent of a rename so it can rewrite local `columnOrder` / + * `columnWidths` keys that reference the old name. */ + onColumnRename?: (oldName: string, newName: string) => void } const OUTPUT_VALUE_SEPARATOR = '::' @@ -338,6 +342,7 @@ export function ColumnSidebar({ workflows, workspaceId, tableId, + onColumnRename, }: ColumnSidebarProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -423,6 +428,11 @@ export function ColumnSidebar({ const [selectedOutputs, setSelectedOutputs] = useState([]) /** Surfaces required-field errors only after a save attempt, matching the workflow editor's deploy flow. */ const [showValidation, setShowValidation] = useState(false) + /** + * Server-side validation message for the column name (e.g. format / regex). + * Cleared on edit so the user sees the error fade as they fix it. + */ + const [nameError, setNameError] = useState(null) const existingColumnRef = useRef(existingColumn) existingColumnRef.current = existingColumn @@ -432,6 +442,7 @@ export function ColumnSidebar({ useEffect(() => { if (!open || !configState) return setShowValidation(false) + setNameError(null) const existing = existingColumnRef.current const cols = allColumnsRef.current const leftOfCurrent = (() => { @@ -844,6 +855,7 @@ export function ColumnSidebar({ columnName: renamedColumn.from, updates: { name: renamedColumn.to }, }) + onColumnRename?.(renamedColumn.from, renamedColumn.to) } await updateWorkflowGroup.mutateAsync({ groupId: existingGroup.id, @@ -918,11 +930,25 @@ export function ColumnSidebar({ columnName: configState.columnName, updates, }) + if (renamed) onColumnRename?.(configState.columnName, trimmedName) toast.success(`Saved "${trimmedName}"`) } onClose() } catch (err) { + // Validation errors (client-side from `requestJson`'s schema parse, or + // server-side `{ error, details: [...] }`) get rendered inline next to + // the offending field — raw Zod issue arrays look like garbage in a toast. + if (isValidationError(err)) { + const nameIssue = + findValidationIssue(err, ['updates', 'name']) ?? + findValidationIssue(err, ['name']) ?? + findValidationIssue(err, ['columnName']) + if (nameIssue) { + setNameError(nameIssue.message) + return + } + } toast.error(toError(err).message) } } @@ -975,14 +1001,22 @@ export function ColumnSidebar({ setNameInput(e.target.value)} + onChange={(e) => { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} spellCheck={false} autoComplete='off' - aria-invalid={showValidation && !nameInput.trim() ? true : undefined} + aria-invalid={ + (showValidation && !nameInput.trim()) || nameError ? true : undefined + } /> {showValidation && !nameInput.trim() && ( )} + {nameError && !(showValidation && !nameInput.trim()) && ( + + )}
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx index 7ae3ccee1df..b8ea172945a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx @@ -68,18 +68,23 @@ export function CellContent({ (exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending') && !(groupHasBlockErrors && !blockRunning) ) { - // `queued` is our own state with no logs analog: cell sits in trigger.dev's - // queue waiting for a worker. Gray makes it visually distinct from - // `pending`/`running` (amber via StatusBadge), so a row of side-by-side - // cells reads at a glance which are queued vs actively running. - if (exec?.status === 'queued') { + // `pending` (pre-enqueue) and `queued` (post-enqueue, awaiting pickup) + // both read as "in the queue, not actively running" — collapse into one + // gray "Queued" badge so the user doesn't see a flicker as the row + // transitions pending → queued. Active block execution flips to amber + // "Running" via the logs StatusBadge. + if (blockRunning) { + displayContent = + } else if (exec?.status === 'queued' || exec?.status === 'pending') { displayContent = ( Queued ) } else { - displayContent = + // `running` without this block in `runningBlockIds` = upstream block + // still going. Show as Pending (amber) per logs convention. + displayContent = } } else if (exec?.status === 'cancelled') { displayContent = diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index ac66c93244c..f0e9e69db02 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -91,8 +91,10 @@ const COL_WIDTH_AUTO_FIT_MAX = 1000 // Wide enough to host the row-number + per-row run button side by side. // Single-digit row numbers (rows 1–9) and multi-digit (10+) need to render // with the play button at the same x-position so the column doesn't reflow -// row-by-row. -const CHECKBOX_COL_WIDTH = 56 +// row-by-row. Tables without workflow columns get the narrower variant +// since there's no per-row run button to host. +const CHECKBOX_COL_WIDTH_WITH_RUN = 56 +const CHECKBOX_COL_WIDTH_NUMBER_ONLY = 36 const ADD_COL_WIDTH = 120 /** Width of the column-config slideout (matches `column-sidebar.tsx`'s `w-[400px]`). */ const COLUMN_SIDEBAR_WIDTH = 400 @@ -319,6 +321,11 @@ export function Table({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, tableWorkflowGroups]) + const hasWorkflowColumns = tableWorkflowGroups.length > 0 + const checkboxColWidth = hasWorkflowColumns + ? CHECKBOX_COL_WIDTH_WITH_RUN + : CHECKBOX_COL_WIDTH_NUMBER_ONLY + const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), [displayColumns, tableWorkflowGroups] @@ -2850,6 +2857,7 @@ export function Table({ workflows={workflows} workspaceId={workspaceId} tableId={tableId} + onColumnRename={handleColumnRename} /> 180) return `${stripped.slice(0, 177)}...` - return stripped + const trimmed = message.trim() + if (trimmed.length > 180) return `${trimmed.slice(0, 177)}...` + return trimmed } interface ImportCsvDialogProps { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 8835c6c8765..7176ffe0da5 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -15,6 +15,7 @@ import { useQueryClient, } from '@tanstack/react-query' import { toast } from '@/components/emcn' +import { isValidationError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' import { @@ -530,6 +531,9 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) reconcileCreatedRow(queryClient, tableId, row) }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateRowCount(queryClient, tableId) }, @@ -641,6 +645,9 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon }, }) }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateRowCount(queryClient, tableId) }, @@ -674,12 +681,13 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) return { previousQueries } }, - onError: (_err, _vars, context) => { + onError: (error, _vars, context) => { if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data) } } + toast.error(error.message, { duration: 5000 }) }, onSettled: () => { invalidateRowData(queryClient, tableId) @@ -724,12 +732,13 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon return { previousQueries } }, - onError: (_err, _vars, context) => { + onError: (error, _vars, context) => { if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data) } } + toast.error(error.message, { duration: 5000 }) }, onSettled: () => { invalidateRowData(queryClient, tableId) @@ -809,6 +818,8 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { }) }, onError: (error) => { + // Validation errors are surfaced as inline FieldErrors by the caller. + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { diff --git a/apps/sim/lib/api/client/errors.ts b/apps/sim/lib/api/client/errors.ts index 9fa4dc5b18b..ea61e501ee9 100644 --- a/apps/sim/lib/api/client/errors.ts +++ b/apps/sim/lib/api/client/errors.ts @@ -25,3 +25,83 @@ export class ApiClientError extends Error { export function isApiClientError(error: unknown): error is ApiClientError { return error instanceof ApiClientError } + +export interface ValidationIssue { + /** Path of the failing field, e.g. ['updates', 'name']. */ + path: ReadonlyArray + /** Human-readable message — uses the schema's custom error string when set. */ + message: string +} + +interface UnknownIssue { + path?: unknown + message?: unknown +} + +function normalizeIssue(raw: unknown): ValidationIssue | null { + if (!raw || typeof raw !== 'object') return null + const { path, message } = raw as UnknownIssue + if (typeof message !== 'string' || message.length === 0) return null + if (!Array.isArray(path)) return null + const cleanPath = path.filter( + (segment): segment is string | number => + typeof segment === 'string' || typeof segment === 'number' + ) + return { path: cleanPath, message } +} + +/** + * Pull a list of validation issues out of an unknown error. Recognises both + * shapes the boundary produces: + * + * - Client-side contract validation: `requestJson` calls `schema.parse(input)` + * before fetch; failure throws a raw `ZodError` whose `.issues` is the array. + * - Server-side contract validation: route returns `{ error, details: [...] }`, + * which `requestJson` re-throws as `ApiClientError` carrying the body. + * + * Returns an empty array when the error isn't a recognised validation shape so + * callers can fall back to toast/log paths. + */ +export function extractValidationIssues(error: unknown): ValidationIssue[] { + if (!error || typeof error !== 'object') return [] + + if (isApiClientError(error)) { + const body = error.body + if (body && typeof body === 'object') { + const details = (body as { details?: unknown }).details + if (Array.isArray(details)) { + return details.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null) + } + } + return [] + } + + const issues = (error as { issues?: unknown }).issues + if (Array.isArray(issues)) { + return issues.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null) + } + return [] +} + +/** + * Match a single issue by suffix path. `pathSuffix` lets callers ignore the + * outer body wrapper — `findValidationIssue(err, ['name'])` matches both + * `path: ['name']` and `path: ['updates', 'name']`. + */ +export function findValidationIssue( + error: unknown, + pathSuffix: ReadonlyArray +): ValidationIssue | null { + const issues = extractValidationIssues(error) + for (const issue of issues) { + if (issue.path.length < pathSuffix.length) continue + const tail = issue.path.slice(issue.path.length - pathSuffix.length) + if (tail.every((segment, i) => segment === pathSuffix[i])) return issue + } + return null +} + +/** True when the error is a recognised validation failure (client or server). */ +export function isValidationError(error: unknown): boolean { + return extractValidationIssues(error).length > 0 +} diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index b752e725515..2de02b27d66 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -1,13 +1,13 @@ import { db } from '@sim/db' import { organization, subscription, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { tasks } from '@trigger.dev/sdk' import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { type PlanCategory, sqlIsPaid, sqlIsPro, sqlIsTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getJobQueue } from '@/lib/core/async-jobs' import { shouldExecuteInline } from '@/lib/core/async-jobs/config' +import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' const logger = createLogger('RetentionDispatcher') @@ -143,53 +143,20 @@ export async function resolveCleanupScope( } } -type RunnerFn = (payload: CleanupJobPayload) => Promise - -async function getInlineRunner(jobType: CleanupJobType): Promise { - switch (jobType) { - case 'cleanup-logs': { - const { runCleanupLogs } = await import('@/background/cleanup-logs') - return runCleanupLogs - } - case 'cleanup-soft-deletes': { - const { runCleanupSoftDeletes } = await import('@/background/cleanup-soft-deletes') - return runCleanupSoftDeletes - } - case 'cleanup-tasks': { - const { runCleanupTasks } = await import('@/background/cleanup-tasks') - return runCleanupTasks - } - } -} - -/** - * When the job queue backend is "database" (no Trigger.dev, no BullMQ), the - * enqueued rows just sit in async_jobs forever. Run them inline as fire-and-forget - * promises, following the same pattern as the workflow execution API route. - */ -async function runInlineIfNeeded( - jobQueue: Awaited>, - jobType: CleanupJobType, - jobId: string, - payload: CleanupJobPayload -): Promise { - if (!shouldExecuteInline()) return - const runner = await getInlineRunner(jobType) - void (async () => { - try { - await jobQueue.startJob(jobId) - await runner(payload) - await jobQueue.completeJob(jobId, null) - } catch (error) { - const errorMessage = toError(error).message - logger.error(`[${jobType}] Inline job ${jobId} failed`, { error: errorMessage }) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markErr) { - logger.error(`[${jobType}] Failed to mark job ${jobId} as failed`, { markErr }) - } +async function buildCleanupRunner( + jobType: CleanupJobType +): Promise { + const cleanupRunner = await (async () => { + switch (jobType) { + case 'cleanup-logs': + return (await import('@/background/cleanup-logs')).runCleanupLogs + case 'cleanup-soft-deletes': + return (await import('@/background/cleanup-soft-deletes')).runCleanupSoftDeletes + case 'cleanup-tasks': + return (await import('@/background/cleanup-tasks')).runCleanupTasks } })() + return ((payload) => cleanupRunner(payload as CleanupJobPayload)) as EnqueueOptions['runner'] } /** @@ -214,9 +181,10 @@ export async function dispatchCleanupJobs( for (const plan of plansWithDefaults) { const payload: CleanupJobPayload = { plan } - const jobId = await jobQueue.enqueue(jobType, payload) + const jobId = await jobQueue.enqueue(jobType, payload, { + runner: shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined, + }) jobIds.push(jobId) - await runInlineIfNeeded(jobQueue, jobType, jobId, payload) } // Enterprise: workspaces whose owning org is on an active enterprise sub and @@ -270,12 +238,11 @@ export async function dispatchCleanupJobs( } } else { // Fallback: parallel enqueue via abstraction + const inlineRunner = shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined const results = await Promise.allSettled( enterpriseRows.map(async (row) => { const payload: CleanupJobPayload = { plan: 'enterprise', workspaceId: row.id } - const jobId = await jobQueue.enqueue(jobType, payload) - await runInlineIfNeeded(jobQueue, jobType, jobId, payload) - return jobId + return jobQueue.enqueue(jobType, payload, { runner: inlineRunner }) }) ) diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index b11ee70b148..54c4983bd5e 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -1,8 +1,8 @@ import { asyncJobs, db } from '@sim/db' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' -import { abortInlineJob } from '@/lib/core/async-jobs/inline-abort' import { type EnqueueOptions, JOB_STATUS, @@ -16,6 +16,7 @@ import { const logger = createLogger('DatabaseJobQueue') type AsyncJobRow = typeof asyncJobs.$inferSelect +type Runner = NonNullable function rowToJob(row: AsyncJobRow): Job { return { @@ -34,6 +35,38 @@ function rowToJob(row: AsyncJobRow): Job { } } +const inlineAbortControllers = new Map() + +interface Semaphore { + available: number + waiters: Array<() => void> +} +const semaphores = new Map() + +async function acquireSlot(key: string, limit: number): Promise { + let s = semaphores.get(key) + if (!s) { + s = { available: limit, waiters: [] } + semaphores.set(key, s) + } + if (s.available > 0) { + s.available -= 1 + return + } + await new Promise((resolve) => s.waiters.push(resolve)) +} + +function releaseSlot(key: string): void { + const s = semaphores.get(key) + if (!s) return + const next = s.waiters.shift() + if (next) { + next() + return + } + s.available += 1 +} + export class DatabaseJobQueue implements JobQueueBackend { async enqueue( type: JobType, @@ -56,9 +89,51 @@ export class DatabaseJobQueue implements JobQueueBackend { }) logger.debug('Enqueued job', { jobId, type }) + if (options?.runner) { + this.runInline(type, jobId, payload, options.runner, options.concurrencyKey, options.concurrencyLimit) + } return jobId } + async batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise { + if (items.length === 0) return [] + const now = new Date() + const rows = items.map(({ payload, options }) => ({ + id: `run_${generateId().replace(/-/g, '').slice(0, 20)}`, + type, + payload: payload as Record, + status: JOB_STATUS.PENDING, + createdAt: now, + attempts: 0, + maxAttempts: options?.maxAttempts ?? 3, + metadata: (options?.metadata ?? {}) as Record, + updatedAt: now, + })) + + await db.insert(asyncJobs).values(rows) + + logger.debug('Batch-enqueued jobs', { count: rows.length, type }) + + for (let i = 0; i < items.length; i++) { + const { payload, options } = items[i] + if (options?.runner) { + this.runInline( + type, + rows[i].id, + payload, + options.runner, + options.concurrencyKey, + options.concurrencyLimit + ) + } + } + + return rows.map((r) => r.id) + } + async getJob(jobId: string): Promise { const [row] = await db.select().from(asyncJobs).where(eq(asyncJobs.id, jobId)).limit(1) @@ -116,9 +191,14 @@ export class DatabaseJobQueue implements JobQueueBackend { async cancelJob(jobId: string): Promise { // Abort any in-process inline execution first so the running workflow // observes the signal and stops mid-flight. Then mark the row failed so - // any future poller skips it. The DB queue is single-process / dev-only, - // so an in-memory registry is sufficient for cross-call abort. - const aborted = abortInlineJob(jobId) + // any future poller skips it. + const controller = inlineAbortControllers.get(jobId) + let aborted = false + if (controller) { + controller.abort('Cancelled') + inlineAbortControllers.delete(jobId) + aborted = true + } const now = new Date() await db @@ -133,4 +213,45 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted }) } + + /** + * Fire-and-forget IIFE that owns the lifecycle for an inline job: registers + * the abort controller (so `cancelJob` can interrupt mid-flight), acquires + * a concurrency slot if `concurrencyKey` is set, drives + * `startJob → runner → completeJob | markJobFailed`. + */ + private runInline( + type: JobType, + jobId: string, + payload: TPayload, + runner: Runner, + concurrencyKey?: string, + concurrencyLimit?: number + ): void { + const abortController = new AbortController() + inlineAbortControllers.set(jobId, abortController) + void (async () => { + if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { + await acquireSlot(concurrencyKey, concurrencyLimit) + } + try { + await this.startJob(jobId) + await runner(payload, abortController.signal) + await this.completeJob(jobId, null) + } catch (err) { + const message = toError(err).message + logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) + try { + await this.markJobFailed(jobId, message) + } catch (markErr) { + logger.error(`[${type}] Failed to mark job ${jobId} as failed`, { markErr }) + } + } finally { + inlineAbortControllers.delete(jobId) + if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { + releaseSlot(concurrencyKey) + } + } + })() + } } diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index cff02f218d0..2c6d1173032 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -81,6 +81,23 @@ export class TriggerDevJobQueue implements JobQueueBackend { return handle.id } + async batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise { + if (items.length === 0) return [] + // tasks.batchTrigger returns only a batchId, not per-item run IDs, so we + // can't use it when callers need to track individual runs (e.g. table cell + // tasks need per-row jobIds for cancellation). Sequential `tasks.trigger` + // gives us per-item IDs and naturally preserves input order in the queue. + const ids: string[] = [] + for (const { payload, options } of items) { + const id = await this.enqueue(type, payload, options) + ids.push(id) + } + return ids + } + async getJob(jobId: string): Promise { try { const run = await runs.retrieve(jobId) diff --git a/apps/sim/lib/core/async-jobs/inline-abort.ts b/apps/sim/lib/core/async-jobs/inline-abort.ts deleted file mode 100644 index af4179da39f..00000000000 --- a/apps/sim/lib/core/async-jobs/inline-abort.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Process-local registry of `AbortController`s for jobs running inline - * (i.e. on the same Node process that enqueued them — the database-backed - * queue path). The trigger.dev backend does not use this: cancellation there - * is handled by `runs.cancel(jobId)` which interrupts the worker. - * - * Wiring: - * - `runWorkflowColumn` registers a controller after enqueue (keyed by the - * returned `jobId`) and passes its `signal` into the inline task body. - * - `DatabaseJobQueue.cancelJob` looks up the controller and aborts it so - * the running workflow execution can observe the signal mid-flight. - * - The IIFE that owns the controller unregisters in `finally`. - */ -const inlineAbortControllers = new Map() - -export function registerInlineAbort(jobId: string, controller: AbortController): void { - inlineAbortControllers.set(jobId, controller) -} - -export function unregisterInlineAbort(jobId: string): void { - inlineAbortControllers.delete(jobId) -} - -/** - * Aborts the in-process controller for `jobId` if one is registered. Safe to - * call from `cancelJob` regardless of whether the job ran inline. Returns - * true if a controller was found and aborted. - */ -export function abortInlineJob(jobId: string, reason = 'Cancelled'): boolean { - const controller = inlineAbortControllers.get(jobId) - if (!controller) return false - controller.abort(reason) - inlineAbortControllers.delete(jobId) - return true -} diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 42be995c0c2..a7ee73a7c6c 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -77,10 +77,24 @@ export interface EnqueueOptions { delayMs?: number tags?: string[] /** - * Trigger.dev concurrency key. Combined with the task's `queue.concurrencyLimit`, - * limits parallel runs sharing this key. The database backend ignores it. + * Combined with the task's `queue.concurrencyLimit`, caps parallel runs + * sharing this key. Trigger.dev enforces server-side; the database backend + * enforces in-process via a FIFO semaphore. */ concurrencyKey?: string + /** + * Per-key concurrency cap. Database backend only — trigger.dev reads this + * from the task definition (`queue.concurrencyLimit`). + */ + concurrencyLimit?: number + /** + * Job body invoked when the queue backend lacks an external worker. + * Trigger.dev ignores this (its workers execute the task definition); + * the database backend kicks it off as a fire-and-forget IIFE so the + * row drives through `processing → completed | failed`. Receives the + * payload and an `AbortSignal` driven by `cancelJob`. + */ + runner?: (payload: TPayload, signal: AbortSignal) => Promise } /** @@ -93,6 +107,16 @@ export interface JobQueueBackend { */ enqueue(type: JobType, payload: TPayload, options?: EnqueueOptions): Promise + /** + * Enqueue multiple jobs as a single batch. Returns one jobId per item, in + * input order. Backends preserve input order in queue dispatch (trigger.dev + * via tasks.batchTrigger, database via a single multi-row INSERT). + */ + batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise + /** * Get a job by ID */ diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index dbea6a32c40..8f6d2757d4e 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -276,7 +276,7 @@ export function getUniqueColumns(schema: TableSchema): ColumnDefinition[] { export function validateUniqueConstraints( data: RowData, schema: TableSchema, - existingRows: { id: string; data: RowData }[], + existingRows: { id: string; data: RowData; position?: number }[], excludeRowId?: string ): ValidationResult { const errors: string[] = [] @@ -297,8 +297,10 @@ export function validateUniqueConstraints( }) if (duplicate) { + const rowLabel = + typeof duplicate.position === 'number' ? `row ${duplicate.position + 1}` : duplicate.id errors.push( - `Column "${column.name}" must be unique. Value "${value}" already exists in row ${duplicate.id}` + `Column "${column.name}" must be unique. Value "${value}" already exists in ${rowLabel}` ) } } @@ -364,14 +366,14 @@ export async function checkUniqueConstraintsDb( : baseCondition const conflictingRow = await db - .select({ id: userTableRows.id }) + .select({ id: userTableRows.id, position: userTableRows.position }) .from(userTableRows) .where(whereClause) .limit(1) if (conflictingRow.length > 0) { errors.push( - `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].id}` + `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].position + 1}` ) } } @@ -474,6 +476,7 @@ export async function checkBatchUniqueConstraintsDb( .select({ id: userTableRows.id, data: userTableRows.data, + position: userTableRows.position, }) .from(userTableRows) .where(and(eq(userTableRows.tableId, tableId), or(...valueConditions))) @@ -504,7 +507,7 @@ export async function checkBatchUniqueConstraintsDb( rowErrors.push(rowError) } - const errorMsg = `Column "${columnName}" must be unique. Value "${rowValue}" already exists in row ${conflict.id}` + const errorMsg = `Column "${columnName}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}` if (!rowError.errors.includes(errorMsg)) { rowError.errors.push(errorMsg) } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 222bdbd95bb..06f4c643cf6 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -11,6 +11,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, sql } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs/config' +import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { buildCancelledExecution, writeWorkflowGroupState } from '@/lib/table/cell-write' import type { RowData, @@ -105,7 +107,53 @@ export async function scheduleWorkflowGroupRuns( logger.info(`Scheduling ${pendingRuns.length} workflow group cell run(s) for table=${table.id}`) - await Promise.allSettled(pendingRuns.map((opts) => runWorkflowGroupCell(opts))) + const queue = await getJobQueue() + const { executeWorkflowGroupCellJob } = await import('@/background/workflow-column-execution') + const items = pendingRuns.map((opts) => ({ + payload: opts, + options: { + metadata: { + workflowId: opts.workflowId, + workspaceId: opts.workspaceId, + correlation: { + executionId: opts.executionId, + requestId: `wfgrp-${opts.executionId}`, + source: 'workflow' as const, + workflowId: opts.workflowId, + triggerType: 'table', + }, + }, + concurrencyKey: opts.tableId, + concurrencyLimit: TABLE_CONCURRENCY_LIMIT, + tags: [`tableId:${opts.tableId}`, `rowId:${opts.rowId}`, `group:${opts.groupId}`], + runner: executeWorkflowGroupCellJob as EnqueueOptions['runner'], + }, + })) + + let jobIds: string[] + try { + jobIds = await queue.batchEnqueue('workflow-group-cell', items) + } catch (err) { + logger.error(`Batch enqueue failed for table=${table.id}:`, err) + await Promise.allSettled( + pendingRuns.map((opts) => + writeWorkflowGroupState(opts, { + executionState: { + status: 'error', + executionId: opts.executionId, + jobId: null, + workflowId: opts.workflowId, + error: toError(err).message, + }, + }) + ) + ) + return + } + + for (let i = 0; i < pendingRuns.length; i++) { + await stampQueuedOrCancel(queue, pendingRuns[i], jobIds[i]) + } } catch (err) { logger.error('scheduleWorkflowGroupRuns failed:', err) } @@ -121,128 +169,38 @@ interface RunGroupCellOptions { executionId: string } -/** - * Enqueues a workflow-group cell run as a `workflow-group-cell` async job - * and writes `running` (with the returned `jobId`) onto the row's - * `executions[groupId]`. The actual workflow execution and terminal write - * happen inside the cell task body. Cancellation is authoritative via - * `cancelWorkflowGroupRuns`. - */ -export async function runWorkflowGroupCell(opts: RunGroupCellOptions): Promise { - const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId } = opts - - const { getJobQueue, shouldExecuteInline } = await import('@/lib/core/async-jobs/config') - const cellCtx = { tableId, rowId, workspaceId, groupId, executionId } - - const taskPayload = { - tableId, - tableName, - rowId, - groupId, - workflowId, - workspaceId, - executionId, - } - let jobId: string - let queue: Awaited> - try { - queue = await getJobQueue() - jobId = await queue.enqueue('workflow-group-cell', taskPayload, { - metadata: { - workflowId, - workspaceId, - correlation: { - executionId, - requestId: `wfgrp-${executionId}`, - source: 'workflow', - workflowId, - triggerType: 'table', - }, - }, - // Per-table sub-queue throttles cells within a table without blocking other tables. - concurrencyKey: tableId, - tags: [`tableId:${tableId}`, `rowId:${rowId}`, `group:${groupId}`], - }) - } catch (err) { - const message = toError(err).message - logger.error( - `Failed to enqueue workflow-group-cell (table=${tableId} row=${rowId} group=${groupId}):`, - err - ) - await writeWorkflowGroupState(cellCtx, { - executionState: { - status: 'error', - executionId, - jobId: null, - workflowId, - error: message, - }, - }) - return - } +/** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 10`. */ +const TABLE_CONCURRENCY_LIMIT = 10 - // Single post-enqueue write: stamps `queued` + jobId so the cancel API can - // reach this run from any pod. The cell-task body flips `queued` → `running` - // on entry, giving the renderer a real "queued vs picked up" distinction. - // If cancel won the race the helper bails and we abort the just-enqueued job. +async function stampQueuedOrCancel( + queue: Awaited>, + opts: RunGroupCellOptions, + jobId: string +): Promise { let stampResult: 'wrote' | 'skipped' = 'wrote' try { - stampResult = await writeWorkflowGroupState(cellCtx, { + stampResult = await writeWorkflowGroupState(opts, { executionState: { status: 'queued', - executionId, + executionId: opts.executionId, jobId, - workflowId, + workflowId: opts.workflowId, error: null, }, }) } catch (err) { logger.error( - `Failed to persist jobId on group execution (table=${tableId} row=${rowId} group=${groupId}):`, + `Failed to stamp queued state (table=${opts.tableId} row=${opts.rowId} group=${opts.groupId}):`, err ) } + if (stampResult === 'skipped') { try { await queue.cancelJob(jobId) } catch (cancelErr) { logger.error(`Failed to cancel orphaned workflow-group-cell job (jobId=${jobId}):`, cancelErr) } - return - } - - // Trigger.dev disabled — execute the task body inline (DB queue records - // rows but doesn't dispatch), mirroring `workflow-execution`. - if (shouldExecuteInline()) { - const { registerInlineAbort, unregisterInlineAbort } = await import( - '@/lib/core/async-jobs/inline-abort' - ) - const abortController = new AbortController() - registerInlineAbort(jobId, abortController) - - void (async () => { - try { - const { executeWorkflowGroupCellJob } = await import( - '@/background/workflow-column-execution' - ) - await queue.startJob(jobId) - await executeWorkflowGroupCellJob(taskPayload, abortController.signal) - await queue.completeJob(jobId, null) - } catch (err) { - const message = toError(err).message - logger.error( - `Inline workflow-group-cell failed (jobId=${jobId} table=${tableId} row=${rowId} group=${groupId}):`, - err - ) - try { - await queue.markJobFailed(jobId, message) - } catch (markErr) { - logger.error('Also failed to mark job as failed:', markErr) - } - } finally { - unregisterInlineAbort(jobId) - } - })() } } From 49d89041559de11074747a361cf1c056ba3a8957 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 17:43:59 -0700 Subject: [PATCH 05/58] fix checkbox width to be smaller if csv is small --- .../[tableId]/components/table/table.tsx | 97 +++++++++++++------ 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index f0e9e69db02..6c6a5308115 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -89,12 +89,23 @@ const EMPTY_CHECKED_ROWS = new Set() const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 // Wide enough to host the row-number + per-row run button side by side. -// Single-digit row numbers (rows 1–9) and multi-digit (10+) need to render -// with the play button at the same x-position so the column doesn't reflow -// row-by-row. Tables without workflow columns get the narrower variant -// since there's no per-row run button to host. -const CHECKBOX_COL_WIDTH_WITH_RUN = 56 -const CHECKBOX_COL_WIDTH_NUMBER_ONLY = 36 +// Single-digit row numbers (rows 1–9) and multi-digit need to render with +// the play button at the same x-position so the column doesn't reflow +// row-by-row. +// +// Bucketed by the table's plan-derived `maxRows`, not the live count: a small +// table sized for ≤9,999 always renders the narrow gutter; an enterprise +// table sized up to 9,999,999 always renders the wide one. The gutter never +// changes width as rows are added. +// +// Tables without workflow columns drop the per-row run button (~28px), so +// the gutter shrinks accordingly. +const CHECKBOX_COL_WIDTH_SMALL_WITH_RUN = 56 +const CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY = 36 +const CHECKBOX_COL_WIDTH_LARGE_WITH_RUN = 76 +const CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY = 56 +/** Bucket boundary: tables sized for >9,999 rows get the wide gutter. */ +const LARGE_ROW_NUMBER_THRESHOLD = 10000 const ADD_COL_WIDTH = 120 /** Width of the column-config slideout (matches `column-sidebar.tsx`'s `w-[400px]`). */ const COLUMN_SIDEBAR_WIDTH = 400 @@ -321,10 +332,27 @@ export function Table({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, tableWorkflowGroups]) - const hasWorkflowColumns = tableWorkflowGroups.length > 0 - const checkboxColWidth = hasWorkflowColumns - ? CHECKBOX_COL_WIDTH_WITH_RUN - : CHECKBOX_COL_WIDTH_NUMBER_ONLY + const workflowColumnNames = useMemo( + () => columns.filter((c) => !!c.workflowGroupId).map((c) => c.name), + [columns] + ) + const hasWorkflowColumns = workflowColumnNames.length > 0 + /** + * The sticky left column hosts the row number / checkbox always, plus a + * per-row run button only when the table has workflow columns. Width is + * picked from the table's plan-derived `maxRows` so a free-tier table + * (≤9,999) gets the narrow gutter and an enterprise table (up to + * 9,999,999) gets the wide one. Bucketed, not continuous, so the gutter + * never reflows as rows are added. + */ + const isLargeRowCountTable = (tableData?.maxRows ?? 0) >= LARGE_ROW_NUMBER_THRESHOLD + const checkboxColWidth = isLargeRowCountTable + ? hasWorkflowColumns + ? CHECKBOX_COL_WIDTH_LARGE_WITH_RUN + : CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY + : hasWorkflowColumns + ? CHECKBOX_COL_WIDTH_SMALL_WITH_RUN + : CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), @@ -356,18 +384,18 @@ export function Table({ const colsWidth = isLoadingTable ? displayColCount * COL_WIDTH : displayColumns.reduce((sum, col) => sum + (columnWidths[col.key] ?? COL_WIDTH), 0) - return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH - }, [isLoadingTable, displayColCount, displayColumns, columnWidths]) + return checkboxColWidth + colsWidth + ADD_COL_WIDTH + }, [isLoadingTable, displayColCount, displayColumns, columnWidths, checkboxColWidth]) const resizeIndicatorLeft = useMemo(() => { if (!resizingColumn) return 0 - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth for (const col of displayColumns) { left += columnWidths[col.key] ?? COL_WIDTH if (col.key === resizingColumn) return left } return 0 - }, [resizingColumn, displayColumns, columnWidths]) + }, [resizingColumn, displayColumns, columnWidths, checkboxColWidth]) const dropColumnBounds = useMemo(() => { if (!dropTargetColumnName || !dragColumnName) return null @@ -388,7 +416,7 @@ export function Table({ (dropSide === 'left' && targetGroupStart === dragGroup + dragGroupSize) if (wouldBeNoOp) return null - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth for (let i = 0; i < cols.length; i++) { const col = cols[i] const w = columnWidths[col.key] ?? COL_WIDTH @@ -407,7 +435,14 @@ export function Table({ left += w } return null - }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) + }, [ + dropTargetColumnName, + dragColumnName, + dropSide, + displayColumns, + columnWidths, + checkboxColWidth, + ]) const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { @@ -1067,7 +1102,7 @@ export function Table({ const cols = columnsRef.current const draggedGid = cols.find((c) => c.name === dragColumnNameRef.current)?.workflowGroupId - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth let i = 0 while (i < cols.length) { const col = cols[i] @@ -2441,12 +2476,6 @@ export function Table({ const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null - const workflowColumnNames = useMemo( - () => columns.filter((c) => !!c.workflowGroupId).map((c) => c.name), - [columns] - ) - const hasWorkflowColumns = workflowColumnNames.length > 0 - /** * Row ids for the current multi-row selection. Drives "Run N selected rows" * in the workflow-group run menu — `null` when there's no multi-selection so @@ -2619,14 +2648,18 @@ export function Table({ > {isLoadingTable ? ( - + {Array.from({ length: SKELETON_COL_COUNT }).map((_, i) => ( ))} ) : ( - + )} {isLoadingTable ? ( @@ -2806,6 +2839,7 @@ export function Table({ onRowToggle={handleRowToggle} runningCount={runningByRowId.get(row.id) ?? 0} hasWorkflowColumns={hasWorkflowColumns} + isLargeRowCountTable={isLargeRowCountTable} onStopRow={handleStopRow} onRunRow={handleRunRow} workflowNameById={workflowNameById} @@ -3169,13 +3203,15 @@ const PositionGapRows = React.memo( const TableColGroup = React.memo(function TableColGroup({ columns, columnWidths, + checkboxColWidth, }: { columns: DisplayColumn[] columnWidths: Record + checkboxColWidth: number }) { return ( - + {columns.map((col) => ( ))} @@ -3206,6 +3242,8 @@ interface DataRowProps { runningCount: number /** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */ hasWorkflowColumns: boolean + /** True for tables sized for >9,999 rows; widens the row-number slot to fit 5–7 digit numbers. */ + isLargeRowCountTable: boolean onStopRow: (rowId: string) => void onRunRow: (rowId: string) => void /** Lookup from workflow id → human-readable name, used to label running cells. */ @@ -3262,6 +3300,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.onRowToggle !== next.onRowToggle || prev.runningCount !== next.runningCount || prev.hasWorkflowColumns !== next.hasWorkflowColumns || + prev.isLargeRowCountTable !== next.isLargeRowCountTable || prev.onStopRow !== next.onStopRow || prev.onRunRow !== next.onRunRow || prev.workflowNameById !== next.workflowNameById @@ -3303,6 +3342,7 @@ const DataRow = React.memo(function DataRow({ onRowToggle, runningCount, hasWorkflowColumns, + isLargeRowCountTable, onStopRow, onRunRow, workflowNameById, @@ -3322,7 +3362,10 @@ const DataRow = React.memo(function DataRow({
{ if (e.button !== 0) return onRowToggle(rowIndex, e.shiftKey) From 3b166c81a99ad444a900f394a3708fe521f9666d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 18:55:04 -0700 Subject: [PATCH 06/58] Add drag behavior for workflows, stop workflow on multi select --- .../components/context-menu/context-menu.tsx | 17 +++ .../components/table/cells/cell-content.tsx | 27 ++++- .../table/headers/column-header-menu.tsx | 8 +- .../headers/workflow-group-meta-cell.tsx | 85 +++++++++++++- .../[tableId]/components/table/table.tsx | 101 +++++++++++++++-- apps/sim/hooks/queries/tables.ts | 33 +++++- apps/sim/lib/table/deps.ts | 104 ++++++++++++++++++ apps/sim/lib/table/workflow-columns.ts | 26 ++--- 8 files changed, 361 insertions(+), 40 deletions(-) create mode 100644 apps/sim/lib/table/deps.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 956c35e3a89..484d6e60651 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -1,3 +1,4 @@ +import { Square } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -30,6 +31,10 @@ interface ContextMenuProps { selectedRowCount?: number /** Fires every workflow group on the row(s) the context menu is acting on. */ onRunWorkflows?: () => void + /** Cancels every running/queued execution on the row(s) the context menu is acting on. */ + onStopWorkflows?: () => void + /** Total running/queued executions across the row(s) under the context menu. Drives the Stop label and visibility. */ + runningInSelectionCount?: number /** Whether the table has any workflow columns; gates the run-workflows item. */ hasWorkflowColumns?: boolean disableEdit?: boolean @@ -50,6 +55,8 @@ export function ContextMenu({ canEditCell = true, selectedRowCount = 1, onRunWorkflows, + onStopWorkflows, + runningInSelectionCount = 0, hasWorkflowColumns = false, disableEdit = false, disableInsert = false, @@ -58,6 +65,10 @@ export function ContextMenu({ const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' const runLabel = selectedRowCount > 1 ? `Run workflows on ${selectedRowCount} rows` : 'Run workflows on row' + const stopLabel = + runningInSelectionCount === 1 + ? 'Stop running workflow' + : `Stop ${runningInSelectionCount} running workflows` return ( )} + {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( + + + {stopLabel} + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx index b8ea172945a..31276b7c60f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { Badge, Checkbox } from '@/components/emcn' +import { Badge, Checkbox, Tooltip } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { RowExecutionMetadata } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' @@ -19,6 +19,12 @@ interface CellContentProps { onSave: (value: unknown, reason: SaveReason) => void onCancel: () => void workflowNameById?: Record + /** + * 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[] } /** @@ -35,6 +41,7 @@ export function CellContent({ initialCharacter, onSave, onCancel, + waitingOnLabels, }: CellContentProps) { const isNull = value === null || value === undefined @@ -92,6 +99,24 @@ export function CellContent({ // Group-level failure (executor blew up, missing credentials, validation) // — no specific block produced the error so `blockErrors` is empty. displayContent = + } else if (waitingOnLabels && waitingOnLabels.length > 0) { + // The cell hasn't run because the group is waiting on at least one dep + // (an empty input column or an unfinished upstream group). Tooltip names + // the offenders so the user knows what to fill in. + displayContent = ( + + + + + Waiting + + + + + Waiting on {waitingOnLabels.map((l) => `"${l}"`).join(', ')} + + + ) } else { displayContent = } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx index cde63c50dcc..1a9d5f79916 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx @@ -142,8 +142,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', column.name) + // Workflow-output columns drag as a whole group, so the ghost shows + // the group's name rather than the individual column slug. + const ghostLabel = ownGroup ? ownGroup.name : column.name + const ghost = document.createElement('div') - ghost.textContent = column.name + ghost.textContent = ghostLabel ghost.style.cssText = 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' document.body.appendChild(ghost) @@ -152,7 +156,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragStart?.(column.name) }, - [column.name, readOnly, isRenaming, onDragStart] + [column.name, ownGroup, readOnly, isRenaming, onDragStart] ) const handleDragOver = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx index fd91cce9bf5..a7143a2bd34 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -168,6 +168,15 @@ interface WorkflowGroupMetaCellProps { /** Row ids in the user's current multi-row selection; when non-empty the * run menu adds a "Run N selected rows" option. */ selectedRowIds?: string[] | null + /** When set, the meta cell becomes draggable and forwards events through + * the same column-reorder pipeline used by individual workflow column + * headers. The whole group moves together because downstream code groups + * fan-out siblings by `workflowGroupId`. */ + onDragStart?: (columnName: string) => void + onDragOver?: (columnName: string, side: 'left' | 'right') => void + onDragEnd?: () => void + onDragLeave?: () => void + readOnly?: boolean } /** @@ -192,6 +201,11 @@ export function WorkflowGroupMetaCell({ onDeleteColumn, onDeleteGroup, selectedRowIds, + onDragStart, + onDragOver, + onDragEnd, + onDragLeave, + readOnly, }: WorkflowGroupMetaCellProps) { const wf = workflows?.find((w) => w.id === workflowId) const color = wf?.color ?? 'var(--text-muted)' @@ -200,6 +214,7 @@ export function WorkflowGroupMetaCell({ const [optionsMenuOpen, setOptionsMenuOpen] = useState(false) const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 }) const [runMenuOpen, setRunMenuOpen] = useState(false) + const didDragRef = useRef(false) const selectedCount = selectedRowIds?.length ?? 0 @@ -235,17 +250,85 @@ export function WorkflowGroupMetaCell({ // should select the group + open the config sidebar. const target = e.target as HTMLElement if (target.closest('button, [role="menuitem"], [role="menu"]')) return + // Drag-vs-click guard: when a drag just ended on this cell, swallow the + // synthetic click so we don't accidentally pop open the sidebar. + if (didDragRef.current) { + didDragRef.current = false + return + } onSelectGroup(startColIndex, size) if (columnName) onOpenConfig(columnName) }, [columnName, onOpenConfig, onSelectGroup, size, startColIndex] ) + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (readOnly || !onDragStart || !columnName) { + e.preventDefault() + return + } + didDragRef.current = true + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', columnName) + + const ghost = document.createElement('div') + ghost.textContent = name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) + + onDragStart(columnName) + }, + [columnName, name, onDragStart, readOnly] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!onDragOver || !columnName) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver(columnName, side) + }, + [columnName, onDragOver] + ) + + const handleDragEnd = useCallback(() => { + onDragEnd?.() + }, [onDragEnd]) + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + onDragLeave?.() + }, + [onDragLeave] + ) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const isDraggable = !readOnly && Boolean(onDragStart) + return (
9,999 rows get the wide gutter. */ const LARGE_ROW_NUMBER_THRESHOLD = 10000 const ADD_COL_WIDTH = 120 @@ -2539,6 +2546,28 @@ export function Table({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [contextMenuRowIds, tableWorkflowGroups, closeContextMenu]) + /** + * Total running/queued cells across the rows the context menu is acting on. + * Drives the "Stop N running workflows" item: shown only when > 0. + */ + const runningInContextSelection = useMemo(() => { + if (contextMenuRowIds.length === 0) return 0 + let total = 0 + for (const rowId of contextMenuRowIds) { + total += runningByRowId.get(rowId) ?? 0 + } + return total + }, [contextMenuRowIds, runningByRowId]) + + const handleStopWorkflowsOnSelection = useCallback(() => { + if (contextMenuRowIds.length === 0) return + for (const rowId of contextMenuRowIds) { + if ((runningByRowId.get(rowId) ?? 0) === 0) continue + cancelRunsMutate({ scope: 'row', rowId }) + } + closeContextMenu() + }, [contextMenuRowIds, runningByRowId, cancelRunsMutate, closeContextMenu]) + const handleRunRow = useCallback( (rowId: string) => { if (tableWorkflowGroups.length === 0) return @@ -2665,7 +2694,7 @@ export function Table({ {isLoadingTable ? ( -
+
@@ -2722,6 +2751,17 @@ export function Table({ onDeleteGroup={ userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined } + readOnly={!userPermissions.canEdit} + onDragStart={ + userPermissions.canEdit ? handleColumnDragStart : undefined + } + onDragOver={ + userPermissions.canEdit ? handleColumnDragOver : undefined + } + onDragEnd={userPermissions.canEdit ? handleColumnDragEnd : undefined} + onDragLeave={ + userPermissions.canEdit ? handleColumnDragLeave : undefined + } /> ) : ( ) @@ -2942,6 +2983,10 @@ export function Table({ onRunWorkflows={ userPermissions.canEdit && hasWorkflowColumns ? handleRunWorkflowsOnSelection : undefined } + onStopWorkflows={ + userPermissions.canEdit && hasWorkflowColumns ? handleStopWorkflowsOnSelection : undefined + } + runningInSelectionCount={runningInContextSelection} hasWorkflowColumns={hasWorkflowColumns} disableEdit={!userPermissions.canEdit} disableInsert={!userPermissions.canEdit} @@ -3248,6 +3293,11 @@ interface DataRowProps { onRunRow: (rowId: string) => void /** Lookup from workflow id → human-readable name, used to label running cells. */ workflowNameById: Record + /** + * The table's workflow groups, used to compute per-row "Waiting on …" labels + * for empty workflow-output cells whose group has unmet dependencies. + */ + workflowGroups: WorkflowGroup[] } function rowSelectionChanged( @@ -3303,7 +3353,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.isLargeRowCountTable !== next.isLargeRowCountTable || prev.onStopRow !== next.onStopRow || prev.onRunRow !== next.onRunRow || - prev.workflowNameById !== next.workflowNameById + prev.workflowNameById !== next.workflowNameById || + prev.workflowGroups !== next.workflowGroups ) { return false } @@ -3346,8 +3397,29 @@ const DataRow = React.memo(function DataRow({ onStopRow, onRunRow, workflowNameById, + workflowGroups, }: DataRowProps) { const sel = normalizedSelection + /** + * Per-row "Waiting on …" labels keyed by group id. A group has labels iff + * at least one of its dependencies is unmet for this row — drives the + * "Waiting" pill rendered by `CellContent` for empty workflow-output cells. + * Computed once per render rather than per cell so all cells in a group + * share the same array reference. + */ + const waitingByGroupId = React.useMemo(() => { + if (workflowGroups.length === 0) return null + const map = new Map() + for (const group of workflowGroups) { + const unmet = getUnmetGroupDeps(group, row) + if (unmet.columns.length === 0 && unmet.workflowGroups.length === 0) continue + const upstreamLabels = unmet.workflowGroups + .map((gid) => workflowGroups.find((g) => g.id === gid)?.name ?? gid) + .filter(Boolean) + map.set(group.id, [...unmet.columns, ...upstreamLabels]) + } + return map + }, [workflowGroups, row]) const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) const isRowSelectedByRange = sel !== null && @@ -3363,8 +3435,8 @@ const DataRow = React.memo(function DataRow({
{ if (e.button !== 0) return @@ -3373,7 +3445,7 @@ const DataRow = React.memo(function DataRow({ > @@ -3381,7 +3453,7 @@ const DataRow = React.memo(function DataRow({
@@ -3469,6 +3541,11 @@ const DataRow = React.memo(function DataRow({ onSave={(value, reason) => onSave(row.id, column.name, value, reason)} onCancel={onCancel} workflowNameById={workflowNameById} + waitingOnLabels={ + column.workflowGroupId + ? (waitingByGroupId?.get(column.workflowGroupId) ?? undefined) + : undefined + } />
@@ -3554,7 +3631,7 @@ const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ }) { return ( -
+
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7176ffe0da5..4a7f97f3f88 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,6 +17,7 @@ import { import { toast } from '@/components/emcn' import { isValidationError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' +import { optimisticallyScheduleNewlyEligibleGroups } from '@/lib/table/deps' import type { ContractJsonResponse } from '@/lib/api/contracts' import { type AddWorkflowGroupBodyInput, @@ -675,9 +676,20 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryKey: tableKeys.rowsRoot(tableId), }) - patchCachedRows(queryClient, tableId, (row) => - row.id === rowId ? { ...row, data: { ...row.data, ...data } as RowData } : row - ) + const groups = + queryClient.getQueryData(tableKeys.detail(tableId))?.schema + .workflowGroups ?? [] + + patchCachedRows(queryClient, tableId, (row) => { + if (row.id !== rowId) return row + const patch = data as Partial + const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + return { + ...row, + data: { ...row.data, ...patch } as RowData, + ...(nextExecutions ? { executions: nextExecutions } : {}), + } + }) return { previousQueries } }, @@ -723,11 +735,20 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon }) const updateMap = new Map(updates.map((u) => [u.rowId, u.data])) + const groups = + queryClient.getQueryData(tableKeys.detail(tableId))?.schema + .workflowGroups ?? [] patchCachedRows(queryClient, tableId, (row) => { - const patch = updateMap.get(row.id) - if (!patch) return row - return { ...row, data: { ...row.data, ...patch } as RowData } + const raw = updateMap.get(row.id) + if (!raw) return row + const patch = raw as Partial + const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + return { + ...row, + data: { ...row.data, ...patch } as RowData, + ...(nextExecutions ? { executions: nextExecutions } : {}), + } }) return { previousQueries } diff --git a/apps/sim/lib/table/deps.ts b/apps/sim/lib/table/deps.ts new file mode 100644 index 00000000000..b01c5dc65f5 --- /dev/null +++ b/apps/sim/lib/table/deps.ts @@ -0,0 +1,104 @@ +/** + * Pure dep-satisfaction helpers shared by the server-side scheduler and the + * client UI. Lives in its own file (not `workflow-columns.ts`) so the client + * can import it without pulling in `@sim/db` and other server-only deps. + */ + +import type { RowData, RowExecutionMetadata, RowExecutions, TableRow, WorkflowGroup } from './types' + +/** + * Returns true when every dependency this group needs is filled. Plain + * columns are filled when their value is non-empty; upstream groups are + * filled when `executions[gid].status === 'completed'`. Used both by the + * scheduler's eligibility check and by the manual "Run group" route, which + * needs the same gate WITHOUT the in-flight / terminal-state check. + */ +export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): boolean { + const deps = group.dependencies ?? {} + for (const colName of deps.columns ?? []) { + const value = row.data[colName] + if (value === null || value === undefined || value === '') return false + } + for (const gid of deps.workflowGroups ?? []) { + if (row.executions?.[gid]?.status !== 'completed') return false + } + return true +} + +export interface UnmetDeps { + /** Plain column names whose value on this row is empty. */ + columns: string[] + /** Upstream workflow group ids that haven't reached `completed` on this row. */ + workflowGroups: string[] +} + +/** + * Like `areGroupDepsSatisfied` but returns *which* deps are unmet, so the UI + * can render "Waiting on column_a, column_b". Returns empty arrays when + * everything is filled. + */ +export function getUnmetGroupDeps(group: WorkflowGroup, row: TableRow): UnmetDeps { + const deps = group.dependencies ?? {} + const columns: string[] = [] + for (const colName of deps.columns ?? []) { + const value = row.data[colName] + if (value === null || value === undefined || value === '') columns.push(colName) + } + const workflowGroups: string[] = [] + for (const gid of deps.workflowGroups ?? []) { + if (row.executions?.[gid]?.status !== 'completed') workflowGroups.push(gid) + } + return { columns, workflowGroups } +} + +/** + * Optimistic mirror of the server's row-update→scheduler cascade: for every + * workflow group whose deps were unmet *before* the patch and are satisfied + * *after*, return a new `executions` map with that group flipped to + * `pending`. The cell renderer treats `pending` as "Queued", which is what + * the user expects to see immediately after they fill in the missing input — + * not a flash of dash before the server's pending write arrives. + * + * Returns `null` when nothing changed, so callers can short-circuit. + */ +export function optimisticallyScheduleNewlyEligibleGroups( + groups: WorkflowGroup[], + beforeRow: TableRow, + patch: Partial +): RowExecutions | null { + if (groups.length === 0) return null + + const afterRow: TableRow = { + ...beforeRow, + data: { ...beforeRow.data, ...patch } as RowData, + } + + let next: RowExecutions | null = null + for (const group of groups) { + const wasSatisfied = areGroupDepsSatisfied(group, beforeRow) + if (wasSatisfied) continue + if (!areGroupDepsSatisfied(group, afterRow)) continue + + const exec = beforeRow.executions?.[group.id] + // Don't overwrite an in-flight or terminal state — only "no exec" or a + // prior `cancelled` / `error` is a candidate to retry on dep-fill. + if ( + exec && + exec.status !== 'cancelled' && + exec.status !== 'error' + ) { + continue + } + + if (next === null) next = { ...(beforeRow.executions ?? {}) } + const pending: RowExecutionMetadata = { + status: 'pending', + executionId: exec?.executionId ?? null, + jobId: null, + workflowId: exec?.workflowId ?? group.workflowId, + error: null, + } + next[group.id] = pending + } + return next +} diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 06f4c643cf6..bfa2bd604fb 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -26,24 +26,14 @@ import type { const logger = createLogger('WorkflowGroupScheduler') -/** - * Returns true when every dependency this group needs is filled. Plain - * columns are filled when their value is non-empty; upstream groups are - * filled when `executions[gid].status === 'completed'`. Used both by the - * scheduler's eligibility check and by the manual "Run group" route, which - * needs the same gate WITHOUT the in-flight / terminal-state check. - */ -export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): boolean { - const deps = group.dependencies ?? {} - for (const colName of deps.columns ?? []) { - const value = row.data[colName] - if (value === null || value === undefined || value === '') return false - } - for (const gid of deps.workflowGroups ?? []) { - if (row.executions?.[gid]?.status !== 'completed') return false - } - return true -} +import { areGroupDepsSatisfied } from './deps' + +export { + areGroupDepsSatisfied, + getUnmetGroupDeps, + optimisticallyScheduleNewlyEligibleGroups, + type UnmetDeps, +} from './deps' /** * Per-(row, group) eligibility: returns true if a cell job should be enqueued From 6638b17039f135592149194383ed9c89dd4d674c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 19:03:18 -0700 Subject: [PATCH 07/58] fix z index of checkbox to left, add view workflow button --- .../table/headers/column-header-menu.tsx | 15 +++++++++--- .../headers/workflow-group-meta-cell.tsx | 24 ++++++++++++++++++- .../[tableId]/components/table/table.tsx | 19 ++++++++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx index 1a9d5f79916..fd28476f560 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx @@ -37,6 +37,9 @@ interface ColumnHeaderMenuProps { workflowGroups?: WorkflowGroup[] sourceInfo?: ColumnSourceInfo onOpenConfig: (columnName: string) => void + /** Opens a popup preview of the column's underlying workflow. Surfaced in + * the chevron menu for workflow-output columns. */ + onViewWorkflow?: (workflowId: string) => void } /** @@ -70,6 +73,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ workflowGroups, sourceInfo, onOpenConfig, + onViewWorkflow, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -143,8 +147,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ e.dataTransfer.setData('text/plain', column.name) // Workflow-output columns drag as a whole group, so the ghost shows - // the group's name rather than the individual column slug. - const ghostLabel = ownGroup ? ownGroup.name : column.name + // the group's name (falling back to the workflow's name, then the + // column slug) rather than the individual column slug. + const ghostLabel = + ownGroup?.name ?? configuredWorkflow?.name ?? column.name const ghost = document.createElement('div') ghost.textContent = ghostLabel @@ -156,7 +162,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragStart?.(column.name) }, - [column.name, ownGroup, readOnly, isRenaming, onDragStart] + [column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart] ) const handleDragOver = useCallback( @@ -331,6 +337,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} onDeleteColumn={onDeleteColumn} + onViewWorkflow={ + onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined + } />
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx index a7143a2bd34..08a44c34705 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx @@ -12,7 +12,15 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowLeft, ArrowRight, EyeOff, Pencil, PlayOutline, Trash } from '@/components/emcn/icons' +import { + ArrowLeft, + ArrowRight, + Eye, + EyeOff, + Pencil, + PlayOutline, + Trash, +} from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { SELECTION_TINT_BG } from '../constants' @@ -45,6 +53,9 @@ interface ColumnOptionsMenuProps { /** When set, surfaces a "Run N selected rows" item above Run all. */ onRunGroupSelected?: () => void selectedRowCount?: number + /** When set, the menu surfaces a "View workflow" item that opens a popup + * preview of the configured workflow. */ + onViewWorkflow?: () => void } /** @@ -69,6 +80,7 @@ export function ColumnOptionsMenu({ onRunGroupIncomplete, onRunGroupSelected, selectedRowCount = 0, + onViewWorkflow, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunGroupAll && onRunGroupIncomplete) const showRunSelected = Boolean(onRunGroupSelected) && selectedRowCount > 0 @@ -117,6 +129,12 @@ export function ColumnOptionsMenu({ )} + {onViewWorkflow && ( + onViewWorkflow()}> + + View workflow + + )} onOpenConfig(column.name)}> Edit column @@ -168,6 +186,8 @@ interface WorkflowGroupMetaCellProps { /** Row ids in the user's current multi-row selection; when non-empty the * run menu adds a "Run N selected rows" option. */ selectedRowIds?: string[] | null + /** Opens a popup preview of the underlying workflow. */ + onViewWorkflow?: (workflowId: string) => void /** When set, the meta cell becomes draggable and forwards events through * the same column-reorder pipeline used by individual workflow column * headers. The whole group moves together because downstream code groups @@ -201,6 +221,7 @@ export function WorkflowGroupMetaCell({ onDeleteColumn, onDeleteGroup, selectedRowIds, + onViewWorkflow, onDragStart, onDragOver, onDragEnd, @@ -406,6 +427,7 @@ export function WorkflowGroupMetaCell({ onRunGroup && selectedCount > 0 ? handleRunSelected : undefined } selectedRowCount={selectedCount} + onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index add9a3354b8..c6d7133f541 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -122,7 +122,7 @@ const ROW_HEIGHT_ESTIMATE = 35 const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none' const CELL_CHECKBOX = - 'sticky left-0 z-[3] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none' + 'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none' const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' const CELL_HEADER_CHECKBOX = @@ -259,6 +259,17 @@ export function Table({ [] ) + const handleViewWorkflow = useCallback( + (workflowId: string) => { + window.open( + `/workspace/${workspaceId}/w/${workflowId}`, + '_blank', + 'noopener,noreferrer' + ) + }, + [workspaceId] + ) + function handleColumnOrderChange(order: string[]) { setColumnOrder(order) } @@ -2694,7 +2705,7 @@ export function Table({ {isLoadingTable ? ( -
+
@@ -2751,6 +2762,7 @@ export function Table({ onDeleteGroup={ userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined } + onViewWorkflow={handleViewWorkflow} readOnly={!userPermissions.canEdit} onDragStart={ userPermissions.canEdit ? handleColumnDragStart : undefined @@ -2816,6 +2828,7 @@ export function Table({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.name)} onOpenConfig={handleConfigureColumn} + onViewWorkflow={handleViewWorkflow} /> ))} {userPermissions.canEdit && ( @@ -3631,7 +3644,7 @@ const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ }) { return ( -
+
From 0fa46026fafd68dc2b3b646503ce2d3dda724352 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 19:28:53 -0700 Subject: [PATCH 08/58] Switch to emcn buttons for Add inputs --- .../column-sidebar/column-sidebar.tsx | 8 +- .../components/context-menu/context-menu.tsx | 2 +- .../[tableId]/components/table/table.tsx | 128 ++++++++++++++---- apps/sim/hooks/queries/tables.ts | 6 + 4 files changed, 113 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx index 9ff7d9c6265..99bc676a96a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx @@ -1050,16 +1050,18 @@ export function ColumnSidebar({ {startBlockInputs.blockId && missingInputColumnNames.length > 0 && ( - + Adds {missingInputColumnNames.join(', ')} to the workflow's Start block diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 484d6e60651..a3656fefeb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -116,7 +116,7 @@ export function ContextMenu({ )} {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( - + {stopLabel} )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index c6d7133f541..7d1103d1009 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -350,11 +350,7 @@ export function Table({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, tableWorkflowGroups]) - const workflowColumnNames = useMemo( - () => columns.filter((c) => !!c.workflowGroupId).map((c) => c.name), - [columns] - ) - const hasWorkflowColumns = workflowColumnNames.length > 0 + const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId) /** * The sticky left column hosts the row number / checkbox always, plus a * per-row run button only when the table has workflow columns. Width is @@ -378,7 +374,7 @@ export function Table({ ) const hasWorkflowGroup = headerGroups.some((g) => g.kind === 'workflow') - const maxPosition = useMemo(() => (rows.length > 0 ? rows[rows.length - 1].position : -1), [rows]) + const maxPosition = rows.length > 0 ? rows[rows.length - 1].position : -1 const maxPositionRef = useRef(maxPosition) maxPositionRef.current = maxPosition @@ -1228,6 +1224,87 @@ export function Table({ return () => document.removeEventListener('mouseup', handleMouseUp) }, []) + /** + * Auto-scroll the table while a cell-drag selection is in progress and the + * cursor enters a "hot zone" near the top or bottom of the scroll + * container. Scroll velocity ramps with proximity to the edge (max ~14px / + * frame at the very edge). The horizontal axis is intentionally left out: + * the fixed sticky checkbox column makes left-edge hot zones awkward and + * the table is rarely wider than the viewport in practice. + */ + useEffect(() => { + const HOT_ZONE_PX = 48 + const MAX_VELOCITY_PX = 14 + let pointerX: number | null = null + let pointerY: number | null = null + let rafId: number | null = null + + /** + * After auto-scroll moves the table under the cursor, no `mouseenter` + * fires on newly-revealed cells, so the selection focus would stay stuck + * on whatever cell was under the cursor when the cursor stopped moving. + * Manually re-pick the cell under the (unchanged) cursor coords and feed + * its row/col into the selection so the highlight expands as we scroll. + */ + const updateFocusUnderCursor = () => { + if (pointerX === null || pointerY === null) return + const target = document.elementFromPoint(pointerX, pointerY) + if (!target) return + const td = (target as HTMLElement).closest('td[data-row][data-col]') as HTMLElement | null + if (!td) return + const rowIndex = Number.parseInt(td.getAttribute('data-row') ?? '', 10) + const colIndex = Number.parseInt(td.getAttribute('data-col') ?? '', 10) + if (Number.isNaN(rowIndex) || Number.isNaN(colIndex)) return + setSelectionFocus({ rowIndex, colIndex }) + } + + const tick = () => { + rafId = null + const el = scrollRef.current + if (!isDraggingRef.current || !el || pointerY === null) return + const rect = el.getBoundingClientRect() + const distFromTop = pointerY - rect.top + const distFromBottom = rect.bottom - pointerY + let dy = 0 + if (distFromTop < HOT_ZONE_PX) { + const intensity = 1 - Math.max(0, distFromTop) / HOT_ZONE_PX + dy = -Math.ceil(intensity * MAX_VELOCITY_PX) + } else if (distFromBottom < HOT_ZONE_PX) { + const intensity = 1 - Math.max(0, distFromBottom) / HOT_ZONE_PX + dy = Math.ceil(intensity * MAX_VELOCITY_PX) + } + if (dy !== 0) { + el.scrollTop += dy + updateFocusUnderCursor() + rafId = requestAnimationFrame(tick) + } + } + + const handleMove = (e: MouseEvent) => { + if (!isDraggingRef.current) return + pointerX = e.clientX + pointerY = e.clientY + if (rafId === null) rafId = requestAnimationFrame(tick) + } + + const handleStop = () => { + pointerX = null + pointerY = null + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + } + + document.addEventListener('mousemove', handleMove) + document.addEventListener('mouseup', handleStop) + return () => { + document.removeEventListener('mousemove', handleMove) + document.removeEventListener('mouseup', handleStop) + handleStop() + } + }, []) + useEffect(() => { if (isColumnSelection) return const target = selectionFocus ?? selectionAnchor @@ -2541,7 +2618,9 @@ export function Table({ [cancelRunsMutate] ) - const handleRunWorkflowsOnSelection = useCallback(() => { + // Plain function: only consumer is `` which isn't `React.memo`'d, + // so reference stability isn't observed. + const handleRunWorkflowsOnSelection = () => { if (tableWorkflowGroups.length === 0) return if (contextMenuRowIds.length === 0) return for (const group of tableWorkflowGroups) { @@ -2553,31 +2632,24 @@ export function Table({ }) } closeContextMenu() - // mutate is stable in v5 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contextMenuRowIds, tableWorkflowGroups, closeContextMenu]) + } - /** - * Total running/queued cells across the rows the context menu is acting on. - * Drives the "Stop N running workflows" item: shown only when > 0. - */ - const runningInContextSelection = useMemo(() => { - if (contextMenuRowIds.length === 0) return 0 - let total = 0 - for (const rowId of contextMenuRowIds) { - total += runningByRowId.get(rowId) ?? 0 - } - return total - }, [contextMenuRowIds, runningByRowId]) + // Total running/queued cells across the rows the context menu is acting on; + // drives the "Stop N running workflows" item, shown only when > 0. The reduce + // is cheap (selection size is small) and a memo wouldn't pay for itself. + const runningInContextSelection = contextMenuRowIds.reduce( + (total, rowId) => total + (runningByRowId.get(rowId) ?? 0), + 0 + ) - const handleStopWorkflowsOnSelection = useCallback(() => { + const handleStopWorkflowsOnSelection = () => { if (contextMenuRowIds.length === 0) return for (const rowId of contextMenuRowIds) { if ((runningByRowId.get(rowId) ?? 0) === 0) continue cancelRunsMutate({ scope: 'row', rowId }) } closeContextMenu() - }, [contextMenuRowIds, runningByRowId, cancelRunsMutate, closeContextMenu]) + } const handleRunRow = useCallback( (rowId: string) => { @@ -3474,11 +3546,13 @@ const DataRow = React.memo(function DataRow({
{hasWorkflowColumns && ( - + )}
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 4a7f97f3f88..241bf2afb60 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -477,6 +477,7 @@ export function useRenameTable(workspaceId: string) { }) }, onError: (error) => { + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { @@ -533,6 +534,8 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) reconcileCreatedRow(queryClient, tableId, row) }, onError: (error) => { + // Validation errors are surfaced inline by the caller (see useUpdateColumn). + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -647,6 +650,7 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon }) }, onError: (error) => { + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -699,6 +703,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryClient.setQueryData(queryKey, data) } } + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -759,6 +764,7 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon queryClient.setQueryData(queryKey, data) } } + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { From cd9ab9fa6aaf9d5f61363d797faef0dfd8de6991 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 5 May 2026 11:48:16 -0700 Subject: [PATCH 09/58] Split up workflow sidebar from column sidebar, refactor cells --- .../app/api/table/[tableId]/groups/route.ts | 3 + .../column-config-sidebar.tsx | 249 ++++ .../column-types.ts | 9 +- .../column-sidebar/column-sidebar.tsx | 1203 ----------------- .../components/table/cells/cell-content.tsx | 137 +- .../components/table/cells/cell-render.tsx | 239 ++++ .../[tableId]/components/table/table.tsx | 265 ++-- .../workflow-sidebar/run-settings-section.tsx | 119 ++ .../workflow-sidebar/workflow-sidebar.tsx | 930 +++++++++++++ .../mcp-dynamic-args/mcp-dynamic-args.tsx | 14 +- .../panel/components/editor/editor.tsx | 16 +- .../preview-editor/preview-editor.tsx | 21 +- .../field-divider/field-divider.tsx | 42 + apps/sim/components/emcn/components/index.ts | 1 + apps/sim/hooks/queries/tables.ts | 1 + apps/sim/lib/api/contracts/tables.ts | 20 + apps/sim/lib/table/service.ts | 52 +- apps/sim/lib/table/types.ts | 7 + 18 files changed, 1855 insertions(+), 1473 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{column-sidebar => column-config-sidebar}/column-types.ts (66%) delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-render.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx create mode 100644 apps/sim/components/emcn/components/field-divider/field-divider.tsx diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 847647fc397..ff805f73f96 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -110,6 +110,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.newOutputColumns !== undefined ? { newOutputColumns: validated.newOutputColumns } : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), }, requestId ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx new file mode 100644 index 00000000000..b2e2829faf8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -0,0 +1,249 @@ +'use client' + +import type React from 'react' +import { useState } from 'react' +import { toError } from '@sim/utils/errors' +import { X } from 'lucide-react' +import { Button, Combobox, FieldDivider, Input, Label, Switch, toast } from '@/components/emcn' +import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' +import { cn } from '@/lib/core/utils/cn' +import type { ColumnDefinition } from '@/lib/table' +import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' + +/** + * Discriminates the two flows the column-config sidebar handles. Workflow + * configuration is a separate component (``) so this surface + * never has to branch on `isWorkflow`. + */ +export type ColumnConfig = + | { mode: 'create'; proposedName: string; type: ColumnDefinition['type'] } + | { mode: 'edit'; columnName: string } + +interface ColumnConfigSidebarProps { + /** When non-null the sidebar is open. */ + config: ColumnConfig | null + onClose: () => void + /** Existing column record for `mode: 'edit'`; ignored otherwise. */ + existingColumn: ColumnDefinition | null + workspaceId: string + tableId: string + /** Notify parent of a rename so it can rewrite local `columnOrder` / + * `columnWidths` keys that reference the old name. */ + onColumnRename?: (oldName: string, newName: string) => void +} + +/** + * Right-edge sidebar for plain (non-workflow) column configuration. Handles + * create (with type pre-chosen by the parent's "+ New column" dropdown) and + * edit. No `isWorkflow` branches — workflow-output columns route through + * `` instead. + * + * Form state seeds from props via lazy `useState` initializers; the parent + * uses `key={config?.columnName ?? 'closed'}` to remount when switching + * columns, eliminating the prop-mirroring `useEffect` the previous combined + * sidebar relied on. + */ +export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) { + // Mount the form body with `key` keyed on the config identity so opening a + // different column / mode remounts and re-seeds state from props. + const open = props.config !== null + return ( + + ) +} + +function configKey(config: ColumnConfig): string { + return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}` +} + +interface ColumnConfigBodyProps extends Omit { + config: ColumnConfig +} + +function ColumnConfigBody({ + config, + onClose, + existingColumn, + workspaceId, + tableId, + onColumnRename, +}: ColumnConfigBodyProps) { + const updateColumn = useUpdateColumn({ workspaceId, tableId }) + const addColumn = useAddTableColumn({ workspaceId, tableId }) + + const [nameInput, setNameInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.name ?? config.columnName) : config.proposedName + ) + const [typeInput, setTypeInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.type ?? 'string') : config.type + ) + const [uniqueInput, setUniqueInput] = useState(() => + config.mode === 'edit' ? !!existingColumn?.unique : false + ) + const [showValidation, setShowValidation] = useState(false) + const [nameError, setNameError] = useState(null) + + const saveDisabled = updateColumn.isPending || addColumn.isPending + const trimmedName = nameInput.trim() + + async function handleSave() { + if (!trimmedName) { + setShowValidation(true) + return + } + + try { + if (config.mode === 'create') { + await addColumn.mutateAsync({ name: trimmedName, type: typeInput }) + toast.success(`Added "${trimmedName}"`) + onClose() + return + } + + const renamed = trimmedName !== config.columnName + const typeChanged = !!existingColumn && existingColumn.type !== typeInput + const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + + const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + ...(renamed ? { name: trimmedName } : {}), + ...(typeChanged ? { type: typeInput } : {}), + ...(uniqueChanged ? { unique: uniqueInput } : {}), + } + if (Object.keys(updates).length === 0) { + onClose() + return + } + + await updateColumn.mutateAsync({ columnName: config.columnName, updates }) + if (renamed) onColumnRename?.(config.columnName, trimmedName) + toast.success(`Saved "${trimmedName}"`) + onClose() + } catch (err) { + // Server validation errors carry a Zod issue array on the body; surface + // them inline next to the offending field instead of as a raw toast. + if (isValidationError(err)) { + const nameIssue = + findValidationIssue(err, ['updates', 'name']) ?? + findValidationIssue(err, ['name']) ?? + findValidationIssue(err, ['columnName']) + if (nameIssue) { + setNameError(nameIssue.message) + return + } + } + toast.error(toError(err).message) + } + } + + return ( +
+
+

Configure column

+ +
+ +
+
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
+ + {config.mode === 'edit' && ( + <> + +
+ Type + ({ + label: o.label, + value: o.type, + icon: o.icon, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={260} + /> +
+ + )} + + +
+
+ + setUniqueInput(!!v)} + /> +
+
+
+ +
+ + +
+
+ ) +} + +function RequiredLabel({ + htmlFor, + children, +}: { + htmlFor?: string + children: React.ReactNode +}) { + return ( + + ) +} + +function FieldError({ message }: { message: string }) { + return

{message}

+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts similarity index 66% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 10e392e82a1..6c9f31ade67 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -10,9 +10,9 @@ import { import type { ColumnDefinition } from '@/lib/table' /** - * UI-only column type. `'workflow'` is a virtual selection that lets the user - * configure a workflow group from the sidebar; on save, it expands into N real - * scalar columns + one workflow group, none of which carry a `'workflow'` type. + * UI-only column type. `'workflow'` is the virtual entry users pick from the + * "+ New column" dropdown to spawn a workflow group; the resulting columns are + * stored as scalar types under the hood (none carry `'workflow'`). */ export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' @@ -30,3 +30,6 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] + +/** Plain column types (no workflow). Used by ``'s type combobox in edit mode. */ +export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx deleted file mode 100644 index 99bc676a96a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ /dev/null @@ -1,1203 +0,0 @@ -'use client' - -import type React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ExternalLink, Loader2, RepeatIcon, SplitIcon, X } from 'lucide-react' -import { - Button, - Combobox, - type ComboboxOption, - type ComboboxOptionGroup, - Input, - Label, - Loader, - Switch, - Tooltip, - toast, -} from '@/components/emcn' -import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' -import { requestJson } from '@/lib/api/client/request' -import type { - AddWorkflowGroupBodyInput, - UpdateWorkflowGroupBodyInput, -} from '@/lib/api/contracts/tables' -import { - putWorkflowNormalizedStateContract, - type WorkflowStateContractInput, -} from '@/lib/api/contracts/workflows' -import { cn } from '@/lib/core/utils/cn' -import type { - ColumnDefinition, - WorkflowGroup, - WorkflowGroupDependencies, - WorkflowGroupOutput, -} from '@/lib/table' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - type FlattenOutputsBlockInput, - type FlattenOutputsEdgeInput, - flattenWorkflowOutputs, - getBlockExecutionOrder, -} from '@/lib/workflows/blocks/flatten-outputs' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import type { InputFormatField } from '@/lib/workflows/types' -import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' -import { getBlock } from '@/blocks' -import { - useAddTableColumn, - useAddWorkflowGroup, - useUpdateColumn, - useUpdateWorkflowGroup, -} from '@/hooks/queries/tables' -import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types' - -export type ColumnConfigState = - | { mode: 'edit'; columnName: string } - | { mode: 'new'; columnName: string; workflowId: string; proposedName: string } - | { - mode: 'create' - columnName: string - proposedName: string - /** When present, the sidebar opens with the workflow type pre-selected. */ - workflowId?: string - } - | null - -interface ColumnSidebarProps { - configState: ColumnConfigState - onClose: () => void - /** The current column record for edit mode. Null for new mode or closed. */ - existingColumn: ColumnDefinition | null - allColumns: ColumnDefinition[] - workflowGroups: WorkflowGroup[] - workflows: WorkflowMetadata[] | undefined - workspaceId: string - tableId: string - /** Notify parent of a rename so it can rewrite local `columnOrder` / - * `columnWidths` keys that reference the old name. */ - onColumnRename?: (oldName: string, newName: string) => void -} - -const OUTPUT_VALUE_SEPARATOR = '::' - -/** Shared dashed-divider style — mirrors the workflow editor's subblock divider. */ -const DASHED_DIVIDER_STYLE = { - backgroundImage: - 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', -} as const - -/** Encodes blockId + path so duplicate field names across blocks stay distinct in the picker UI. */ -const encodeOutputValue = (blockId: string, path: string) => - `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` - -/** Splits an encoded `${blockId}::${path}` into its components for persistence. */ -const decodeOutputValue = (value: string): { blockId: string; path: string } => { - const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) - if (idx === -1) return { blockId: '', path: value } - return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } -} - -interface BlockOutputGroup { - blockId: string - blockName: string - blockType: string - blockIcon: string | React.ComponentType<{ className?: string }> - blockColor: string - paths: string[] -} - -/** - * Loose shape of `useWorkflowState` data — we only need the fields we round-trip - * through PUT /state. Typed locally to avoid pulling the heavy `WorkflowState` - * generic from `@/stores/workflows/workflow/types`. - */ -interface WorkflowStatePayload { - blocks: Record< - string, - { - type: string - subBlocks?: Record - } & Record - > - edges: unknown[] - loops: unknown - parallels: unknown - lastSaved?: number - isDeployed?: boolean -} - -function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { - switch (colType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'json': - return 'object' - default: - return 'string' - } -} - -const TagIcon: React.FC<{ - icon: string | React.ComponentType<{ className?: string }> - color: string -}> = ({ icon, color }) => ( -
- {typeof icon === 'string' ? ( - {icon} - ) : ( - (() => { - const IconComponent = icon - return - })() - )} -
-) - -function FieldDivider() { - return ( -
-
-
- ) -} - -/** Mirrors the workflow editor's required-field label: title + asterisk. */ -function FieldLabel({ - htmlFor, - required, - children, -}: { - htmlFor?: string - required?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -/** Inline validation message styled like the workflow editor's destructive text. */ -function FieldError({ message }: { message: string }) { - return

{message}

-} - -/** - * Tinted inline warning row with a message on the left and an action button - * on the right. Stacks naturally — render multiple in sequence and they line - * up. Color mirrors the group-header deploy badge: `red` for blocking states, - * `amber` for soft warnings. - */ -const DEP_VALUE_PREFIX_COLUMN = 'col:' -const DEP_VALUE_PREFIX_GROUP = 'group:' - -/** - * "Run after" picker: which upstream columns and workflow groups must be - * filled before this group fires. Same Combobox shape as the Output columns - * picker. Empty selection = the group fires on any row change. - */ -function RunSettingsSection({ - scalarDepColumns, - groupDepOptions, - deps, - groupDeps, - workflows, - onChangeDeps, - onChangeGroupDeps, -}: { - scalarDepColumns: ColumnDefinition[] - groupDepOptions: WorkflowGroup[] - deps: string[] - groupDeps: string[] - workflows: WorkflowMetadata[] | undefined - onChangeDeps: (next: string[]) => void - onChangeGroupDeps: (next: string[]) => void -}) { - const groups = useMemo(() => { - const result: ComboboxOptionGroup[] = [] - if (scalarDepColumns.length > 0) { - result.push({ - section: 'Columns', - items: scalarDepColumns.map((c) => ({ - label: c.name, - value: `${DEP_VALUE_PREFIX_COLUMN}${c.name}`, - })), - }) - } - if (groupDepOptions.length > 0) { - result.push({ - section: 'Workflow groups', - items: groupDepOptions.map((g) => { - const wf = workflows?.find((w) => w.id === g.workflowId) - const color = wf?.color ?? 'var(--text-muted)' - const label = g.name ?? wf?.name ?? 'Workflow' - return { - label, - value: `${DEP_VALUE_PREFIX_GROUP}${g.id}`, - iconElement: ( -
)} - {displayContent} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-render.tsx new file mode 100644 index 00000000000..88f34bfaa60 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-render.tsx @@ -0,0 +1,239 @@ +'use client' + +import type React from 'react' +import { Badge, Checkbox, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { RowExecutionMetadata } from '@/lib/table' +import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { storageToDisplay } from '../../../utils' +import type { DisplayColumn } from '../types' + +/** + * Discriminated union describing every shape a table cell can take. + * + * Workflow-output cells follow a status state machine: they always render + * *something* (a value, a status pill, or a dash), driven by the combination + * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow) + * cells just render the typed value or empty. + * + * `'empty'` is the universal fallback used by both workflow cells (no exec, + * no value, no waiting) and plain cells (null/undefined value). + * + * Adding a new cell appearance is a three-step mechanical change: add a + * variant here, pick it in `resolveCellRender`, render it in `CellRender`. + * TypeScript's exhaustiveness check on the renderer's `switch` (the + * unreachable default) flags any branch you forgot. + */ +export type CellRenderKind = + // Workflow-output cells + | { kind: 'value'; text: string } + | { kind: 'block-error' } + | { kind: 'running' } + | { kind: 'pending-upstream' } + | { kind: 'queued' } + | { kind: 'cancelled' } + | { kind: 'error' } + | { kind: 'waiting'; labels: string[] } + // Plain typed cells + | { kind: 'boolean'; checked: boolean } + | { kind: 'json'; text: string } + | { kind: 'date'; text: string } + | { kind: 'text'; text: string } + // Universal fallback + | { kind: 'empty' } + +interface ResolveCellRenderInput { + value: unknown + exec: RowExecutionMetadata | undefined + column: DisplayColumn + /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */ + waitingOnLabels: string[] | undefined +} + +/** + * Decide which `CellRenderKind` to render for a cell. Pure — easily + * unit-testable in isolation, no JSX involved. + * + * Order matters for workflow cells: block-error wins over a value (the user + * cares about the failure), value wins over running/queued (we have data + * already), and the running/queued branch deliberately collapses pre-enqueue + * `pending` and post-enqueue `queued` into one `Queued` pill so the cell + * doesn't flicker as the row transitions from one to the other. + */ +export function resolveCellRender({ + value, + exec, + column, + waitingOnLabels, +}: ResolveCellRenderInput): CellRenderKind { + const isNull = value === null || value === undefined + + if (column.workflowGroupId) { + const blockId = column.outputBlockId + const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined + const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false + const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) + + if (blockError) return { kind: 'block-error' } + if (!isNull) return { kind: 'value', text: stringifyValue(value) } + + const inFlight = + exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' + if (inFlight && !(groupHasBlockErrors && !blockRunning)) { + if (blockRunning) return { kind: 'running' } + if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' } + // `running` with this block not in `runningBlockIds` = upstream block + // still going; surface as the amber Pending pill per logs convention. + return { kind: 'pending-upstream' } + } + + if (exec?.status === 'cancelled') return { kind: 'cancelled' } + if (exec?.status === 'error') return { kind: 'error' } + if (waitingOnLabels && waitingOnLabels.length > 0) { + return { kind: 'waiting', labels: waitingOnLabels } + } + return { kind: 'empty' } + } + + if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + if (isNull) return { kind: 'empty' } + if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } + if (column.type === 'date') return { kind: 'date', text: String(value) } + return { kind: 'text', text: String(value) } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return JSON.stringify(value) +} + +interface CellRenderProps { + kind: CellRenderKind + /** When true the static content sits underneath the InlineEditor overlay + * and should be visually hidden (but kept in flow to preserve cell size). */ + isEditing: boolean +} + +/** + * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business + * logic — adding a new cell appearance means adding a new `case` here. The + * exhaustiveness check on the `switch` (the unreachable default) flags any + * variant you forgot to handle. + */ +export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { + switch (kind.kind) { + case 'value': + return ( + + {kind.text} + + ) + + case 'block-error': + case 'error': + return + + case 'running': + return + + case 'pending-upstream': + return + + case 'cancelled': + return + + case 'queued': + return ( + + + Queued + + + ) + + case 'waiting': + return ( + + + + + + Waiting + + + + + Waiting on {kind.labels.map((l) => `"${l}"`).join(', ')} + + + + ) + + case 'boolean': + return ( +
+ +
+ ) + + case 'json': + return ( + + {kind.text} + + ) + + case 'date': + return ( + + {storageToDisplay(kind.text)} + + ) + + case 'text': + return ( + + {kind.text} + + ) + + case 'empty': + return + + default: { + // Exhaustiveness guard: TypeScript flags this branch if a new + // `CellRenderKind` variant is added without a matching `case` above. + const _exhaustive: never = kind + return _exhaustive + } + } +} + +/** + * Workflow-output cells are hand-editable; while editing, the static content + * must stay in flow (so the cell doesn't collapse) but be visually hidden so + * the InlineEditor overlay shows through. Plain wrapper around any non-text + * variant. + */ +function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) { + if (!isEditing) return <>{children} + return
{children}
+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 7d1103d1009..26b029a2a52 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -9,6 +9,10 @@ import { Button, Checkbox, Download, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Modal, ModalBody, ModalContent, @@ -67,7 +71,15 @@ import type { DeletedRowSnapshot } from '@/stores/table/types' import { useContextMenu, useRowExecution, useTable } from '../../hooks' import type { EditingCell, QueryOptions, SaveReason } from '../../types' import { cleanCellValue, storageToDisplay } from '../../utils' -import { type ColumnConfigState, ColumnSidebar } from '../column-sidebar/column-sidebar' +import { + type ColumnConfig, + ColumnConfigSidebar, +} from '../column-config-sidebar/column-config-sidebar' +import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar/column-types' +import { + type WorkflowConfig, + WorkflowSidebar, +} from '../workflow-sidebar/workflow-sidebar' import { ContextMenu } from '../context-menu' import { RowModal } from '../row-modal' import { TableFilter } from '../table-filter' @@ -664,7 +676,8 @@ export function Table({ const handleViewExecution = useCallback(() => { if (!contextMenuExecutionId) return - setConfigState(null) + setColumnConfig(null) + setWorkflowConfig(null) setExecutionDetailsId(contextMenuExecutionId) closeContextMenu() }, [contextMenuExecutionId, closeContextMenu]) @@ -2094,15 +2107,6 @@ export function Table({ return name }, []) - const handleAddColumn = useCallback(() => { - // Open the sidebar in `'create'` mode — nothing is persisted until the - // user fills in name/type and hits Save. The sidebar's save flow handles - // both scalar (`addColumn`) and workflow-group (`addWorkflowGroup`) paths. - const name = generateColumnName() - setExecutionDetailsId(null) - setConfigState({ mode: 'create', columnName: name, proposedName: name }) - }, [generateColumnName]) - const handleChangeType = useCallback((columnName: string, newType: ColumnDefinition['type']) => { const column = columnsRef.current.find((c) => c.name === columnName) const previousType = column?.type @@ -2188,26 +2192,71 @@ export function Table({ * - `{ mode: 'create' }` → user picked a workflow from "Add column"; column doesn't exist yet, * created on Save in a single POST. */ - const [configState, setConfigState] = useState(null) + const [columnConfig, setColumnConfig] = useState(null) + const [workflowConfig, setWorkflowConfig] = useState(null) /** Execution id whose run details are open in the slideout. */ const [executionDetailsId, setExecutionDetailsId] = useState(null) /** * Right padding added to the table's scroll content while a slideout panel - * is open, equal to the panel's width. Without it, the rightmost columns are - * clipped under the panel and there's no way to scroll them into view. - * The two panels are mutually exclusive (each opener closes the other). + * is open, equal to the panel's width. The three panels (column config, + * workflow config, log details) are mutually exclusive — opening any one + * closes the others. */ const logPanelWidth = useLogDetailsUIStore((state) => state.panelWidth) - const sidebarReservedPx = configState - ? COLUMN_SIDEBAR_WIDTH - : executionDetailsId - ? logPanelWidth - : 0 - - const handleConfigureColumn = useCallback((columnName: string) => { + const sidebarReservedPx = + columnConfig || workflowConfig + ? COLUMN_SIDEBAR_WIDTH + : executionDetailsId + ? logPanelWidth + : 0 + + /** Open one of the two sidebars. The helpers enforce the "only one open" invariant. */ + const openColumnConfig = useCallback((cfg: ColumnConfig) => { setExecutionDetailsId(null) - setConfigState({ mode: 'edit', columnName }) + setWorkflowConfig(null) + setColumnConfig(cfg) }, []) + const openWorkflowConfig = useCallback((cfg: WorkflowConfig) => { + setExecutionDetailsId(null) + setColumnConfig(null) + setWorkflowConfig(cfg) + }, []) + + /** + * Open the column-config sidebar pre-seeded with the chosen scalar type. + * Nothing is persisted until the user fills in the name and hits Save. + */ + const handleAddColumnOfType = useCallback( + (type: ColumnDefinition['type']) => { + openColumnConfig({ mode: 'create', proposedName: generateColumnName(), type }) + }, + [generateColumnName, openColumnConfig] + ) + + /** Open the workflow-config sidebar to spawn a brand-new workflow group. */ + const handleAddWorkflowColumn = useCallback(() => { + openWorkflowConfig({ mode: 'create', proposedName: generateColumnName() }) + }, [generateColumnName, openWorkflowConfig]) + + const handleConfigureColumn = useCallback( + (columnName: string) => { + const column = columnsRef.current.find((c) => c.name === columnName) + if (column?.workflowGroupId) { + // Workflow-output column header → single-output sub-mode. + openWorkflowConfig({ mode: 'edit-output', columnName }) + } else { + openColumnConfig({ mode: 'edit', columnName }) + } + }, + [openColumnConfig, openWorkflowConfig] + ) + + const handleConfigureWorkflowGroup = useCallback( + (groupId: string) => { + openWorkflowConfig({ mode: 'edit-group', groupId }) + }, + [openWorkflowConfig] + ) const handleDeleteWorkflowGroup = useCallback( (groupId: string) => { @@ -2441,12 +2490,12 @@ export function Table({ label: tableData?.name ?? '', editing: tableHeaderRename.editingId ? { - isEditing: true, - value: tableHeaderRename.editValue, - onChange: tableHeaderRename.setEditValue, - onSubmit: tableHeaderRename.submitRename, - onCancel: tableHeaderRename.cancelRename, - } + isEditing: true, + value: tableHeaderRename.editValue, + onChange: tableHeaderRename.setEditValue, + onSubmit: tableHeaderRename.submitRename, + onCancel: tableHeaderRename.cancelRename, + } : undefined, dropdownItems: [ { @@ -2478,13 +2527,14 @@ export function Table({ ] ) - const createTrigger = useMemo( - () => - userPermissions.canEdit ? ( - - ) : null, - [handleAddColumn, addColumnMutation.isPending, userPermissions.canEdit] - ) + const createTrigger = userPermissions.canEdit ? ( + + ) : null const handleExportCsv = useCallback(async () => { if (!tableData) return @@ -2500,19 +2550,19 @@ export function Table({ () => tableData ? [ - { - label: 'Import CSV', - icon: Upload, - onClick: () => setIsImportCsvOpen(true), - disabled: userPermissions.canEdit !== true, - }, - { - label: 'Export CSV', - icon: Download, - onClick: () => void handleExportCsv(), - disabled: tableData.rowCount === 0, - }, - ] + { + label: 'Import CSV', + icon: Upload, + onClick: () => setIsImportCsvOpen(true), + disabled: userPermissions.canEdit !== true, + }, + { + label: 'Export CSV', + icon: Download, + onClick: () => void handleExportCsv(), + disabled: tableData.rowCount === 0, + }, + ] : undefined, [tableData, userPermissions.canEdit, handleExportCsv] ) @@ -2819,7 +2869,7 @@ export function Table({ } groupId={g.groupId} onSelectGroup={handleGroupSelect} - onOpenConfig={handleConfigureColumn} + onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)} onRunGroup={userPermissions.canEdit ? handleRunGroup : undefined} selectedRowIds={selectedRowIds} onInsertLeft={ @@ -2904,9 +2954,11 @@ export function Table({ /> ))} {userPermissions.canEdit && ( - )} @@ -3004,14 +3056,21 @@ export function Table({ )}
- setConfigState(null)} + setColumnConfig(null)} existingColumn={ - configState?.mode === 'edit' - ? (columns.find((c) => c.name === configState.columnName) ?? null) + columnConfig?.mode === 'edit' + ? (columns.find((c) => c.name === columnConfig.columnName) ?? null) : null } + workspaceId={workspaceId} + tableId={tableId} + onColumnRename={handleColumnRename} + /> + setWorkflowConfig(null)} allColumns={columns} workflowGroups={tableWorkflowGroups} workflows={workflows} @@ -3725,42 +3784,68 @@ const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ ) }) -const AddColumnButton = React.memo(function AddColumnButton({ - onClick, - disabled, -}: { - onClick: () => void - disabled: boolean -}) { - return ( - - - - ) -}) - const HEADER_ADD_COLUMN_ICON = -function HeaderAddColumnTrigger({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { - return ( - +interface NewColumnDropdownProps { + /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders + * the in-table column-header `` trigger. Same dropdown content either way. */ + trigger: 'header' | 'inline-header' + disabled: boolean + onPickType: (type: ColumnDefinition['type']) => void + onPickWorkflow: () => void +} + +/** + * "+ New column" dropdown — the single entry point for creating a column. + * Lists every column type plus "Workflow"; picking a type opens the right + * sidebar pre-seeded. + */ +function NewColumnDropdown({ + trigger, + disabled, + onPickType, + onPickWorkflow, +}: NewColumnDropdownProps) { + const menu = ( + + + {trigger === 'header' ? ( + + ) : ( + + )} + + + {COLUMN_TYPE_OPTIONS.map((option) => { + const Icon = option.icon + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return ( + + + {option.label} + + ) + })} + + ) + + // The in-table trigger lives inside a `` so it must be a ``. The + // header trigger lives in the page header so it sits inline. + return trigger === 'inline-header' ? {menu} : menu } const AddRowButton = React.memo(function AddRowButton({ onClick }: { onClick: () => void }) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx new file mode 100644 index 00000000000..c68ae90e158 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx @@ -0,0 +1,119 @@ +'use client' + +import { Combobox, type ComboboxOptionGroup, Label } from '@/components/emcn' +import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +const DEP_VALUE_PREFIX_COLUMN = 'col:' +const DEP_VALUE_PREFIX_GROUP = 'group:' + +interface RunSettingsSectionProps { + scalarDepColumns: ColumnDefinition[] + groupDepOptions: WorkflowGroup[] + /** Plain column names this group waits on. */ + deps: string[] + /** Producing workflow group ids this group waits on. */ + groupDeps: string[] + workflows: WorkflowMetadata[] | undefined + onChangeDeps: (next: string[]) => void + onChangeGroupDeps: (next: string[]) => void +} + +/** + * "Run after" picker: which upstream columns + workflow groups must be + * filled before this group fires. Empty selection = the group fires on any + * row change. Same Combobox shape as the Output columns picker. + * + * Inner derivations (`groups`, `flatOptions`, `selected`) are computed inline + * — the previous version memo'd each, but the deps change frequently and the + * arrays are short, so the memos never paid for themselves. + */ +export function RunSettingsSection({ + scalarDepColumns, + groupDepOptions, + deps, + groupDeps, + workflows, + onChangeDeps, + onChangeGroupDeps, +}: RunSettingsSectionProps) { + const groups: ComboboxOptionGroup[] = [] + if (scalarDepColumns.length > 0) { + groups.push({ + section: 'Columns', + items: scalarDepColumns.map((c) => ({ + label: c.name, + value: `${DEP_VALUE_PREFIX_COLUMN}${c.name}`, + })), + }) + } + if (groupDepOptions.length > 0) { + groups.push({ + section: 'Workflow groups', + items: groupDepOptions.map((g) => { + const wf = workflows?.find((w) => w.id === g.workflowId) + const color = wf?.color ?? 'var(--text-muted)' + const label = g.name ?? wf?.name ?? 'Workflow' + return { + label, + value: `${DEP_VALUE_PREFIX_GROUP}${g.id}`, + iconElement: ( +
- {showDivider && ( -
-
-
- )} + {showDivider && }
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 1753bc2da46..de88bd656b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -16,7 +16,7 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { Button, Loader, Tooltip } from '@/components/emcn' +import { Button, FieldDivider, Loader, Tooltip } from '@/components/emcn' import { captureEvent } from '@/lib/posthog/client' import { buildCanonicalIndex, @@ -542,9 +542,7 @@ export function Editor() { )}
-
-
-
+ )} {subBlocks.length === 0 && !isWorkflowBlock ? ( @@ -605,11 +603,7 @@ export function Editor() { : undefined } /> - {showDivider && ( -
-
-
- )} + {showDivider && }
) })} @@ -660,9 +654,7 @@ export function Editor() { allowExpandInPreview={false} /> {index < advancedOnlySubBlocks.length - 1 && ( -
-
-
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 254642e50ba..6b29e31d032 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -24,6 +24,7 @@ import { ChevronDown, Code, Combobox, + FieldDivider, Input, Label, Tooltip, @@ -1442,15 +1443,7 @@ function PreviewEditorContent({ )}
-
-
-
+
)} @@ -1473,15 +1466,7 @@ function PreviewEditorContent({ disabled={true} /> {index < visibleSubBlocks.length - 1 && ( -
-
-
+ )}
))} diff --git a/apps/sim/components/emcn/components/field-divider/field-divider.tsx b/apps/sim/components/emcn/components/field-divider/field-divider.tsx new file mode 100644 index 00000000000..8d1f6b10212 --- /dev/null +++ b/apps/sim/components/emcn/components/field-divider/field-divider.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/core/utils/cn' + +const DASHED_DIVIDER_STYLE = { + backgroundImage: + 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', +} as const + +export interface FieldDividerProps extends React.HTMLAttributes { + /** + * Adds the `subblock-divider` marker class so the workflow editor's CSS + * (`globals.css` `:has()` rule) can hide the divider when adjacent subblocks + * render empty content. Default `false` — only the workflow editor needs it. + */ + subblockMarker?: boolean +} + +/** + * Dashed horizontal divider used between fields in form-style panels (the + * workflow editor's subblock list, the table column/workflow sidebars). Same + * visual as the existing `subblock-divider` pattern in `editor.tsx`, + * promoted here so consumers don't keep redefining the gradient style. + * + * @example + * ```tsx + * ... + * + * ... + * ``` + */ +function FieldDivider({ className, subblockMarker = false, ...props }: FieldDividerProps) { + return ( +
+
+
+ ) +} + +export { FieldDivider } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 15b6cefd77f..e6cdf8e8d22 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -59,6 +59,7 @@ export { DropdownMenuTrigger, } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' +export { FieldDivider, type FieldDividerProps } from './field-divider/field-divider' export { FormField, type FormFieldProps } from './form-field/form-field' export { Input, type InputProps, inputVariants } from './input/input' export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp' diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 241bf2afb60..4c2568a46cc 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1310,6 +1310,7 @@ interface UpdateWorkflowGroupVariables { dependencies?: WorkflowGroupDependencies outputs?: WorkflowGroupOutput[] newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns'] + mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates'] } export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 05248757a68..e42fb48151a 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -752,6 +752,20 @@ export const addWorkflowGroupBodySchema = z.object({ autoRun: z.boolean().optional(), }) +/** + * Re-points an existing column to a different workflow output. Use when the + * user changes which `(blockId, path)` flows into a column they already have, + * without restructuring the rest of the group's outputs. Distinct from the + * `outputs` add/remove diff: the column keeps its identity, type, deps, and + * row position; only its source mapping changes (and its row data is + * cleared, since the new output may produce a different shape). + */ +const workflowGroupMappingUpdateSchema = z.object({ + columnName: z.string().min(1), + blockId: z.string().min(1), + path: z.string().min(1), +}) + export const updateWorkflowGroupBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), groupId: z.string().min(1), @@ -760,6 +774,12 @@ export const updateWorkflowGroupBodySchema = z.object({ dependencies: workflowGroupDependenciesSchema.optional(), outputs: z.array(workflowGroupOutputSchema).optional(), newOutputColumns: z.array(workflowGroupOutputColumnSchema).optional(), + /** + * Per-column mapping swaps: keep the column, change the source `(blockId, + * path)`. Applied before the `outputs` add/remove diff. Each entry's + * `columnName` must already exist in the group's outputs. + */ + mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(), }) export const deleteWorkflowGroupBodySchema = z.object({ diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 106c1d5df22..4bde33c5639 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -2239,7 +2239,6 @@ export async function renameColumn( columns: updatedColumns, ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) const metadata = table.metadata as TableMetadata | null let updatedMetadata = metadata @@ -2253,6 +2252,11 @@ export async function renameColumn( columnOrder: updatedMetadata.columnOrder.map((n) => (n === actualOldName ? data.newName : n)), } } + // Validate against the *post-rename* column order. The schema's workflow + // group outputs already reference the new name, so checking against the old + // columnOrder makes the renamed output look "missing" from its group and + // falsely flags the remaining siblings as non-contiguous. + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) const now = new Date() const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { @@ -2781,12 +2785,40 @@ export async function updateWorkflowGroup( } const group = groups[groupIndex] - const newOutputs = data.outputs ?? group.outputs + // Apply `mappingUpdates` first: each entry repoints an existing output's + // `(blockId, path)` while preserving the column. We patch the **old** view + // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the + // swap as a remove+add. The corresponding row data is cleared after the + // schema write so stale values from the old source don't linger. + const mappingUpdates = data.mappingUpdates ?? [] + const remappedColumnNames = new Set() + let oldOutputs = group.outputs + if (mappingUpdates.length > 0) { + const updateByName = new Map(mappingUpdates.map((u) => [u.columnName, u])) + for (const u of mappingUpdates) { + const exists = oldOutputs.some((o) => o.columnName === u.columnName) + if (!exists) { + throw new Error( + `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` + ) + } + } + oldOutputs = oldOutputs.map((o) => { + const u = updateByName.get(o.columnName) + if (!u) return o + remappedColumnNames.add(o.columnName) + return { ...o, blockId: u.blockId, path: u.path } + }) + } + + // If the caller passed `outputs`, that's the new full set. If only + // `mappingUpdates` was sent, the new set is the remapped old set. + const newOutputs = data.outputs ?? oldOutputs const oldKey = (o: WorkflowGroupOutput) => `${o.blockId}::${o.path}` - const oldByKey = new Map(group.outputs.map((o) => [oldKey(o), o])) + const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) - const removed = group.outputs.filter((o) => !newByKey.has(oldKey(o))) + const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) const newColDefs = data.newOutputColumns ?? [] const newColByName = new Map(newColDefs.map((c) => [c.name, c])) @@ -2884,10 +2916,20 @@ export async function updateWorkflowGroup( sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` ) } + // Remapped columns keep their identity but read from a new source — clear + // their existing values so the user sees Empty/Waiting until the next run + // repopulates from the new `(blockId, path)`. Skipped columns that were + // also in `removedColumnNames` (the diff dropped them outright). + for (const name of remappedColumnNames) { + if (removedColumnNames.has(name)) continue + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` + ) + } }) logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length})` + `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnNames.size})` ) const updatedTable: TableDefinition = { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 54c4d921405..483d94b782a 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -401,6 +401,13 @@ export interface UpdateWorkflowGroupData { outputs?: WorkflowGroupOutput[] /** Column definitions for any newly-added outputs. */ newOutputColumns?: ColumnDefinition[] + /** + * Per-column mapping swaps: keep the existing column, repoint it at a new + * `(blockId, path)`. Applied before the `outputs` diff and clears the + * affected columns' row data so the next run repopulates from the new + * source. + */ + mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> } export interface DeleteWorkflowGroupData { From 899cd467da1b9fe2a45ac30142939b80f4faaf3b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 5 May 2026 14:17:10 -0700 Subject: [PATCH 10/58] Lint and add auto run toggle --- .../app/api/table/[tableId]/groups/route.ts | 1 + .../column-config-sidebar.tsx | 14 +---- .../components/table/cells/cell-render.tsx | 24 ++++++-- .../table/headers/column-header-menu.tsx | 3 +- .../headers/workflow-group-meta-cell.tsx | 4 +- .../[tableId]/components/table/table.tsx | 55 +++++++----------- .../workflow-sidebar/workflow-sidebar.tsx | 58 ++++++++++++------- .../preview-editor/preview-editor.tsx | 4 +- apps/sim/background/resume-execution.ts | 17 +++--- apps/sim/hooks/queries/tables.ts | 3 +- apps/sim/lib/api/contracts/tables.ts | 7 +++ apps/sim/lib/billing/cleanup-dispatcher.ts | 4 +- .../lib/core/async-jobs/backends/database.ts | 9 ++- apps/sim/lib/core/config/feature-flags.ts | 2 +- apps/sim/lib/table/deps.ts | 6 +- apps/sim/lib/table/service.ts | 1 + apps/sim/lib/table/types.ts | 9 +++ apps/sim/lib/table/workflow-columns.ts | 16 +++-- 18 files changed, 136 insertions(+), 101 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index ff805f73f96..bf74653212a 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -113,6 +113,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.mappingUpdates !== undefined ? { mappingUpdates: validated.mappingUpdates } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, requestId ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index b2e2829faf8..b7331c55b1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -58,11 +58,7 @@ export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) { )} > {props.config && ( - + )} ) @@ -229,13 +225,7 @@ function ColumnConfigBody({ ) } -function RequiredLabel({ - htmlFor, - children, -}: { - htmlFor?: string - children: React.ReactNode -}) { +function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { return (
diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index 81a67be294f..f7bd79d2a37 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -44,9 +44,7 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { const { findCellContextByExecutionId } = await import('@/lib/table/workflow-columns') const cellContext = await findCellContextByExecutionId(parentExecutionId) - let cellOnBlockComplete: - | ((blockId: string, output: unknown) => Promise) - | undefined + let cellOnBlockComplete: ((blockId: string, output: unknown) => Promise) | undefined let writeCellTerminal: | ((status: 'completed' | 'error' | 'paused', error: string | null) => Promise) | undefined @@ -154,11 +152,14 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { }) } } else { - logger.warn('Cell context found but table or group missing — falling back to plain resume', { - parentExecutionId, - tableId: cellContext.tableId, - groupId: cellContext.groupId, - }) + logger.warn( + 'Cell context found but table or group missing — falling back to plain resume', + { + parentExecutionId, + tableId: cellContext.tableId, + groupId: cellContext.groupId, + } + ) } } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 4c2568a46cc..1fc8baf75bd 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -17,7 +17,6 @@ import { import { toast } from '@/components/emcn' import { isValidationError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' -import { optimisticallyScheduleNewlyEligibleGroups } from '@/lib/table/deps' import type { ContractJsonResponse } from '@/lib/api/contracts' import { type AddWorkflowGroupBodyInput, @@ -69,6 +68,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupOutput, } from '@/lib/table' +import { optimisticallyScheduleNewlyEligibleGroups } from '@/lib/table/deps' import { useSocket } from '@/app/workspace/providers/socket-provider' /** Short poll to surface running → completed transitions from the server without a dedicated realtime channel. */ @@ -1311,6 +1311,7 @@ interface UpdateWorkflowGroupVariables { outputs?: WorkflowGroupOutput[] newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns'] mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates'] + autoRun?: boolean } export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index e42fb48151a..6cc739cf2ac 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -744,6 +744,11 @@ export const addWorkflowGroupBodySchema = z.object({ name: z.string().optional(), dependencies: workflowGroupDependenciesSchema.optional(), outputs: z.array(workflowGroupOutputSchema).min(1), + /** When `false`, the group never auto-fires from the scheduler — it can + * only be triggered manually. Defaults to `true`. Persisted on the + * group; distinct from the top-level `autoRun` below which is a + * one-shot "schedule existing rows on creation" flag. */ + autoRun: z.boolean().optional(), }), outputColumns: z.array(workflowGroupOutputColumnSchema).min(1), /** When false, skip auto-scheduling existing rows after the group is added. @@ -780,6 +785,8 @@ export const updateWorkflowGroupBodySchema = z.object({ * `columnName` must already exist in the group's outputs. */ mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(), + /** Toggle the group's persisted auto-run flag. Omit to leave unchanged. */ + autoRun: z.boolean().optional(), }) export const deleteWorkflowGroupBodySchema = z.object({ diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index 2de02b27d66..e1dea22e95e 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -143,9 +143,7 @@ export async function resolveCleanupScope( } } -async function buildCleanupRunner( - jobType: CleanupJobType -): Promise { +async function buildCleanupRunner(jobType: CleanupJobType): Promise { const cleanupRunner = await (async () => { switch (jobType) { case 'cleanup-logs': diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index 54c4983bd5e..4c96bb86e94 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -90,7 +90,14 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Enqueued job', { jobId, type }) if (options?.runner) { - this.runInline(type, jobId, payload, options.runner, options.concurrencyKey, options.concurrencyLimit) + this.runInline( + type, + jobId, + payload, + options.runner, + options.concurrencyKey, + options.concurrencyLimit + ) } return jobId } diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index fbef06c142a..154790f596e 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -29,7 +29,7 @@ try { } catch { // invalid URL — isHosted stays false } -export const isHosted = true//appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') +export const isHosted = true //appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') /** * Is billing enforcement enabled diff --git a/apps/sim/lib/table/deps.ts b/apps/sim/lib/table/deps.ts index b01c5dc65f5..138baefb46d 100644 --- a/apps/sim/lib/table/deps.ts +++ b/apps/sim/lib/table/deps.ts @@ -82,11 +82,7 @@ export function optimisticallyScheduleNewlyEligibleGroups( const exec = beforeRow.executions?.[group.id] // Don't overwrite an in-flight or terminal state — only "no exec" or a // prior `cancelled` / `error` is a candidate to retry on dep-fill. - if ( - exec && - exec.status !== 'cancelled' && - exec.status !== 'error' - ) { + if (exec && exec.status !== 'cancelled' && exec.status !== 'error') { continue } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 4bde33c5639..cbcb99d6152 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -2869,6 +2869,7 @@ export async function updateWorkflowGroup( name: data.name ?? group.name, dependencies: data.dependencies ?? group.dependencies, outputs: newOutputs, + ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), } const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) const updatedSchema: TableSchema = { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 483d94b782a..4589e41285e 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -62,6 +62,13 @@ export interface WorkflowGroup { name?: string dependencies?: WorkflowGroupDependencies outputs: WorkflowGroupOutput[] + /** + * When `false`, the group never auto-fires from the scheduler — it can only + * be triggered manually via the "Run" actions. Defaults to `true` so + * existing groups keep firing on dep satisfaction. Persisted alongside the + * group definition; the scheduler reads it in `isGroupEligible`. + */ + autoRun?: boolean } /** @@ -408,6 +415,8 @@ export interface UpdateWorkflowGroupData { * source. */ mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ + autoRun?: boolean } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index bfa2bd604fb..efed0bc329d 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -36,13 +36,19 @@ export { } from './deps' /** - * Per-(row, group) eligibility: returns true if a cell job should be enqueued - * for this pair right now. Skip when the group is in flight (`queued`, - * `running`, or `pending` with a `jobId` already stamped) or in a terminal - * state. Plain `pending` without a jobId is the "ready to dispatch" state — - * the run route sets it and the scheduler is what actually enqueues the job. + * Per-(row, group) eligibility for the **automatic** scheduler path. Skip + * when the group has `autoRun: false` set (manual-only), when the group is + * in flight (`queued`, `running`, or `pending` with a `jobId` already + * stamped), or in a terminal state. Plain `pending` without a jobId is the + * "ready to dispatch" state — the run route sets it and the scheduler is + * what actually enqueues the job. + * + * The manual "Run all" path (`triggerWorkflowGroupRun`) uses + * `areGroupDepsSatisfied` directly and bypasses this guard, so the user can + * still kick off a run on a group that's set to manual-only. */ export function isGroupEligible(group: WorkflowGroup, row: TableRow): boolean { + if (group.autoRun === false) return false const exec = row.executions?.[group.id] const status = exec?.status if ( From dffbb80e7ccdaaf6d9d89100ee8ab1589cf4d1db Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 5 May 2026 17:02:48 -0700 Subject: [PATCH 11/58] fix column reordering, add action bar --- .../table-action-bar/table-action-bar.tsx | 132 ++++++++++++++++++ .../table/headers/column-header-menu.tsx | 65 +++------ .../table/headers/column-type-icon.tsx | 47 +++---- .../[tableId]/components/table/table.tsx | 112 ++++++++++++--- apps/sim/lib/table/service.ts | 53 ++++++- 5 files changed, 313 insertions(+), 96 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx new file mode 100644 index 00000000000..436be1fe055 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -0,0 +1,132 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { Square } from 'lucide-react' +import { Button, Tooltip } from '@/components/emcn' +import { PlayOutline, RefreshCw } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +interface TableActionBarProps { + /** Number of rows currently selected (checkbox + multi-row range). */ + selectedCount: number + /** Total running/queued workflow cells across the selected rows. Drives the + * Stop button's visibility (hidden when 0) and label. */ + runningCount: number + /** Whether the table has any workflow columns. The bar is hidden entirely + * when there are none — Run/Stop have nothing to act on. */ + hasWorkflowColumns: boolean + /** Smart run: fire workflows only on rows whose cells are empty / errored + * / cancelled. Skips already-completed cells. Maps to server + * `runMode: 'incomplete'`. The default action — what "play" should + * intuitively do. */ + onRun: () => void + /** Forceful re-run: fire workflows on every selected row, including ones + * that already have results. Maps to server `runMode: 'all'`. */ + onRerun: () => void + /** Cancel running/queued cells across selected rows. */ + onStopWorkflows: () => void + /** Disables actions while a bulk mutation is in flight. */ + isLoading?: boolean + /** Additional className for the floating wrapper — used to lift the bar + * above bottom-anchored UI like a pagination row. */ + className?: string +} + +/** + * Floating action bar shown at the bottom of the viewport when one or more + * rows are selected on a table that has workflow columns. Mirrors the shell + * + interaction pattern from the knowledge-base `` so the bulk- + * action surface reads consistently across the product. + * + * Two run actions: **Play** is the smart default (run only on empty / failed + * cells); **Refresh** forces a full re-run on every selected row. **Stop** + * only appears when ≥1 selected row has a running cell. + */ +export function TableActionBar({ + selectedCount, + runningCount, + hasWorkflowColumns, + onRun, + onRerun, + onStopWorkflows, + isLoading = false, + className, +}: TableActionBarProps) { + const visible = hasWorkflowColumns && selectedCount > 0 + const stopLabel = + runningCount === 1 ? 'Stop running workflow' : `Stop ${runningCount} running workflows` + const runLabel = 'Run workflows on empty or failed cells' + const rerunLabel = + selectedCount === 1 ? 'Re-run workflows on row' : `Re-run workflows on ${selectedCount} rows` + + return ( + + {visible && ( + +
+ + {selectedCount} selected + + +
+ + + + + {runLabel} + + + + + + + {rerunLabel} + + + {runningCount > 0 && ( + + + + + {stopLabel} + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx index d2fe9005048..f36d6c9af96 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx @@ -35,6 +35,8 @@ interface ColumnHeaderMenuProps { onDragLeave?: () => void workflows?: WorkflowMetadata[] workflowGroups?: WorkflowGroup[] + /** Source-info entry for workflow-output columns; supplies the producing + * block's icon component. The block's color is intentionally not used. */ sourceInfo?: ColumnSourceInfo onOpenConfig: (columnName: string) => void /** Opens a popup preview of the column's underlying workflow. Surfaced in @@ -94,10 +96,6 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ? 'Hide column' : 'Delete workflow' : undefined - const workflowColor = configuredWorkflow?.color - const blockIconInfo = sourceInfo?.blockIconInfo - const blockName = sourceInfo?.blockName - useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() @@ -190,6 +188,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const th = e.currentTarget as HTMLElement const related = e.relatedTarget as Node | null if (related && th.contains(related)) return + // Don't clear when the cursor is moving to another column header — the + // next dragover will set the right target. Clearing here causes the + // drop indicator to flicker between sibling columns of a workflow + // group (and any adjacent column hop in general). + if (related && related instanceof Element && related.closest('th')) return onDragLeave?.() }, [onDragLeave] @@ -247,8 +250,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
- {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} +
) : (
@@ -296,26 +286,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ draggable={false} > - {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + type={column.type} + isWorkflowColumn={!!column.workflowGroupId} + blockIconInfo={sourceInfo?.blockIconInfo} + /> + + {column.workflowGroupId ? column.headerLabel : column.name} +