Skip to content

Commit 374574c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/azure-data-explorer
# Conflicts: # apps/sim/tools/generated/tool-outputs.ts
2 parents 29c8903 + 3051954 commit 374574c

23 files changed

Lines changed: 504 additions & 44 deletions

File tree

apps/docs/content/docs/en/tables/using-in-workflows.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ After the run, the table holds the enriched rows. The next run queries them agai
125125

126126
**Iterate row by row.** Wrap a Query → process → update cycle in a [Loop block](/workflows/blocks/loop) to handle one row at a time. This runs sequentially, slower than a batch update but useful when each row needs its own multi-step logic. Inside the loop the Agent reads the current row and an Update Row by ID writes its result.
127127

128-
**Paginate large reads.** Query Rows returns at most 1000 rows. When `totalCount` exceeds your **Limit**, increase **Offset** on each pass (0, then 100, then 200) to walk through the whole table, typically inside a Loop.
128+
**Paginate large reads.** Query Rows returns at most 1000 rows, and a page can also end early once its rows reach the response size budget — so a page may come back shorter than your **Limit** even when more rows match. Advance **Offset** by the `rowCount` you actually received, not by the Limit you asked for, and keep going while `nextCursor` is set. Stop when `nextCursor` is null. Stepping by the Limit instead skips whatever a short page left behind.
129129

130130
## Inspecting reads and writes
131131

apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { userTableRows } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import { readClientId } from '@/lib/api/client-id'
67
import {
78
deleteTableRowContract,
89
getTableQuerySchema,
@@ -14,7 +15,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1415
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1516
import type { RowData, TableSchema } from '@/lib/table'
1617
import { updateRow } from '@/lib/table'
17-
import { signalTableRowsChanged } from '@/lib/table/events'
18+
import { signalTableRowsChangedByActor } from '@/lib/table/events'
1819
import { performDeleteTableRow } from '@/lib/table/orchestration'
1920
import {
2021
createTableRowsResponse,
@@ -172,7 +173,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
172173
)
173174

174175
// Live-collab: tell open viewers the change landed so they refetch.
175-
signalTableRowsChanged(tableId)
176+
signalTableRowsChangedByActor(tableId, readClientId(request))
176177
// Only `null` when a `cancellationGuard` is supplied and the SQL guard
177178
// rejects the write — this route doesn't pass one, so reaching null is a bug.
178179
if (!updatedRow) throw new Error('updateRow returned null without a cancellationGuard')
@@ -251,7 +252,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
251252
}
252253

253254
// Live-collab: tell open viewers the change landed so they refetch.
254-
signalTableRowsChanged(tableId)
255+
signalTableRowsChangedByActor(tableId, readClientId(request))
255256

256257
return NextResponse.json({
257258
success: true,

apps/sim/app/api/table/[tableId]/rows/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { readClientId } from '@/lib/api/client-id'
34
import {
45
type BatchInsertTableRowsBodyInput,
56
batchUpdateTableRowsBodySchema,
@@ -26,7 +27,7 @@ import {
2627
validateRowSize,
2728
} from '@/lib/table'
2829
import { TableQueryValidationError } from '@/lib/table/errors'
29-
import { signalTableRowsChanged } from '@/lib/table/events'
30+
import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events'
3031
import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters'
3132
import {
3233
validatePredicateShape,
@@ -254,7 +255,9 @@ export const POST = withRouteHandler(
254255
table,
255256
requestId
256257
)
257-
signalTableRowsChanged(tableId)
258+
// Attributed unlike the batch path above: the acting tab's insert deliberately avoids
259+
// invalidating the rows root to prevent flicker, which an unattributed echo would undo.
260+
signalTableRowsChangedByActor(tableId, readClientId(request))
258261

259262
const responseBody = {
260263
success: true,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { toast } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { backoffWithJitter } from '@sim/utils/retry'
77
import { useQueryClient } from '@tanstack/react-query'
8+
import { getClientFingerprint } from '@/lib/api/client-id'
89
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
910
import type {
1011
RowData,
@@ -245,6 +246,30 @@ export function useTableEventStream({
245246
}, ROWS_INVALIDATE_DEBOUNCE_MS)
246247
}
247248

249+
/**
250+
* This tab's fingerprint as it appears on a broadcast it caused. Resolved once, asynchronously;
251+
* until it lands `applyEdit` simply takes the refetch path, which is the pre-existing behavior.
252+
*/
253+
let ownFingerprint: string | undefined
254+
void getClientFingerprint().then((fingerprint) => {
255+
ownFingerprint = fingerprint
256+
})
257+
258+
/**
259+
* A manual row edit landed. Refetch the rows so the winning last-write value shows live —
260+
* unless this tab is the one that made it.
261+
*
262+
* The signal names its originator only for writes whose mutation hook already applies the
263+
* server's answer to every cached rows query, active or not (single-row create, update,
264+
* delete). For those the refetch is pure duplication: on a scrolled table it re-fetches every
265+
* loaded page, and on delete it races the refetch the hook itself issued. Other tabs see
266+
* someone else's fingerprint and refetch normally; an unattributed edit refetches everywhere.
267+
*/
268+
const applyEdit = (event: Extract<TableEvent, { kind: 'edit' }>): void => {
269+
if (event.originatorId && event.originatorId === ownFingerprint) return
270+
scheduleRowsInvalidate()
271+
}
272+
248273
const applyCell = (event: Extract<TableEvent, { kind: 'cell' }>): void => {
249274
void snapshotAndMutateRows(queryClient, tableId, (row) => applyCellEventToRow(row, event), {
250275
cancelInFlight: false,
@@ -445,9 +470,7 @@ export function useTableEventStream({
445470
else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event)
446471
else if (entry.event?.kind === 'job') applyJob(entry.event)
447472
else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event)
448-
// A collaborator's manual edit: refetch rows (debounced) so the winning
449-
// last-write value shows live, in this client's own wire format.
450-
else if (entry.event?.kind === 'edit') scheduleRowsInvalidate()
473+
else if (entry.event?.kind === 'edit') applyEdit(entry.event)
451474
// A collaborator changed the table structure: mirror the local
452475
// invalidateTableSchema set — the definition (exact, so rows stay on the
453476
// debounce), the run-state + enrichment sibling queries under detail (a group

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,32 @@
44
import { describe, expect, it } from 'vitest'
55
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
66
import {
7+
isAgentToolBlock,
78
isCustomToolAlreadySelected,
89
isMcpToolAlreadySelected,
910
isWorkflowAlreadySelected,
1011
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
1112

13+
describe('isAgentToolBlock', () => {
14+
it('includes the current File block', () => {
15+
expect(isAgentToolBlock({ type: 'file_v5', category: 'blocks', hideFromToolbar: false })).toBe(
16+
true
17+
)
18+
})
19+
20+
it('excludes hidden blocks such as the legacy File block', () => {
21+
expect(isAgentToolBlock({ type: 'file', category: 'blocks', hideFromToolbar: true })).toBe(
22+
false
23+
)
24+
})
25+
26+
it('does not make every visible core block agent-callable', () => {
27+
expect(isAgentToolBlock({ type: 'memory', category: 'blocks', hideFromToolbar: false })).toBe(
28+
false
29+
)
30+
})
31+
})
32+
1233
describe('isMcpToolAlreadySelected', () => {
1334
describe('basic functionality', () => {
1435
it.concurrent('returns false when selectedTools is empty', () => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowI
4646
import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents'
4747
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
4848
import {
49+
isAgentToolBlock,
4950
isCustomToolAlreadySelected,
5051
isMcpToolAlreadySelected,
5152
isWorkflowAlreadySelected,
@@ -665,21 +666,7 @@ export const ToolInput = memo(function ToolInput({
665666

666667
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
667668
const toolBlocks = useMemo(() => {
668-
const allToolBlocks = getAllBlocks().filter(
669-
(block) =>
670-
!block.hideFromToolbar &&
671-
(block.category === 'tools' ||
672-
block.type === 'api' ||
673-
block.type === 'webhook_request' ||
674-
block.type === 'workflow' ||
675-
block.type === 'workflow_input' ||
676-
block.type === 'knowledge' ||
677-
block.type === 'function' ||
678-
block.type === 'table') &&
679-
block.type !== 'evaluator' &&
680-
block.type !== 'mcp' &&
681-
block.type !== 'file'
682-
)
669+
const allToolBlocks = getAllBlocks().filter(isAgentToolBlock)
683670
return filterBlocks(allToolBlocks)
684671
}, [filterBlocks, customBlockOverlayVersion])
685672

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
2+
import type { BlockConfig } from '@/blocks/types'
3+
4+
const CORE_AGENT_TOOL_TYPES = new Set([
5+
'api',
6+
'webhook_request',
7+
'workflow',
8+
'workflow_input',
9+
'knowledge',
10+
'function',
11+
'table',
12+
'file_v5',
13+
])
14+
15+
/**
16+
* Checks whether a registered block should appear in the agent tool picker.
17+
*/
18+
export function isAgentToolBlock(
19+
block: Pick<BlockConfig, 'category' | 'hideFromToolbar' | 'type'>
20+
): boolean {
21+
return (
22+
!block.hideFromToolbar && (block.category === 'tools' || CORE_AGENT_TOOL_TYPES.has(block.type))
23+
)
24+
}
225

326
/**
427
* Checks if an MCP tool is already selected.

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,74 @@ describe('SearchModal', () => {
364364
}
365365
})
366366

367+
it('keeps a block above its same-name trigger for the exact-name query', async () => {
368+
const Icon = () => null
369+
const original = { ...mockSearchState.data }
370+
mockSearchState.data = {
371+
...mockSearchState.data,
372+
tools: [
373+
{
374+
id: 'gmail',
375+
name: 'Gmail',
376+
icon: Icon,
377+
bgColor: '#E8453C',
378+
type: 'gmail',
379+
searchValue: 'gmail gmail',
380+
},
381+
],
382+
triggers: [{ id: 'gmail', name: 'Gmail', icon: Icon, bgColor: '#E8453C', type: 'gmail' }],
383+
}
384+
385+
try {
386+
await act(async () => {
387+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
388+
})
389+
390+
await enterSearchQuery('gmail')
391+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
392+
(el) => el.textContent ?? ''
393+
)
394+
expect(rows[0]).toContain('Gmail')
395+
expect(rows[0]).not.toContain('Gmail Trigger')
396+
expect(rows[1]).toContain('Gmail Trigger')
397+
} finally {
398+
mockSearchState.data = original
399+
}
400+
})
401+
402+
it('ranks prefix-matched rows above actions that only contain the letter mid-word', async () => {
403+
const Icon = () => null
404+
const original = { ...mockSearchState.data }
405+
mockSearchState.data = {
406+
...mockSearchState.data,
407+
tools: [
408+
{
409+
id: 'hex',
410+
name: 'Hex',
411+
icon: Icon,
412+
bgColor: '#111',
413+
type: 'hex',
414+
searchValue: 'hex hex',
415+
},
416+
],
417+
}
418+
419+
try {
420+
await act(async () => {
421+
root.render(<SearchModal open onOpenChange={vi.fn()} pageContext='workflow' />)
422+
})
423+
424+
await enterSearchQuery('h')
425+
const rows = Array.from(document.querySelectorAll<HTMLElement>('[cmdk-item]')).map(
426+
(el) => el.textContent ?? ''
427+
)
428+
expect(rows[0]).toContain('Hex')
429+
expect(rows.findIndex((row) => row.includes('New chat'))).toBeGreaterThan(0)
430+
} finally {
431+
mockSearchState.data = original
432+
}
433+
})
434+
367435
it('puts the workflow verb actions first for their bare-verb queries', async () => {
368436
const Icon = () => null
369437
const original = { ...mockSearchState.data }

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,9 @@ function SearchModalContent({
10541054
...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []),
10551055
...rankActionGroup(actionsByGroup.sim, 'Sim'),
10561056
]
1057+
const blockNames = new Set(
1058+
[...availableBlocks, ...availableTools].map((item) => item.name.toLowerCase())
1059+
)
10571060

10581061
return {
10591062
actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })),
@@ -1072,8 +1075,14 @@ function SearchModalContent({
10721075
section: 'triggers',
10731076
item,
10741077
/* The display rename ("Start" → "Start Trigger") costs the exact-name
1075-
bonus, so a query that IS the trigger's name ranks it like a page row. */
1076-
score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score,
1078+
bonus, so a query that IS the trigger's name ranks it like a page row
1079+
— unless a block shares that name (Gmail, Slack). Then the query names
1080+
the block first, and the lift would leapfrog its exact-name match. */
1081+
score:
1082+
item.baseName.toLowerCase() === query.toLowerCase() &&
1083+
!blockNames.has(item.baseName.toLowerCase())
1084+
? PAGE_MATCH_TIER
1085+
: score,
10771086
})),
10781087
tools: rank(
10791088
'tools',

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ describe('getGlobalSearchResults', () => {
100100
).toEqual(['new-chat-action', 'new-chat-result'])
101101
})
102102

103+
it('keeps a mid-word-matched action below word-start entity matches', () => {
104+
const action = {
105+
id: 'create-folder',
106+
name: 'Create folder',
107+
icon: () => null,
108+
context: 'global' as const,
109+
run: () => {},
110+
}
111+
const [actionMatch] = scoreActions([action], 'a')
112+
const [blockMatch] = scoreAndSort([{ name: 'Airtable' }], (item) => item.name, 'a')
113+
114+
expect(actionMatch.score).toBeLessThan(blockMatch.score)
115+
})
116+
117+
it('still biases a word-start action match above entity name matches', () => {
118+
const action = {
119+
id: 'create-workflow',
120+
name: 'Create workflow',
121+
icon: () => null,
122+
context: 'global' as const,
123+
run: () => {},
124+
}
125+
const [actionMatch] = scoreActions([action], 'w')
126+
const [blockMatch] = scoreAndSort([{ name: 'Webhook' }], (item) => item.name, 'w')
127+
128+
expect(actionMatch.score).toBeGreaterThan(blockMatch.score)
129+
})
130+
103131
it('breaks identical visible-name matches by the original section order', () => {
104132
const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' }
105133
const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' }

0 commit comments

Comments
 (0)