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 7201a3f7d2..48cd644480 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 @@ -303,25 +303,28 @@ const TYPEWRITER_MS_PER_CHAR = 15 */ function useTypewriter(text: string | null): string | null { const [revealed, setRevealed] = useState(text) - const isFirstRunRef = useRef(true) const prevTextRef = useRef(text) + const mountedRef = useRef(false) + const animateRef = useRef(false) - useEffect(() => { - if (isFirstRunRef.current) { - isFirstRunRef.current = false - prevTextRef.current = text - setRevealed(text) - return - } - if (prevTextRef.current === text) return + // Reset synchronously during render when `text` changes (not on first mount) + // so no frame ever shows the full new value before the animation begins — + // an effect-based reset lands one frame late and flashes the whole text. + if (prevTextRef.current !== text) { prevTextRef.current = text + const animate = mountedRef.current && text !== null && text.length > 0 + animateRef.current = animate + setRevealed(animate ? '' : text) + } - if (text === null || text.length === 0) { - setRevealed(text) - return - } + useEffect(() => { + mountedRef.current = true + }, []) - const full = text + useEffect(() => { + if (!animateRef.current) return + animateRef.current = false + const full = text as string const start = performance.now() let raf = 0 const tick = (now: number) => { @@ -329,7 +332,6 @@ function useTypewriter(text: string | null): string | null { setRevealed(full.slice(0, chars)) if (chars < full.length) raf = requestAnimationFrame(tick) } - setRevealed('') raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [text]) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index afd799e8a0..ac54495de7 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -73,7 +73,6 @@ import type { } from '@/lib/table' import { areGroupDepsSatisfied, - areOutputsFilled, isExecInFlight, optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' @@ -225,6 +224,43 @@ async function fetchTableRunState(tableId: string, signal?: AbortSignal): Promis } } +/** Count groups flipped to in-flight (`pending`) by an optimistic schedule that + * weren't in-flight before — the delta to add to the run-state counter. */ +function countNewlyInFlight(before: RowExecutions, after: RowExecutions): number { + let n = 0 + for (const gid of Object.keys(after)) { + if (after[gid]?.status === 'pending' && !isExecInFlight(before[gid])) n++ + } + return n +} + +/** Add optimistically-stamped cells to the run-state counter so the "X running" + * badge + per-row gutter Stop reflect them instantly (the optimistic stamp + * eats the dispatcher's `pending` SSE, so `applyCell` never bumps the count). + * Returns the prior snapshot for rollback, or `null` when nothing was bumped. */ +function bumpRunState( + queryClient: ReturnType, + tableId: string, + stampedByRow: Record +): { snapshot: TableRunState | undefined } | null { + const total = Object.values(stampedByRow).reduce((s, n) => s + n, 0) + if (total === 0) return null + const snapshot = queryClient.getQueryData(tableKeys.activeDispatches(tableId)) + queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { + const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } + const nextByRow = { ...base.runningByRowId } + for (const [rid, n] of Object.entries(stampedByRow)) { + nextByRow[rid] = (nextByRow[rid] ?? 0) + n + } + return { + ...base, + runningCellCount: base.runningCellCount + total, + runningByRowId: nextByRow, + } + }) + return { snapshot } +} + /** * Aggregate live state for a table: active dispatches (drives the "about to * run" overlay), the running-cell count (top-right counter), and per-row @@ -453,6 +489,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) .workflowGroups ?? [] const stamped = withOptimisticAutoFireExec(groups, row) reconcileCreatedRow(queryClient, tableId, stamped) + // Bump the run-state counter for any auto-fire groups stamped pending so + // the "X running" badge + gutter Stop show immediately (the row had no + // prior executions, so the stamped set is the full delta). + const stampedCount = countNewlyInFlight({}, stamped.executions ?? {}) + if (stampedCount > 0) bumpRunState(queryClient, tableId, { [row.id]: stampedCount }) }, onError: (error) => { if (isValidationError(error)) return @@ -618,10 +659,14 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] + const stampedByRow: Record = {} patchCachedRows(queryClient, tableId, (row) => { if (row.id !== rowId) return row const patch = data as Partial const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + if (nextExecutions) { + stampedByRow[row.id] = countNewlyInFlight(row.executions ?? {}, nextExecutions) + } return { ...row, data: { ...row.data, ...patch } as RowData, @@ -629,7 +674,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) } }) - return { previousQueries } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { + previousQueries, + runStateSnapshot: bumped?.snapshot, + didBumpRunState: bumped !== null, + } }, onSuccess: (response, { rowId, data: mutatedData }) => { const serverRow = response.data.row @@ -655,6 +705,9 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryClient.setQueryData(queryKey, data) } } + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -694,11 +747,15 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon queryClient.getQueryData(tableKeys.detail(tableId))?.schema .workflowGroups ?? [] + const stampedByRow: Record = {} patchCachedRows(queryClient, tableId, (row) => { const raw = updateMap.get(row.id) if (!raw) return row const patch = raw as Partial const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + if (nextExecutions) { + stampedByRow[row.id] = countNewlyInFlight(row.executions ?? {}, nextExecutions) + } return { ...row, data: { ...row.data, ...patch } as RowData, @@ -706,7 +763,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon } }) - return { previousQueries } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { + previousQueries, + runStateSnapshot: bumped?.snapshot, + didBumpRunState: bumped !== null, + } }, onError: (error, _vars, context) => { if (context?.previousQueries) { @@ -714,6 +776,9 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon queryClient.setQueryData(queryKey, data) } } + if (context?.didBumpRunState) { + queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) + } if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, @@ -1352,12 +1417,10 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { // dispatcher regardless of mode. Stamping pending here would leave // the cell flashing Queued indefinitely (no SSE event will arrive). if (group && !areGroupDepsSatisfied(group, r)) continue - // Mirror server eligibility for `mode: 'incomplete'`: skip cells whose - // outputs are filled, regardless of exec status. A cancelled/error - // cell with a leftover value from a prior run was rendering as filled - // but flipping to "queued" optimistically here even though the server - // would skip it. - if (runMode === 'incomplete' && group && areOutputsFilled(group, r)) continue + // Mirror server eligibility for manual `mode: 'incomplete'`: a + // `completed` group is done (even with a blank output) — only "Run + // all" re-runs it. error/cancelled/never-run cells still re-run. + if (runMode === 'incomplete' && exec?.status === 'completed') continue next[groupId] = buildPendingExec(exec) // Mirror the server-side bulk clear: wipe output values so the cell // doesn't render the stale completed value behind a pending badge. @@ -1376,29 +1439,8 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { return { ...r, data: nextData, executions: next } }) - // Bump the counter to match the stamped cells. Without it the "X running" - // badge + gutter Stop stay at zero until a refetch: the optimistic stamp - // already marks the cell in-flight, so the dispatcher's `pending` SSE - // sees no `wasInFlight` transition and never bumps the counter. - const runStateSnapshot = queryClient.getQueryData( - tableKeys.activeDispatches(tableId) - ) - const totalStamped = Object.values(stampedByRow).reduce((s, n) => s + n, 0) - if (totalStamped > 0) { - queryClient.setQueryData(tableKeys.activeDispatches(tableId), (prev) => { - const base = prev ?? { dispatches: [], runningCellCount: 0, runningByRowId: {} } - const nextByRow = { ...base.runningByRowId } - for (const [rid, n] of Object.entries(stampedByRow)) { - nextByRow[rid] = (nextByRow[rid] ?? 0) + n - } - return { - ...base, - runningCellCount: base.runningCellCount + totalStamped, - runningByRowId: nextByRow, - } - }) - } - return { snapshots, runStateSnapshot, didBumpRunState: totalStamped > 0 } + const bumped = bumpRunState(queryClient, tableId, stampedByRow) + return { snapshots, runStateSnapshot: bumped?.snapshot, didBumpRunState: bumped !== null } }, onError: (_err, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index b0e7511936..eda70c99f5 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -93,7 +93,14 @@ export function classifyEligibility( if (!isManualRun && completedAndFilled) return 'completed-on-auto' if (!isManualRun && status === 'error') return 'error-on-auto' if (!isManualRun && status === 'cancelled') return 'cancelled-on-auto' - if (mode === 'incomplete' && completedAndFilled) return 'completed-on-incomplete' + // Manual incomplete-mode runs (Run row / Run incomplete) treat a `completed` + // group as done even if an output is blank — only "Run all" re-runs it. The + // auto cascade still re-fills blank outputs (completedAndFilled). + if (mode === 'incomplete') { + if (isManualRun ? status === 'completed' : completedAndFilled) { + return 'completed-on-incomplete' + } + } if (isManualRun && group.autoRun === false) return 'manual-bypass' return areGroupDepsSatisfied(group, row) ? 'eligible' : 'deps-unmet'