diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 246a7ddbfe8..c1dfb5a5ed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -377,7 +377,9 @@ export function Table({ /** Select-all Stop — filter-scoped when a filter is active; deselected rows keep running. */ const onStopAllRows = useCallback( (filter?: Filter, excludeRowIds?: string[]) => { - cancelRunsMutate({ scope: 'all', filter, excludeRowIds }) + // `sort` scopes the optimistic flip to the active view's cache (filtered stops + // only cancel matching rows server-side). + cancelRunsMutate({ scope: 'all', filter, sort: queryOptions.sort, excludeRowIds }) captureEvent(posthogRef.current, 'table_workflow_stopped', { table_id: tableId, workspace_id: workspaceId, @@ -385,7 +387,7 @@ export function Table({ row_count: null, }) }, - [cancelRunsMutate, tableId, workspaceId] + [cancelRunsMutate, tableId, workspaceId, queryOptions.sort] ) const onSelectionChange = (next: SelectionSnapshot) => { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index ccb6a07232c..a9796275049 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1252,6 +1252,9 @@ interface CancelRunsParams { rowId?: string /** Scope-`all` only: cancel just the cells on rows matching this filter (filtered select-all Stop). */ filter?: Filter + /** Active sort — with `filter` it identifies the exact rows query whose cells the optimistic + * cancel may flip (other cached views contain rows the server won't touch). */ + sort?: Sort | null /** Scope-`all` only: deselected rows whose cells keep running. */ excludeRowIds?: string[] } @@ -1274,39 +1277,57 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext) body: { workspaceId, scope, rowId, filter, excludeRowIds }, }) }, - onMutate: async ({ scope, rowId, excludeRowIds }) => { + onMutate: async ({ scope, rowId, filter, sort, excludeRowIds }) => { const excludedRowIds = excludeRowIds && excludeRowIds.length > 0 ? new Set(excludeRowIds) : null - const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { - if (scope === 'row' && r.id !== rowId) return null - if (excludedRowIds?.has(r.id)) return null - const executions = (r.executions ?? {}) as RowExecutions - let rowTouched = false - const nextExecutions: RowExecutions = { ...executions } - for (const gid in executions) { - const exec = executions[gid] - if (!isExecInFlight(exec)) continue - if (exec.executionId == null) { - // Optimistic-only or dispatcher-pre-stamp pending — server has not - // claimed the cell yet, so no SSE will arrive to reconcile a - // `cancelled` stamp. Strip the entry instead and let the renderer - // fall through to the cell's prior state (value / empty / etc.). - delete nextExecutions[gid] + // A filtered stop only cancels matching rows server-side — flipping every cached view + // would show rows outside the filter as cancelled until refetch. Scope the optimistic + // flip to the active filtered view; onSettled's invalidation reconciles the rest. + const onlyKey = filter + ? tableKeys.infiniteRows( + tableId, + tableRowsParamsKey({ + pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT, + filter, + sort: sort ?? null, + }) + ) + : undefined + const snapshots = await snapshotAndMutateRows( + queryClient, + tableId, + (r) => { + if (scope === 'row' && r.id !== rowId) return null + if (excludedRowIds?.has(r.id)) return null + const executions = (r.executions ?? {}) as RowExecutions + let rowTouched = false + const nextExecutions: RowExecutions = { ...executions } + for (const gid in executions) { + const exec = executions[gid] + if (!isExecInFlight(exec)) continue + if (exec.executionId == null) { + // Optimistic-only or dispatcher-pre-stamp pending — server has not + // claimed the cell yet, so no SSE will arrive to reconcile a + // `cancelled` stamp. Strip the entry instead and let the renderer + // fall through to the cell's prior state (value / empty / etc.). + delete nextExecutions[gid] + rowTouched = true + continue + } + nextExecutions[gid] = { + status: 'cancelled', + executionId: exec.executionId, + jobId: null, + workflowId: exec.workflowId, + error: 'Cancelled', + ...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}), + } rowTouched = true - continue - } - nextExecutions[gid] = { - status: 'cancelled', - executionId: exec.executionId, - jobId: null, - workflowId: exec.workflowId, - error: 'Cancelled', - ...(exec.blockErrors ? { blockErrors: exec.blockErrors } : {}), } - rowTouched = true - } - return rowTouched ? { ...r, executions: nextExecutions } : null - }) + return rowTouched ? { ...r, executions: nextExecutions } : null + }, + { onlyKey } + ) return { snapshots } }, onError: (_err, _variables, context) => { @@ -1822,14 +1843,20 @@ export async function snapshotAndMutateRows( queryClient: ReturnType, tableId: string, transform: (row: TableRow) => TableRow | null, - options?: { cancelInFlight?: boolean } + options?: { + cancelInFlight?: boolean + /** Restrict the walk to one exact cached query (e.g. the active filtered + * view) when the mutation's server effect doesn't cover other views. */ + onlyKey?: readonly unknown[] + } ): Promise { + const scope = options?.onlyKey + ? ({ queryKey: options.onlyKey, exact: true } as const) + : ({ queryKey: tableKeys.rowsRoot(tableId) } as const) if (options?.cancelInFlight !== false) { - await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + await queryClient.cancelQueries(scope) } - const matching = queryClient.getQueriesData({ - queryKey: tableKeys.rowsRoot(tableId), - }) + const matching = queryClient.getQueriesData(scope) const snapshots: RowsCacheSnapshots = [] for (const [key, data] of matching) { if (!data) continue