diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx index 60c3cc05336..54a2c7f2dea 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -10,6 +10,9 @@ interface CellContentProps { value: unknown exec?: RowExecutionMetadata column: DisplayColumn + /** Current workspace id — lets string cells holding an in-workspace resource + * URL render as a tagged-resource chip instead of a plain external link. */ + workspaceId: string isEditing: boolean initialCharacter?: string | null onSave: (value: unknown, reason: SaveReason) => void @@ -34,6 +37,7 @@ export function CellContent({ value, exec, column, + workspaceId, isEditing, initialCharacter, onSave, @@ -41,7 +45,14 @@ export function CellContent({ waitingOnLabels, isEnrichmentOutput, }: CellContentProps) { - const kind = resolveCellRender({ value, exec, column, waitingOnLabels, isEnrichmentOutput }) + const kind = resolveCellRender({ + value, + exec, + column, + waitingOnLabels, + isEnrichmentOutput, + currentWorkspaceId: workspaceId, + }) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 557186b7668..91cabea7994 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -9,6 +9,7 @@ import type { RowExecutionMetadata } from '@/lib/table' import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' import { storageToDisplay } from '../../../utils' import type { DisplayColumn } from '../types' +import { SimResourceCell, type SimResourceType } from './sim-resource-cell' export type CellRenderKind = // Workflow-output cells @@ -26,6 +27,13 @@ export type CellRenderKind = | { kind: 'json'; text: string } | { kind: 'date'; text: string } | { kind: 'url'; text: string; href: string; domain: string } + | { + kind: 'sim-resource' + workspaceId: string + resourceType: SimResourceType + resourceId: string + href: string + } | { kind: 'text'; text: string } // Universal fallback | { kind: 'empty' } @@ -38,6 +46,9 @@ interface ResolveCellRenderInput { /** Column is an enrichment-group output — a completed-but-empty cell renders * "Not found" rather than a blank, since the enrichment ran and matched nothing. */ isEnrichmentOutput?: boolean + /** Current workspace id — a URL pointing to a resource in this workspace + * renders as a tagged-resource chip rather than a plain external link. */ + currentWorkspaceId?: string } export function resolveCellRender({ @@ -46,6 +57,7 @@ export function resolveCellRender({ column, waitingOnLabels, isEnrichmentOutput, + currentWorkspaceId, }: ResolveCellRenderInput): CellRenderKind { const isNull = value === null || value === undefined const isEmpty = isNull || value === '' @@ -97,6 +109,18 @@ export function resolveCellRender({ if (column.type === 'date') return { kind: 'date', text: String(value) } if (column.type === 'string') { const text = stringifyValue(value) + if (currentWorkspaceId) { + const resource = extractSimResourceInfo(text) + if (resource && resource.workspaceId === currentWorkspaceId) { + return { + kind: 'sim-resource', + workspaceId: resource.workspaceId, + resourceType: resource.resourceType, + resourceId: resource.resourceId, + href: resource.href, + } + } + } const urlInfo = extractUrlInfo(text) if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain } return { kind: 'text', text } @@ -131,6 +155,43 @@ function extractUrlInfo(text: string): { href: string; domain: string } | null { return null } +/** Maps a workspace route section to the sim resource kind it addresses. */ +const SIM_RESOURCE_SECTIONS: Record = { + w: 'workflow', + tables: 'table', + knowledge: 'knowledge', + files: 'file', +} + +/** + * Recognizes a `/workspace/{id}/{section}/{resourceId}` URL (absolute or + * relative) pointing to a sim resource and returns its descriptor. The href is + * the pathname so the link stays within the current deployment. Returns null + * for anything that isn't a single-segment resource route. + */ +function extractSimResourceInfo( + text: string +): { workspaceId: string; resourceType: SimResourceType; resourceId: string; href: string } | null { + const trimmed = text.trim() + if (!trimmed) return null + let pathname: string + if (/^https?:\/\//i.test(trimmed)) { + try { + pathname = new URL(trimmed).pathname + } catch { + return null + } + } else if (trimmed.startsWith('/')) { + pathname = trimmed.split(/[?#]/)[0] + } else { + return null + } + const match = pathname.match(/^\/workspace\/([^/]+)\/(w|tables|knowledge|files)\/([^/]+)\/?$/) + if (!match) return null + const [, workspaceId, section, resourceId] = match + return { workspaceId, resourceType: SIM_RESOURCE_SECTIONS[section], resourceId, href: pathname } +} + interface CellRenderProps { kind: CellRenderKind isEditing: boolean @@ -270,6 +331,17 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'sim-resource': + return ( + + ) + case 'text': return ( = { + workflow: 'Workflow', + table: 'Table', + knowledge: 'Knowledge base', + file: 'File', +} + +interface SimResourceCellProps { + /** Always the current workspace — the resolver only emits this kind for same-workspace URLs. */ + workspaceId: string + resourceType: SimResourceType + resourceId: string + /** In-app pathname the resource link navigates to. */ + href: string + isEditing: boolean +} + +/** + * Renders a cell whose value is a URL pointing to a sim resource in the current + * workspace as a tagged-resource chip — the same icon (and per-workflow colored + * square) used for @-style resource mentions, plus the resource's name as a link. + * Only the list matching `resourceType` is fetched; the other queries stay + * disabled so a sim-resource cell subscribes to a single shared list. + */ +export function SimResourceCell({ + workspaceId, + resourceType, + resourceId, + href, + isEditing, +}: SimResourceCellProps) { + const { data: workflows = [] } = useWorkflows( + resourceType === 'workflow' ? workspaceId : undefined + ) + const { data: tables = [] } = useTablesList(resourceType === 'table' ? workspaceId : undefined) + const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, { + enabled: resourceType === 'knowledge', + }) + const { data: files = [] } = useWorkspaceFiles(workspaceId, 'active', { + enabled: resourceType === 'file', + }) + + const workflow = + resourceType === 'workflow' ? workflows.find((w) => w.id === resourceId) : undefined + + const name = useMemo(() => { + switch (resourceType) { + case 'workflow': + return workflow?.name + case 'table': + return tables.find((t) => t.id === resourceId)?.name + case 'knowledge': + return knowledgeBases.find((kb) => kb.id === resourceId)?.name + case 'file': + return files.find((f) => f.id === resourceId)?.name + } + }, [resourceType, resourceId, workflow, tables, knowledgeBases, files]) + + const label = name ?? FALLBACK_LABEL[resourceType] + + const context: ChatMessageContext = + resourceType === 'workflow' + ? { kind: 'workflow', label, workflowId: resourceId } + : resourceType === 'table' + ? { kind: 'table', label, tableId: resourceId } + : resourceType === 'knowledge' + ? { kind: 'knowledge', label, knowledgeId: resourceId } + : { kind: 'file', label, fileId: resourceId } + + return ( + + + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {label} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index e228edba84d..d8396f8f613 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -23,6 +23,9 @@ import { type NormalizedSelection, resolveCellExec } from './utils' export interface DataRowProps { row: TableRowType columns: DisplayColumn[] + /** Current workspace id — forwarded to cells so in-workspace resource URLs + * render as tagged-resource chips. */ + workspaceId: string rowIndex: number isFirstRow: boolean editingColumnName: string | null @@ -94,6 +97,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { if ( prev.row !== next.row || prev.columns !== next.columns || + prev.workspaceId !== next.workspaceId || prev.rowIndex !== next.rowIndex || prev.isFirstRow !== next.isFirstRow || prev.editingColumnName !== next.editingColumnName || @@ -135,6 +139,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { export const DataRow = React.memo(function DataRow({ row, columns, + workspaceId, rowIndex, isFirstRow, editingColumnName, @@ -196,7 +201,12 @@ export const DataRow = React.memo(function DataRow({ tabIndex={0} aria-checked={isRowSelected} aria-label={`Select row ${rowIndex + 1}`} - className='group/checkbox flex h-[20px] shrink-0 items-center justify-center' + className={cn( + 'group/checkbox flex h-[20px] shrink-0 items-center justify-end', + // Lighter right inset for narrow indices (≤3 digits → numDivWidth ≤ 28); + // full 4px once the column widens (4+ digits, numDivWidth ≥ 36). + numDivWidth >= 36 ? 'pr-1' : 'pr-0.5' + )} style={{ width: numDivWidth }} onMouseDown={(e) => { if (e.button !== 0) return @@ -208,7 +218,7 @@ export const DataRow = React.memo(function DataRow({ > @@ -310,6 +320,7 @@ export const DataRow = React.memo(function DataRow({ )}
void /** Single-row stop for the per-row gutter button. */ onStopRow: (rowId: string) => void - /** Wholesale cancel — page-header "Stop all". */ - onStopAll: () => void - /** Whether `useCancelTableRuns` is currently in flight. */ - cancelRunsPending: boolean /** * Fired whenever the action-bar selection or running-count derivations * change. Wrapper uses this to render . @@ -258,8 +253,6 @@ export function TableGrid({ onRunRows, onStopRows, onStopRow, - onStopAll, - cancelRunsPending, onSelectionChange, queryOptions, columnRenameSinkRef, @@ -330,8 +323,13 @@ export function TableGrid({ const { data: tableRunState } = useTableRunState(tableId) const activeDispatches = tableRunState?.dispatches - const totalRunning = tableRunState?.runningCellCount ?? 0 const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW + // Actual in-flight cell count = sum of the live per-row map (kept current by + // applyCell's SSE deltas, and the same source the per-row gutter uses). The + // dispatch-scope `runningCellCount` over-counts already-completed groups on + // rows still inside a dispatch's scope — e.g. a cascade where 3 of 4 columns + // finished would read "4 running" instead of "1". + const totalRunning = Object.values(runningByRowId).reduce((sum, n) => sum + n, 0) const tableRowCountRef = useRef(tableData?.rowCount ?? 0) tableRowCountRef.current = tableData?.rowCount ?? 0 @@ -2949,8 +2947,12 @@ export function TableGrid({ rowId: row.id, groupId, executionId: exec?.executionId ?? null, + // Requires a real executionId: an error that never produced an execution + // (e.g. enqueue failure → status 'error' with executionId null) has no + // trace to open, so "View execution" must not offer it. canViewExecution: !isEnrichmentGroup && + Boolean(exec?.executionId) && (status === 'completed' || status === 'error' || status === 'running' || isPaused), } }, [normalizedSelection, rows, displayColumns, workflowGroupById]) @@ -3097,16 +3099,6 @@ export function TableGrid({ return (
- {embedded && totalRunning > 0 && ( -
- -
- )} -
)} + {isFetchingNextPage && ( + + +
+ +
+ + + )} ) })() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index d66182b70f4..6a7f53b2185 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -49,7 +49,10 @@ export function checkboxColLayout( ): { colWidth: number; numDivWidth: number } { const digits = maxRows > 0 ? Math.floor(Math.log10(maxRows)) + 1 : 1 const numDivWidth = Math.max(20, digits * 8 + 4) - const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 16 : 0) + // When workflow columns are present a 20px run/stop button sits to the right of + // the number, separated by a 6px gap and a 4px right pad — 30px total. Reserving + // only the button width clipped the number on tables with many (wide) row indices. + const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 30 : 0) return { colWidth, numDivWidth } } @@ -196,6 +199,12 @@ export function resolveCellExec( // cell SSE) cover the actual rows instead. if (d.limit) continue if (!d.scope.groupIds.includes(group.id)) continue + // Auto-fire dispatches (row writes / schema changes) scope every group but + // the dispatcher honors `autoRun: false` per-cell ('autoRun-off'), so those + // cells never actually run — don't optimistically paint them Queued. Manual + // runs (Run all / Run column) bypass autoRun and DO run them, so keep the + // overlay's Queued there. + if (!d.isManualRun && group.autoRun === false) continue if (d.scope.rowIds && !d.scope.rowIds.includes(row.id)) continue if (row.position <= d.cursor) continue return { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 64fa3c1af0d..466835d041b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -462,36 +462,46 @@ export function Table({ return (
{!embedded && ( - <> - 0 ? ( - - ) : null - } - /> - setFilterOpen((prev) => !prev)} - filterActive={filterOpen || !!queryOptions.filter} - /> - {filterOpen && ( - setFilterOpen(false)} + 0 ? ( + + ) : null + } + /> + )} + {/* Sort + filter render in both modes. In embedded (mothership) mode there's + no ResourceHeader, so the run/stop control rides in the options bar's + `extras` slot — keeping the bar populated whether or not a run is live. */} + setFilterOpen((prev) => !prev)} + filterActive={filterOpen || !!queryOptions.filter} + extras={ + embedded && selection.totalRunning > 0 ? ( + - )} - + ) : undefined + } + /> + {filterOpen && ( + setFilterOpen(false)} + /> )} fetchWorkspaceFiles(workspaceId, scope, signal), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), staleTime: 30 * 1000, // 30 seconds - files can change frequently placeholderData: keepPreviousData, // Show cached data immediately }) diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 885df978bb0..441abda9330 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -3,7 +3,7 @@ import { tableRowExecutions, tableRunDispatches, userTableRows } from '@sim/db/s import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, asc, eq, gt, inArray, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, gt, inArray, isNotNull, ne, or, type SQL, sql } from 'drizzle-orm' import { getJobQueue } from '@/lib/core/async-jobs/config' import { writeWorkflowGroupState } from '@/lib/table/cell-write' import { appendTableEvent } from '@/lib/table/events' @@ -185,6 +185,15 @@ export async function insertDispatch(input: { * gutter Run/Stop button. All three statuses are user-cancellable, so the * gutter must surface Stop whenever any of them are present (else clicking * Play during the queued window would re-run an already-queued cell). + * + * Excludes orphan pre-stamps — `pending` rows with no `executionId` — which + * are dead placeholders left when a dispatcher loop wrote the stamp but no + * cell-task ever picked it up (lock contention, queue failure, crash). The + * cell already shows its prior value and `classifyEligibility` treats these as + * claimable, so counting them stuck the "X running" badge above zero forever + * even though nothing was running. Same `executionId == null` test used by + * {@link classifyEligibility} / {@link pickNextEligibleGroupForRow}. + * * Hits the `(table_id, status)` partial index on table_row_executions. */ export async function countRunningCells( tableId: string @@ -198,7 +207,10 @@ export async function countRunningCells( .where( and( eq(tableRowExecutions.tableId, tableId), - inArray(tableRowExecutions.status, ['queued', 'running', 'pending']) + inArray(tableRowExecutions.status, ['queued', 'running', 'pending']), + // Exclude orphan pre-stamps (`pending` + null executionId). De Morgan of + // NOT(pending AND null) — `status` is NOT NULL so `ne` is well-defined. + or(ne(tableRowExecutions.status, 'pending'), isNotNull(tableRowExecutions.executionId)) ) ) .groupBy(tableRowExecutions.rowId)