Compare commits

..

29 Commits

Author SHA1 Message Date
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
414 changed files with 7501 additions and 29605 deletions

View File

@@ -86,112 +86,27 @@ export async function GET(request: NextRequest) {
)
.limit(candidateLimit)
const knownLocales = ['en', 'es', 'fr', 'de', 'ja', 'zh']
const seenIds = new Set<string>()
const mergedResults = []
const vectorRankMap = new Map<string, number>()
vectorResults.forEach((r, idx) => vectorRankMap.set(r.chunkId, idx + 1))
const keywordRankMap = new Map<string, number>()
keywordResults.forEach((r, idx) => keywordRankMap.set(r.chunkId, idx + 1))
const allChunkIds = new Set([
...vectorResults.map((r) => r.chunkId),
...keywordResults.map((r) => r.chunkId),
])
const k = 60
type ResultWithRRF = (typeof vectorResults)[0] & { rrfScore: number }
const scoredResults: ResultWithRRF[] = []
for (const chunkId of allChunkIds) {
const vectorRank = vectorRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const keywordRank = keywordRankMap.get(chunkId) ?? Number.POSITIVE_INFINITY
const rrfScore = 1 / (k + vectorRank) + 1 / (k + keywordRank)
const result =
vectorResults.find((r) => r.chunkId === chunkId) ||
keywordResults.find((r) => r.chunkId === chunkId)
if (result) {
scoredResults.push({ ...result, rrfScore })
for (let i = 0; i < Math.max(vectorResults.length, keywordResults.length); i++) {
if (i < vectorResults.length && !seenIds.has(vectorResults[i].chunkId)) {
mergedResults.push(vectorResults[i])
seenIds.add(vectorResults[i].chunkId)
}
if (i < keywordResults.length && !seenIds.has(keywordResults[i].chunkId)) {
mergedResults.push(keywordResults[i])
seenIds.add(keywordResults[i].chunkId)
}
}
scoredResults.sort((a, b) => b.rrfScore - a.rrfScore)
const localeFilteredResults = scoredResults.filter((result) => {
const firstPart = result.sourceDocument.split('/')[0]
if (knownLocales.includes(firstPart)) {
return firstPart === locale
}
return locale === 'en'
})
const queryLower = query.toLowerCase()
const getTitleBoost = (result: ResultWithRRF): number => {
const fileName = result.sourceDocument
.replace('.mdx', '')
.split('/')
.pop()
?.toLowerCase()
?.replace(/_/g, ' ')
if (fileName === queryLower) return 0.01
if (fileName?.includes(queryLower)) return 0.005
return 0
}
localeFilteredResults.sort((a, b) => {
return b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a))
})
const pageMap = new Map<string, ResultWithRRF>()
for (const result of localeFilteredResults) {
const pageKey = result.sourceDocument
const existing = pageMap.get(pageKey)
if (!existing || result.rrfScore > existing.rrfScore) {
pageMap.set(pageKey, result)
}
}
const deduplicatedResults = Array.from(pageMap.values())
.sort((a, b) => b.rrfScore + getTitleBoost(b) - (a.rrfScore + getTitleBoost(a)))
.slice(0, limit)
const searchResults = deduplicatedResults.map((result) => {
const filteredResults = mergedResults.slice(0, limit)
const searchResults = filteredResults.map((result) => {
const title = result.headerText || result.sourceDocument.replace('.mdx', '')
const pathParts = result.sourceDocument
.replace('.mdx', '')
.split('/')
.filter((part) => part !== 'index' && !knownLocales.includes(part))
.map((part) => {
return part
.replace(/_/g, ' ')
.split(' ')
.map((word) => {
const acronyms = [
'api',
'mcp',
'sdk',
'url',
'http',
'json',
'xml',
'html',
'css',
'ai',
]
if (acronyms.includes(word.toLowerCase())) {
return word.toUpperCase()
}
return word.charAt(0).toUpperCase() + word.slice(1)
})
.join(' ')
})
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
return {
id: result.chunkId,

View File

@@ -1739,12 +1739,12 @@ export function BrowserUseIcon(props: SVGProps<SVGSVGElement>) {
{...props}
version='1.0'
xmlns='http://www.w3.org/2000/svg'
width='28'
height='28'
width='150pt'
height='150pt'
viewBox='0 0 150 150'
preserveAspectRatio='xMidYMid meet'
>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='currentColor' stroke='none'>
<g transform='translate(0,150) scale(0.05,-0.05)' fill='#000000' stroke='none'>
<path
d='M786 2713 c-184 -61 -353 -217 -439 -405 -76 -165 -65 -539 19 -666
l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111

View File

@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="browser_use"
color="#181C1E"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}

File diff suppressed because it is too large Load Diff

View File

@@ -119,145 +119,6 @@ Get a specific event from Google Calendar. Returns API-aligned fields only.
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_update`
Update an existing event in Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to update |
| `summary` | string | No | New event title/summary |
| `description` | string | No | New event description |
| `location` | string | No | New event location |
| `startDateTime` | string | No | New start date and time. MUST include timezone offset \(e.g., 2025-06-03T10:00:00-08:00\) OR provide timeZone parameter |
| `endDateTime` | string | No | New end date and time. MUST include timezone offset \(e.g., 2025-06-03T11:00:00-08:00\) OR provide timeZone parameter |
| `timeZone` | string | No | Time zone \(e.g., America/Los_Angeles\). Required if datetime does not include offset. |
| `attendees` | array | No | Array of attendee email addresses \(replaces existing attendees\) |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Event ID |
| `htmlLink` | string | Event link |
| `status` | string | Event status |
| `summary` | string | Event title |
| `description` | string | Event description |
| `location` | string | Event location |
| `start` | json | Event start |
| `end` | json | Event end |
| `attendees` | json | Event attendees |
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_delete`
Delete an event from Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to delete |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | Deleted event ID |
| `deleted` | boolean | Whether deletion was successful |
### `google_calendar_move`
Move an event to a different calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Source calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Event ID to move |
| `destinationCalendarId` | string | Yes | Destination calendar ID |
| `sendUpdates` | string | No | How to send updates to attendees: all, externalOnly, or none |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Event ID |
| `htmlLink` | string | Event link |
| `status` | string | Event status |
| `summary` | string | Event title |
| `description` | string | Event description |
| `location` | string | Event location |
| `start` | json | Event start |
| `end` | json | Event end |
| `attendees` | json | Event attendees |
| `creator` | json | Event creator |
| `organizer` | json | Event organizer |
### `google_calendar_instances`
Get instances of a recurring event from Google Calendar. Returns API-aligned fields only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `calendarId` | string | No | Calendar ID \(defaults to primary\) |
| `eventId` | string | Yes | Recurring event ID to get instances of |
| `timeMin` | string | No | Lower bound for instances \(RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z\) |
| `timeMax` | string | No | Upper bound for instances \(RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z\) |
| `maxResults` | number | No | Maximum number of instances to return \(default 250, max 2500\) |
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
| `showDeleted` | boolean | No | Include deleted instances |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `nextPageToken` | string | Next page token |
| `timeZone` | string | Calendar time zone |
| `instances` | json | List of recurring event instances |
### `google_calendar_list_calendars`
List all calendars in the user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `minAccessRole` | string | No | Minimum access role for returned calendars: freeBusyReader, reader, writer, or owner |
| `maxResults` | number | No | Maximum number of calendars to return \(default 100, max 250\) |
| `pageToken` | string | No | Token for retrieving subsequent pages of results |
| `showDeleted` | boolean | No | Include deleted calendars |
| `showHidden` | boolean | No | Include hidden calendars |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `nextPageToken` | string | Next page token |
| `calendars` | array | List of calendars |
| ↳ `id` | string | Calendar ID |
| ↳ `summary` | string | Calendar title |
| ↳ `description` | string | Calendar description |
| ↳ `location` | string | Calendar location |
| ↳ `timeZone` | string | Calendar time zone |
| ↳ `accessRole` | string | Access role for the calendar |
| ↳ `backgroundColor` | string | Calendar background color |
| ↳ `foregroundColor` | string | Calendar foreground color |
| ↳ `primary` | boolean | Whether this is the primary calendar |
| ↳ `hidden` | boolean | Whether the calendar is hidden |
| ↳ `selected` | boolean | Whether the calendar is selected |
### `google_calendar_quick_add`
Create events from natural language text. Returns API-aligned fields only.

View File

@@ -1,6 +1,6 @@
---
title: Google Drive
description: Manage files, folders, and permissions
description: Create, upload, and list files
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -40,179 +40,12 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
## Usage Instructions
Integrate Google Drive into the workflow. Can create, upload, download, copy, move, delete, share files and manage permissions.
Integrate Google Drive into the workflow. Can create, upload, and list files.
## Tools
### `google_drive_list`
List files and folders in Google Drive with complete metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderSelector` | string | No | Select the folder to list files from |
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | array | Array of file metadata objects from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
| ↳ `fileExtension` | string | File extension |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the file |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `exportLinks` | json | Export format links |
| ↳ `size` | string | File size in bytes |
| ↳ `quotaBytesUsed` | string | Storage quota used |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `sha1Checksum` | string | SHA-1 hash |
| ↳ `sha256Checksum` | string | SHA-256 hash |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing file |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `version` | string | Version number |
| ↳ `headRevisionId` | string | Head revision ID |
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
| ↳ `thumbnailVersion` | string | Thumbnail version |
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
| `nextPageToken` | string | Token for fetching the next page of results |
### `google_drive_get_file`
Get metadata for a specific file in Google Drive by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `size` | string | File size in bytes |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `version` | string | Version number |
### `google_drive_create_folder`
Create a new folder in Google Drive with complete metadata returned
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of the folder to create |
| `folderSelector` | string | No | Select the parent folder to create the folder in |
| `folderId` | string | No | ID of the parent folder \(internal use\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | object | Complete created folder metadata from Google Drive |
| ↳ `id` | string | Google Drive folder ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | Folder name |
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
| ↳ `description` | string | Folder description |
| ↳ `owners` | json | List of folder owners |
| ↳ `permissions` | json | Folder permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether folder is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the folder |
| ↳ `starred` | boolean | Whether folder is starred |
| ↳ `trashed` | boolean | Whether folder is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `folderColorRgb` | string | Folder color |
| ↳ `createdTime` | string | Folder creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the folder |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `iconLink` | string | URL to folder icon |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing folder |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on folder |
| ↳ `version` | string | Version number |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_upload`
Upload a file to Google Drive with complete metadata returned
@@ -234,9 +67,9 @@ Upload a file to Google Drive with complete metadata returned
| --------- | ---- | ----------- |
| `file` | object | Complete uploaded file metadata from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
@@ -286,6 +119,61 @@ Upload a file to Google Drive with complete metadata returned
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_create_folder`
Create a new folder in Google Drive with complete metadata returned
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of the folder to create |
| `folderSelector` | string | No | Select the parent folder to create the folder in |
| `folderId` | string | No | ID of the parent folder \(internal use\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | object | Complete created folder metadata from Google Drive |
| ↳ `id` | string | Google Drive folder ID |
| ↳ `name` | string | Folder name |
| ↳ `mimeType` | string | MIME type \(application/vnd.google-apps.folder\) |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | Folder description |
| ↳ `owners` | json | List of folder owners |
| ↳ `permissions` | json | Folder permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether folder is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the folder |
| ↳ `starred` | boolean | Whether folder is starred |
| ↳ `trashed` | boolean | Whether folder is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `folderColorRgb` | string | Folder color |
| ↳ `createdTime` | string | Folder creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the folder |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `iconLink` | string | URL to folder icon |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing folder |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on folder |
| ↳ `version` | string | Version number |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
### `google_drive_download`
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
@@ -310,9 +198,9 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
| ↳ `size` | number | File size in bytes |
| `metadata` | object | Complete file metadata from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
@@ -363,211 +251,77 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
| ↳ `linkShareMetadata` | json | Link share metadata |
| ↳ `revisions` | json | File revision history \(first 100 revisions only\) |
### `google_drive_copy`
### `google_drive_list`
Create a copy of a file in Google Drive
List files and folders in Google Drive with complete metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to copy |
| `newName` | string | No | Name for the copied file \(defaults to "Copy of \[original name\]"\) |
| `destinationFolderId` | string | No | ID of the folder to place the copy in \(defaults to same location as original\) |
| `folderSelector` | string | No | Select the folder to list files from |
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The copied file metadata |
| ↳ `id` | string | Google Drive file ID of the copy |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `owners` | json | List of file owners |
| ↳ `size` | string | File size in bytes |
### `google_drive_update`
Update file metadata in Google Drive (rename, move, star, add description)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to update |
| `name` | string | No | New name for the file |
| `description` | string | No | New description for the file |
| `addParents` | string | No | Comma-separated list of parent folder IDs to add \(moves file to these folders\) |
| `removeParents` | string | No | Comma-separated list of parent folder IDs to remove |
| `starred` | boolean | No | Whether to star or unstar the file |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The updated file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `description` | string | File description |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `modifiedTime` | string | Last modification time |
### `google_drive_trash`
Move a file to the trash in Google Drive (can be restored later)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to move to trash |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | The trashed file metadata |
| ↳ `id` | string | Google Drive file ID |
| ↳ `kind` | string | Resource type identifier |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `trashed` | boolean | Whether file is in trash \(should be true\) |
| ↳ `trashedTime` | string | When file was trashed |
| ↳ `webViewLink` | string | URL to view in browser |
### `google_drive_delete`
Permanently delete a file from Google Drive (bypasses trash)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to permanently delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the file was successfully deleted |
| `fileId` | string | The ID of the deleted file |
### `google_drive_share`
Share a file with a user, group, domain, or make it public
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to share |
| `type` | string | Yes | Type of grantee: user, group, domain, or anyone |
| `role` | string | Yes | Permission role: owner \(transfer ownership\), organizer \(shared drive only\), fileOrganizer \(shared drive only\), writer \(edit\), commenter \(view and comment\), reader \(view only\) |
| `email` | string | No | Email address of the user or group \(required for type=user or type=group\) |
| `domain` | string | No | Domain to share with \(required for type=domain\) |
| `transferOwnership` | boolean | No | Required when role is owner. Transfers ownership to the specified user. |
| `moveToNewOwnersRoot` | boolean | No | When transferring ownership, move the file to the new owner's My Drive root folder. |
| `sendNotification` | boolean | No | Whether to send an email notification \(default: true\) |
| `emailMessage` | string | No | Custom message to include in the notification email |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `permission` | json | The created permission details |
| ↳ `id` | string | Permission ID |
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
| ↳ `role` | string | Permission role |
| ↳ `emailAddress` | string | Email of the grantee |
| ↳ `displayName` | string | Display name of the grantee |
| ↳ `domain` | string | Domain of the grantee |
| ↳ `expirationTime` | string | Expiration time |
| ↳ `deleted` | boolean | Whether grantee is deleted |
### `google_drive_unshare`
Remove a permission from a file (revoke access)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to modify permissions on |
| `permissionId` | string | Yes | The ID of the permission to remove \(use list_permissions to find this\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `removed` | boolean | Whether the permission was successfully removed |
| `fileId` | string | The ID of the file |
| `permissionId` | string | The ID of the removed permission |
### `google_drive_list_permissions`
List all permissions (who has access) for a file in Google Drive
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to list permissions for |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `permissions` | array | List of permissions on the file |
| ↳ `id` | string | Permission ID \(use to remove permission\) |
| ↳ `type` | string | Grantee type \(user, group, domain, anyone\) |
| ↳ `role` | string | Permission role \(owner, organizer, fileOrganizer, writer, commenter, reader\) |
| ↳ `emailAddress` | string | Email of the grantee |
| ↳ `displayName` | string | Display name of the grantee |
| ↳ `photoLink` | string | Photo URL of the grantee |
| ↳ `domain` | string | Domain of the grantee |
| ↳ `expirationTime` | string | When permission expires |
| ↳ `deleted` | boolean | Whether grantee account is deleted |
| ↳ `allowFileDiscovery` | boolean | Whether file is discoverable by grantee |
| ↳ `pendingOwner` | boolean | Whether ownership transfer is pending |
| ↳ `permissionDetails` | json | Details about inherited permissions |
| `nextPageToken` | string | Token for fetching the next page of permissions |
### `google_drive_get_about`
Get information about the user and their Google Drive (storage quota, capabilities)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `user` | json | Information about the authenticated user |
| ↳ `displayName` | string | User display name |
| ↳ `emailAddress` | string | User email address |
| ↳ `photoLink` | string | URL to user profile photo |
| ↳ `permissionId` | string | User permission ID |
| ↳ `me` | boolean | Whether this is the authenticated user |
| `storageQuota` | json | Storage quota information in bytes |
| ↳ `limit` | string | Total storage limit in bytes \(null for unlimited\) |
| ↳ `usage` | string | Total storage used in bytes |
| ↳ `usageInDrive` | string | Storage used by Drive files in bytes |
| ↳ `usageInDriveTrash` | string | Storage used by trashed files in bytes |
| `canCreateDrives` | boolean | Whether user can create shared drives |
| `importFormats` | json | Map of MIME types that can be imported and their target formats |
| `exportFormats` | json | Map of Google Workspace MIME types and their exportable formats |
| `maxUploadSize` | string | Maximum upload size in bytes |
| `files` | array | Array of file metadata objects from Google Drive |
| ↳ `id` | string | Google Drive file ID |
| ↳ `name` | string | File name |
| ↳ `mimeType` | string | MIME type |
| ↳ `kind` | string | Resource type identifier |
| ↳ `description` | string | File description |
| ↳ `originalFilename` | string | Original uploaded filename |
| ↳ `fullFileExtension` | string | Full file extension |
| ↳ `fileExtension` | string | File extension |
| ↳ `owners` | json | List of file owners |
| ↳ `permissions` | json | File permissions |
| ↳ `permissionIds` | json | Permission IDs |
| ↳ `shared` | boolean | Whether file is shared |
| ↳ `ownedByMe` | boolean | Whether owned by current user |
| ↳ `writersCanShare` | boolean | Whether writers can share |
| ↳ `viewersCanCopyContent` | boolean | Whether viewers can copy |
| ↳ `copyRequiresWriterPermission` | boolean | Whether copy requires writer permission |
| ↳ `sharingUser` | json | User who shared the file |
| ↳ `starred` | boolean | Whether file is starred |
| ↳ `trashed` | boolean | Whether file is in trash |
| ↳ `explicitlyTrashed` | boolean | Whether explicitly trashed |
| ↳ `appProperties` | json | App-specific properties |
| ↳ `createdTime` | string | File creation time |
| ↳ `modifiedTime` | string | Last modification time |
| ↳ `modifiedByMeTime` | string | When modified by current user |
| ↳ `viewedByMeTime` | string | When last viewed by current user |
| ↳ `sharedWithMeTime` | string | When shared with current user |
| ↳ `lastModifyingUser` | json | User who last modified the file |
| ↳ `viewedByMe` | boolean | Whether viewed by current user |
| ↳ `modifiedByMe` | boolean | Whether modified by current user |
| ↳ `webViewLink` | string | URL to view in browser |
| ↳ `webContentLink` | string | Direct download URL |
| ↳ `iconLink` | string | URL to file icon |
| ↳ `thumbnailLink` | string | URL to thumbnail |
| ↳ `exportLinks` | json | Export format links |
| ↳ `size` | string | File size in bytes |
| ↳ `quotaBytesUsed` | string | Storage quota used |
| ↳ `md5Checksum` | string | MD5 hash |
| ↳ `sha1Checksum` | string | SHA-1 hash |
| ↳ `sha256Checksum` | string | SHA-256 hash |
| ↳ `parents` | json | Parent folder IDs |
| ↳ `spaces` | json | Spaces containing file |
| ↳ `driveId` | string | Shared drive ID |
| ↳ `capabilities` | json | User capabilities on file |
| ↳ `version` | string | Version number |
| ↳ `headRevisionId` | string | Head revision ID |
| ↳ `hasThumbnail` | boolean | Whether has thumbnail |
| ↳ `thumbnailVersion` | string | Thumbnail version |
| ↳ `imageMediaMetadata` | json | Image-specific metadata |
| ↳ `videoMediaMetadata` | json | Video-specific metadata |
| ↳ `isAppAuthorized` | boolean | Whether created by requesting app |
| ↳ `contentRestrictions` | json | Content restrictions |
| ↳ `linkShareMetadata` | json | Link share metadata |
| `nextPageToken` | string | Token for fetching the next page of results |

View File

@@ -1,6 +1,6 @@
---
title: Google Forms
description: Manage Google Forms and responses
description: Read responses from a Google Form
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -29,7 +29,7 @@ In Sim, the Google Forms integration enables your agents to programmatically acc
## Usage Instructions
Integrate Google Forms into your workflow. Read form structure, get responses, create forms, update content, and manage notification watches.
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
@@ -37,246 +37,15 @@ Integrate Google Forms into your workflow. Read form structure, get responses, c
### `google_forms_get_responses`
Retrieve a single response or list responses from a Google Form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `responseId` | string | No | If provided, returns this specific response |
| `pageSize` | number | No | Maximum number of responses to return \(service may return fewer\). Defaults to 5000. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `responses` | array | Array of form responses \(when no responseId provided\) |
| ↳ `responseId` | string | Unique response ID |
| ↳ `createTime` | string | When the response was created |
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
| ↳ `answers` | json | Map of question IDs to answer values |
| `response` | object | Single form response \(when responseId is provided\) |
| ↳ `responseId` | string | Unique response ID |
| ↳ `createTime` | string | When the response was created |
| ↳ `lastSubmittedTime` | string | When the response was last submitted |
| ↳ `answers` | json | Map of question IDs to answer values |
| `raw` | json | Raw API response data |
### `google_forms_get_form`
Retrieve a form structure including its items, settings, and metadata
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The form ID |
| `title` | string | The form title visible to responders |
| `description` | string | The form description |
| `documentTitle` | string | The document title visible in Drive |
| `responderUri` | string | The URI to share with responders |
| `linkedSheetId` | string | The ID of the linked Google Sheet |
| `revisionId` | string | The revision ID of the form |
| `items` | array | The form items \(questions, sections, etc.\) |
| ↳ `itemId` | string | Item ID |
| ↳ `title` | string | Item title |
| ↳ `description` | string | Item description |
| `settings` | json | Form settings |
| `publishSettings` | json | Form publish settings |
### `google_forms_create_form`
Create a new Google Form with a title
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | string | Yes | The title of the form visible to responders |
| `documentTitle` | string | No | The document title visible in Drive \(defaults to form title\) |
| `unpublished` | boolean | No | If true, create an unpublished form that does not accept responses |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The ID of the created form |
| `title` | string | The form title |
| `documentTitle` | string | The document title in Drive |
| `responderUri` | string | The URI to share with responders |
| `revisionId` | string | The revision ID of the form |
### `google_forms_batch_update`
Apply multiple updates to a form (add items, update info, change settings, etc.)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to update |
| `requests` | json | Yes | Array of update requests \(updateFormInfo, updateSettings, createItem, updateItem, moveItem, deleteItem\) |
| `includeFormInResponse` | boolean | No | Whether to return the updated form in the response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `replies` | array | The replies from each update request |
| `writeControl` | object | Write control information with revision IDs |
| ↳ `requiredRevisionId` | string | Required revision ID for conflict detection |
| ↳ `targetRevisionId` | string | Target revision ID |
| `form` | object | The updated form \(if includeFormInResponse was true\) |
| ↳ `formId` | string | The form ID |
| ↳ `info` | object | Form info containing title and description |
| ↳ `title` | string | The form title visible to responders |
| ↳ `description` | string | The form description |
| ↳ `documentTitle` | string | The document title visible in Drive |
| ↳ `title` | string | Item title |
| ↳ `description` | string | Item description |
| ↳ `documentTitle` | string | The document title visible in Drive |
| ↳ `settings` | object | Form settings |
| ↳ `quizSettings` | object | Quiz settings |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `emailCollectionType` | string | Email collection type |
| ↳ `quizSettings` | object | Quiz settings |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `isQuiz` | boolean | Whether the form is a quiz |
| ↳ `emailCollectionType` | string | Email collection type |
| ↳ `itemId` | string | Item ID |
| ↳ `questionItem` | json | Question item configuration |
| ↳ `questionGroupItem` | json | Question group configuration |
| ↳ `pageBreakItem` | json | Page break configuration |
| ↳ `textItem` | json | Text item configuration |
| ↳ `imageItem` | json | Image item configuration |
| ↳ `videoItem` | json | Video item configuration |
| ↳ `revisionId` | string | The revision ID of the form |
| ↳ `responderUri` | string | The URI to share with responders |
| ↳ `linkedSheetId` | string | The ID of the linked Google Sheet |
| ↳ `publishSettings` | object | Form publish settings |
| ↳ `publishState` | object | Current publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `publishState` | object | Current publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form is accepting responses |
### `google_forms_set_publish_settings`
Update the publish settings of a form (publish/unpublish, accept responses)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `isPublished` | boolean | Yes | Whether the form is published and visible to others |
| `isAcceptingResponses` | boolean | No | Whether the form accepts responses \(forced to false if isPublished is false\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formId` | string | The form ID |
| `publishSettings` | json | The updated publish settings |
| ↳ `publishState` | object | The publish state |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
| ↳ `isPublished` | boolean | Whether the form is published |
| ↳ `isAcceptingResponses` | boolean | Whether the form accepts responses |
### `google_forms_create_watch`
Create a notification watch for form changes (schema changes or new responses)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form to watch |
| `eventType` | string | Yes | Event type to watch: SCHEMA \(form changes\) or RESPONSES \(new submissions\) |
| `topicName` | string | Yes | The Cloud Pub/Sub topic name \(format: projects/\{project\}/topics/\{topic\}\) |
| `watchId` | string | No | Custom watch ID \(4-63 chars, lowercase letters, numbers, hyphens\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | The watch ID |
| `eventType` | string | The event type being watched |
| `topicName` | string | The Cloud Pub/Sub topic |
| `createTime` | string | When the watch was created |
| `expireTime` | string | When the watch expires \(7 days after creation\) |
| `state` | string | The watch state \(ACTIVE, SUSPENDED\) |
### `google_forms_list_watches`
List all notification watches for a form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `watches` | array | List of watches for the form |
| ↳ `id` | string | Watch ID |
| ↳ `eventType` | string | Event type \(SCHEMA or RESPONSES\) |
| ↳ `createTime` | string | When the watch was created |
| ↳ `expireTime` | string | When the watch expires |
| ↳ `state` | string | Watch state |
### `google_forms_delete_watch`
Delete a notification watch from a form
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `watchId` | string | Yes | The ID of the watch to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the watch was successfully deleted |
### `google_forms_renew_watch`
Renew a notification watch for another 7 days
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `formId` | string | Yes | The ID of the Google Form |
| `watchId` | string | Yes | The ID of the watch to renew |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | The watch ID |
| `eventType` | string | The event type being watched |
| `expireTime` | string | The new expiration time |
| `state` | string | The watch state |
| `data` | json | Response or list of responses |

View File

@@ -215,191 +215,4 @@ Check if a user is a member of a Google Group
| --------- | ---- | ----------- |
| `isMember` | boolean | Whether the user is a member of the group |
### `google_groups_list_aliases`
List all email aliases for a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `aliases` | array | List of email aliases for the group |
| ↳ `id` | string | Unique group identifier |
| ↳ `primaryEmail` | string | Group |
| ↳ `alias` | string | Alias email address |
| ↳ `kind` | string | API resource type |
| ↳ `etag` | string | Resource version identifier |
### `google_groups_add_alias`
Add an email alias to a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
| `alias` | string | Yes | The email alias to add to the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Unique group identifier |
| `primaryEmail` | string | Group |
| `alias` | string | The alias that was added |
| `kind` | string | API resource type |
| `etag` | string | Resource version identifier |
### `google_groups_remove_alias`
Remove an email alias from a Google Group
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupKey` | string | Yes | Group email address or unique group ID |
| `alias` | string | Yes | The email alias to remove from the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the alias was successfully deleted |
### `google_groups_get_settings`
Get the settings for a Google Group including access permissions, moderation, and posting options
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupEmail` | string | Yes | The email address of the group |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | The group |
| `name` | string | The group name \(max 75 characters\) |
| `description` | string | The group description \(max 4096 characters\) |
| `whoCanJoin` | string | Who can join the group \(ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN\) |
| `whoCanViewMembership` | string | Who can view group membership |
| `whoCanViewGroup` | string | Who can view group messages |
| `whoCanPostMessage` | string | Who can post messages to the group |
| `allowExternalMembers` | string | Whether external users can be members |
| `allowWebPosting` | string | Whether web posting is allowed |
| `primaryLanguage` | string | The group |
| `isArchived` | string | Whether messages are archived |
| `archiveOnly` | string | Whether the group is archive-only \(inactive\) |
| `messageModerationLevel` | string | Message moderation level |
| `spamModerationLevel` | string | Spam handling level \(ALLOW, MODERATE, SILENTLY_MODERATE, REJECT\) |
| `replyTo` | string | Default reply destination |
| `customReplyTo` | string | Custom email for replies |
| `includeCustomFooter` | string | Whether to include custom footer |
| `customFooterText` | string | Custom footer text \(max 1000 characters\) |
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
| `whoCanLeaveGroup` | string | Who can leave the group |
| `whoCanContactOwner` | string | Who can contact the group owner |
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
| `whoCanApproveMembers` | string | Who can approve new members |
| `whoCanBanUsers` | string | Who can ban users |
| `whoCanModerateMembers` | string | Who can manage members |
| `whoCanModerateContent` | string | Who can moderate content |
| `whoCanAssistContent` | string | Who can assist with content metadata |
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
| `whoCanDiscoverGroup` | string | Who can discover the group |
| `defaultSender` | string | Default sender identity \(DEFAULT_SELF or GROUP\) |
### `google_groups_update_settings`
Update the settings for a Google Group including access permissions, moderation, and posting options
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `groupEmail` | string | Yes | The email address of the group |
| `name` | string | No | The group name \(max 75 characters\) |
| `description` | string | No | The group description \(max 4096 characters\) |
| `whoCanJoin` | string | No | Who can join: ANYONE_CAN_JOIN, ALL_IN_DOMAIN_CAN_JOIN, INVITED_CAN_JOIN, CAN_REQUEST_TO_JOIN |
| `whoCanViewMembership` | string | No | Who can view membership: ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
| `whoCanViewGroup` | string | No | Who can view group messages: ANYONE_CAN_VIEW, ALL_IN_DOMAIN_CAN_VIEW, ALL_MEMBERS_CAN_VIEW, ALL_MANAGERS_CAN_VIEW |
| `whoCanPostMessage` | string | No | Who can post: NONE_CAN_POST, ALL_MANAGERS_CAN_POST, ALL_MEMBERS_CAN_POST, ALL_OWNERS_CAN_POST, ALL_IN_DOMAIN_CAN_POST, ANYONE_CAN_POST |
| `allowExternalMembers` | string | No | Whether external users can be members: true or false |
| `allowWebPosting` | string | No | Whether web posting is allowed: true or false |
| `primaryLanguage` | string | No | The group's primary language \(e.g., en\) |
| `isArchived` | string | No | Whether messages are archived: true or false |
| `archiveOnly` | string | No | Whether the group is archive-only \(inactive\): true or false |
| `messageModerationLevel` | string | No | Message moderation: MODERATE_ALL_MESSAGES, MODERATE_NON_MEMBERS, MODERATE_NEW_MEMBERS, MODERATE_NONE |
| `spamModerationLevel` | string | No | Spam handling: ALLOW, MODERATE, SILENTLY_MODERATE, REJECT |
| `replyTo` | string | No | Default reply: REPLY_TO_CUSTOM, REPLY_TO_SENDER, REPLY_TO_LIST, REPLY_TO_OWNER, REPLY_TO_IGNORE, REPLY_TO_MANAGERS |
| `customReplyTo` | string | No | Custom email for replies \(when replyTo is REPLY_TO_CUSTOM\) |
| `includeCustomFooter` | string | No | Whether to include custom footer: true or false |
| `customFooterText` | string | No | Custom footer text \(max 1000 characters\) |
| `sendMessageDenyNotification` | string | No | Whether to send rejection notifications: true or false |
| `defaultMessageDenyNotificationText` | string | No | Default rejection message text |
| `membersCanPostAsTheGroup` | string | No | Whether members can post as the group: true or false |
| `includeInGlobalAddressList` | string | No | Whether included in Global Address List: true or false |
| `whoCanLeaveGroup` | string | No | Who can leave: ALL_MANAGERS_CAN_LEAVE, ALL_MEMBERS_CAN_LEAVE, NONE_CAN_LEAVE |
| `whoCanContactOwner` | string | No | Who can contact owner: ALL_IN_DOMAIN_CAN_CONTACT, ALL_MANAGERS_CAN_CONTACT, ALL_MEMBERS_CAN_CONTACT, ANYONE_CAN_CONTACT |
| `favoriteRepliesOnTop` | string | No | Whether favorite replies appear at top: true or false |
| `whoCanApproveMembers` | string | No | Who can approve members: ALL_OWNERS_CAN_APPROVE, ALL_MANAGERS_CAN_APPROVE, ALL_MEMBERS_CAN_APPROVE, NONE_CAN_APPROVE |
| `whoCanBanUsers` | string | No | Who can ban users: OWNERS_ONLY, OWNERS_AND_MANAGERS, NONE |
| `whoCanModerateMembers` | string | No | Who can manage members: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `whoCanModerateContent` | string | No | Who can moderate content: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `whoCanAssistContent` | string | No | Who can assist with content metadata: OWNERS_ONLY, OWNERS_AND_MANAGERS, ALL_MEMBERS, NONE |
| `enableCollaborativeInbox` | string | No | Whether collaborative inbox is enabled: true or false |
| `whoCanDiscoverGroup` | string | No | Who can discover: ANYONE_CAN_DISCOVER, ALL_IN_DOMAIN_CAN_DISCOVER, ALL_MEMBERS_CAN_DISCOVER |
| `defaultSender` | string | No | Default sender: DEFAULT_SELF or GROUP |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | The group |
| `name` | string | The group name |
| `description` | string | The group description |
| `whoCanJoin` | string | Who can join the group |
| `whoCanViewMembership` | string | Who can view group membership |
| `whoCanViewGroup` | string | Who can view group messages |
| `whoCanPostMessage` | string | Who can post messages to the group |
| `allowExternalMembers` | string | Whether external users can be members |
| `allowWebPosting` | string | Whether web posting is allowed |
| `primaryLanguage` | string | The group |
| `isArchived` | string | Whether messages are archived |
| `archiveOnly` | string | Whether the group is archive-only |
| `messageModerationLevel` | string | Message moderation level |
| `spamModerationLevel` | string | Spam handling level |
| `replyTo` | string | Default reply destination |
| `customReplyTo` | string | Custom email for replies |
| `includeCustomFooter` | string | Whether to include custom footer |
| `customFooterText` | string | Custom footer text |
| `sendMessageDenyNotification` | string | Whether to send rejection notifications |
| `defaultMessageDenyNotificationText` | string | Default rejection message text |
| `membersCanPostAsTheGroup` | string | Whether members can post as the group |
| `includeInGlobalAddressList` | string | Whether included in Global Address List |
| `whoCanLeaveGroup` | string | Who can leave the group |
| `whoCanContactOwner` | string | Who can contact the group owner |
| `favoriteRepliesOnTop` | string | Whether favorite replies appear at top |
| `whoCanApproveMembers` | string | Who can approve new members |
| `whoCanBanUsers` | string | Who can ban users |
| `whoCanModerateMembers` | string | Who can manage members |
| `whoCanModerateContent` | string | Who can moderate content |
| `whoCanAssistContent` | string | Who can assist with content metadata |
| `enableCollaborativeInbox` | string | Whether collaborative inbox is enabled |
| `whoCanDiscoverGroup` | string | Who can discover the group |
| `defaultSender` | string | Default sender identity |

View File

@@ -28,7 +28,7 @@ In Sim, the Google Sheets integration empowers your agents to automate reading f
## Usage Instructions
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, update, clear data, create spreadsheets, get spreadsheet info, and copy sheets.
Integrate Google Sheets into the workflow with explicit sheet selection. Can read, write, append, and update data in specific sheets.
@@ -42,8 +42,9 @@ Read data from a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet \(found in the URL: docs.google.com/spreadsheets/d/\{SPREADSHEET_ID\}/edit\). |
| `range` | string | No | The A1 notation range to read \(e.g. "Sheet1!A1:D10", "A1:B5"\). Defaults to first sheet A1:Z1000 if not specified. |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
| `cellRange` | string | No | The cell range to read \(e.g. "A1:D10"\). Defaults to "A1:Z1000" if not specified. |
#### Output
@@ -65,7 +66,8 @@ Write data to a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `range` | string | No | The A1 notation range to write to \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
| `cellRange` | string | No | The cell range to write to \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
@@ -91,7 +93,8 @@ Update data in a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to update |
| `range` | string | No | The A1 notation range to update \(e.g. "Sheet1!A1:D10", "A1:B5"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to update |
| `cellRange` | string | No | The cell range to update \(e.g. "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to update as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to update |
| `includeValuesInResponse` | boolean | No | Whether to include the updated values in the response |
@@ -117,7 +120,7 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to append to |
| `range` | string | No | The A1 notation range to append after \(e.g. "Sheet1", "Sheet1!A:D"\) |
| `sheetName` | string | Yes | The name of the sheet/tab to append to |
| `values` | array | Yes | The data to append as a 2D array \(e.g. \[\["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to append |
| `insertDataOption` | string | No | How to insert the data \(OVERWRITE or INSERT_ROWS\) |
@@ -136,180 +139,4 @@ Append data to the end of a specific sheet in a Google Sheets spreadsheet
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_clear`
Clear values from a specific range in a Google Sheets spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to clear |
| `cellRange` | string | No | The cell range to clear \(e.g. "A1:D10"\). Clears entire sheet if not specified. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `clearedRange` | string | The range that was cleared |
| `sheetName` | string | Name of the sheet that was cleared |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_get_spreadsheet`
Get metadata about a Google Sheets spreadsheet including title and sheet list
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `includeGridData` | boolean | No | Whether to include grid data \(cell values\). Defaults to false. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `title` | string | The title of the spreadsheet |
| `locale` | string | The locale of the spreadsheet |
| `timeZone` | string | The time zone of the spreadsheet |
| `spreadsheetUrl` | string | URL to the spreadsheet |
| `sheets` | array | List of sheets in the spreadsheet |
| ↳ `sheetId` | number | The sheet ID |
| ↳ `title` | string | The sheet title/name |
| ↳ `index` | number | The sheet index \(position\) |
| ↳ `rowCount` | number | Number of rows in the sheet |
| ↳ `columnCount` | number | Number of columns in the sheet |
| ↳ `hidden` | boolean | Whether the sheet is hidden |
### `google_sheets_create_spreadsheet`
Create a new Google Sheets spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `title` | string | Yes | The title of the new spreadsheet |
| `sheetTitles` | json | No | Array of sheet names to create \(e.g., \["Sheet1", "Data", "Summary"\]\). Defaults to a single "Sheet1". |
| `locale` | string | No | The locale of the spreadsheet \(e.g., "en_US"\) |
| `timeZone` | string | No | The time zone of the spreadsheet \(e.g., "America/New_York"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The ID of the created spreadsheet |
| `title` | string | The title of the created spreadsheet |
| `spreadsheetUrl` | string | URL to the created spreadsheet |
| `sheets` | array | List of sheets created in the spreadsheet |
| ↳ `sheetId` | number | The sheet ID |
| ↳ `title` | string | The sheet title/name |
| ↳ `index` | number | The sheet index \(position\) |
### `google_sheets_batch_get`
Read multiple ranges from a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `ranges` | json | Yes | Array of ranges to read \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
| `majorDimension` | string | No | The major dimension of values: "ROWS" \(default\) or "COLUMNS" |
| `valueRenderOption` | string | No | How values should be rendered: "FORMATTED_VALUE" \(default\), "UNFORMATTED_VALUE", or "FORMULA" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `valueRanges` | array | Array of value ranges read from the spreadsheet |
| ↳ `range` | string | The range that was read |
| ↳ `majorDimension` | string | Major dimension \(ROWS or COLUMNS\) |
| ↳ `values` | array | The cell values as a 2D array |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_batch_update`
Update multiple ranges in a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `data` | json | Yes | Array of value ranges to update. Each item should have "range" \(e.g., "Sheet1!A1:D10"\) and "values" \(2D array\). |
| `valueInputOption` | string | No | How input data should be interpreted: "RAW" or "USER_ENTERED" \(default\). USER_ENTERED parses formulas. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `totalUpdatedRows` | number | Total number of rows updated |
| `totalUpdatedColumns` | number | Total number of columns updated |
| `totalUpdatedCells` | number | Total number of cells updated |
| `totalUpdatedSheets` | number | Total number of sheets updated |
| `responses` | array | Array of update responses for each range |
| ↳ `spreadsheetId` | string | The spreadsheet ID |
| ↳ `updatedRange` | string | The range that was updated |
| ↳ `updatedRows` | number | Number of rows updated in this range |
| ↳ `updatedColumns` | number | Number of columns updated in this range |
| ↳ `updatedCells` | number | Number of cells updated in this range |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_batch_clear`
Clear multiple ranges in a Google Sheets spreadsheet in a single request
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet |
| `ranges` | json | Yes | Array of ranges to clear \(e.g., \["Sheet1!A1:D10", "Sheet2!A1:B5"\]\). Each range should include sheet name. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `spreadsheetId` | string | The spreadsheet ID |
| `clearedRanges` | array | Array of ranges that were cleared |
| `metadata` | json | Spreadsheet metadata including ID and URL |
| ↳ `spreadsheetId` | string | Google Sheets spreadsheet ID |
| ↳ `spreadsheetUrl` | string | Spreadsheet URL |
### `google_sheets_copy_sheet`
Copy a sheet from one spreadsheet to another
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `sourceSpreadsheetId` | string | Yes | The ID of the source spreadsheet |
| `sheetId` | number | Yes | The ID of the sheet to copy \(numeric ID, not the sheet name\). Use Get Spreadsheet to find sheet IDs. |
| `destinationSpreadsheetId` | string | Yes | The ID of the destination spreadsheet where the sheet will be copied |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `sheetId` | number | The ID of the newly created sheet in the destination |
| `title` | string | The title of the copied sheet |
| `index` | number | The index \(position\) of the copied sheet |
| `sheetType` | string | The type of the sheet \(GRID, CHART, etc.\) |
| `destinationSpreadsheetId` | string | The ID of the destination spreadsheet |
| `destinationSpreadsheetUrl` | string | URL to the destination spreadsheet |

View File

@@ -30,7 +30,7 @@ In Sim, the Google Slides integration enables your agents to interact directly w
## Usage Instructions
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, get thumbnails, get page details, delete objects, duplicate objects, reorder slides, create tables, create shapes, and insert text.
Integrate Google Slides into the workflow. Can read, write, create presentations, replace text, add slides, add images, and get thumbnails.
@@ -52,15 +52,6 @@ Read content from a Google Slides presentation
| --------- | ---- | ----------- |
| `slides` | json | Array of slides with their content |
| `metadata` | json | Presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `pageSize` | object | Presentation page size |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `width` | json | Page width as a Dimension object |
| ↳ `height` | json | Page height as a Dimension object |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_write`
@@ -80,10 +71,6 @@ Write or update content in a Google Slides presentation
| --------- | ---- | ----------- |
| `updatedContent` | boolean | Indicates if presentation content was updated successfully |
| `metadata` | json | Updated presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_create`
@@ -103,10 +90,6 @@ Create a new Google Slides presentation
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `metadata` | json | Created presentation metadata including ID, title, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `title` | string | The presentation title |
| ↳ `mimeType` | string | The mime type of the presentation |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_replace_all_text`
@@ -128,10 +111,6 @@ Find and replace all occurrences of text throughout a Google Slides presentation
| --------- | ---- | ----------- |
| `occurrencesChanged` | number | Number of text occurrences that were replaced |
| `metadata` | json | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `findText` | string | The text that was searched for |
| ↳ `replaceText` | string | The text that replaced the matches |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_slide`
@@ -152,10 +131,6 @@ Add a new slide to a Google Slides presentation with a specified layout
| --------- | ---- | ----------- |
| `slideId` | string | The object ID of the newly created slide |
| `metadata` | json | Operation metadata including presentation ID, layout, and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `layout` | string | The layout used for the new slide |
| ↳ `insertionIndex` | number | The zero-based index where the slide was inserted |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_add_image`
@@ -179,10 +154,6 @@ Insert an image into a specific slide in a Google Slides presentation
| --------- | ---- | ----------- |
| `imageId` | string | The object ID of the newly created image |
| `metadata` | json | Operation metadata including presentation ID and image URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the image was inserted |
| ↳ `imageUrl` | string | The source image URL |
| ↳ `url` | string | URL to open the presentation |
### `google_slides_get_thumbnail`
@@ -205,182 +176,5 @@ Generate a thumbnail image of a specific slide in a Google Slides presentation
| `width` | number | Width of the thumbnail in pixels |
| `height` | number | Height of the thumbnail in pixels |
| `metadata` | json | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID for the thumbnail |
| ↳ `thumbnailSize` | string | The requested thumbnail size |
| ↳ `mimeType` | string | The thumbnail MIME type |
### `google_slides_get_page`
Get detailed information about a specific slide/page in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `objectId` | string | The object ID of the page |
| `pageType` | string | The type of page \(SLIDE, MASTER, LAYOUT, NOTES, NOTES_MASTER\) |
| `pageElements` | array | Array of page elements \(shapes, images, tables, etc.\) on this page |
| `slideProperties` | object | Properties specific to slides \(layout, master, notes\) |
| ↳ `layoutObjectId` | string | Object ID of the layout this slide is based on |
| ↳ `masterObjectId` | string | Object ID of the master this slide is based on |
| ↳ `notesPage` | json | The notes page associated with the slide |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_delete_object`
Delete a page element (shape, image, table, etc.) or an entire slide from a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the element or slide to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `deleted` | boolean | Whether the object was successfully deleted |
| `objectId` | string | The object ID that was deleted |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_duplicate_object`
Duplicate an object (slide, shape, image, table, etc.) in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the element or slide to duplicate |
| `objectIds` | string | No | Optional JSON object mapping source object IDs \(within the slide being duplicated\) to new object IDs for the duplicates. Format: \{"sourceId1":"newId1","sourceId2":"newId2"\} |
| `Format` | string | No | No description |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `duplicatedObjectId` | string | The object ID of the newly created duplicate |
| `metadata` | object | Operation metadata including presentation ID and source object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `sourceObjectId` | string | The original object ID that was duplicated |
| ↳ `url` | string | URL to the presentation |
### `google_slides_update_slides_position`
Move one or more slides to a new position in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `slideObjectIds` | string | Yes | Comma-separated list of slide object IDs to move. The slides will maintain their relative order. |
| `insertionIndex` | number | Yes | The zero-based index where the slides should be moved. All slides with indices greater than or equal to this will be shifted right. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `moved` | boolean | Whether the slides were successfully moved |
| `slideObjectIds` | array | The slide object IDs that were moved |
| `insertionIndex` | number | The index where the slides were moved to |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |
### `google_slides_create_table`
Create a new table on a slide in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the table to |
| `rows` | number | Yes | Number of rows in the table \(minimum 1\) |
| `columns` | number | Yes | Number of columns in the table \(minimum 1\) |
| `width` | number | No | Width of the table in points \(default: 400\) |
| `height` | number | No | Height of the table in points \(default: 200\) |
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableId` | string | The object ID of the newly created table |
| `rows` | number | Number of rows in the table |
| `columns` | number | Number of columns in the table |
| `metadata` | object | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the table was created |
| ↳ `url` | string | URL to the presentation |
### `google_slides_create_shape`
Create a shape (rectangle, ellipse, text box, arrow, etc.) on a slide in a Google Slides presentation
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `pageObjectId` | string | Yes | The object ID of the slide/page to add the shape to |
| `shapeType` | string | Yes | The type of shape to create. Common types: TEXT_BOX, RECTANGLE, ROUND_RECTANGLE, ELLIPSE, TRIANGLE, DIAMOND, STAR_5, ARROW_EAST, HEART, CLOUD |
| `width` | number | No | Width of the shape in points \(default: 200\) |
| `height` | number | No | Height of the shape in points \(default: 100\) |
| `positionX` | number | No | X position from the left edge in points \(default: 100\) |
| `positionY` | number | No | Y position from the top edge in points \(default: 100\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `shapeId` | string | The object ID of the newly created shape |
| `shapeType` | string | The type of shape that was created |
| `metadata` | object | Operation metadata including presentation ID and page object ID |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `pageObjectId` | string | The page object ID where the shape was created |
| ↳ `url` | string | URL to the presentation |
### `google_slides_insert_text`
Insert text into a shape or table cell in a Google Slides presentation. Use this to add text to text boxes, shapes, or table cells.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `presentationId` | string | Yes | The ID of the presentation |
| `objectId` | string | Yes | The object ID of the shape or table cell to insert text into. For table cells, use the cell object ID. |
| `text` | string | Yes | The text to insert |
| `insertionIndex` | number | No | The zero-based index at which to insert the text. If not specified, text is inserted at the beginning \(index 0\). |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `inserted` | boolean | Whether the text was successfully inserted |
| `objectId` | string | The object ID where text was inserted |
| `text` | string | The text that was inserted |
| `metadata` | object | Operation metadata including presentation ID and URL |
| ↳ `presentationId` | string | The presentation ID |
| ↳ `url` | string | URL to the presentation |

View File

@@ -51,7 +51,6 @@ Search for similar content in a knowledge base using vector similarity
| `properties` | string | No | No description |
| `tagName` | string | No | No description |
| `tagValue` | string | No | No description |
| `tagFilters` | string | No | No description |
#### Output
@@ -109,8 +108,19 @@ Create a new document in a knowledge base
| `knowledgeBaseId` | string | Yes | ID of the knowledge base containing the document |
| `name` | string | Yes | Name of the document |
| `content` | string | Yes | Content of the document |
| `documentTags` | object | No | Document tags |
| `documentTags` | string | No | No description |
| `tag1` | string | No | Tag 1 value for the document |
| `tag2` | string | No | Tag 2 value for the document |
| `tag3` | string | No | Tag 3 value for the document |
| `tag4` | string | No | Tag 4 value for the document |
| `tag5` | string | No | Tag 5 value for the document |
| `tag6` | string | No | Tag 6 value for the document |
| `tag7` | string | No | Tag 7 value for the document |
| `documentTagsData` | array | No | Structured tag data with names, types, and values |
| `items` | object | No | No description |
| `properties` | string | No | No description |
| `tagName` | string | No | No description |
| `tagValue` | string | No | No description |
| `tagType` | string | No | No description |
#### Output

View File

@@ -45,7 +45,8 @@ Read data from a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
| `range` | string | No | The range of cells to read from. Accepts "SheetName!A1:B2" for explicit ranges or just "SheetName" to read the used range of that sheet. If omitted, reads the used range of the first sheet. |
| `sheetName` | string | Yes | The name of the sheet/tab to read from |
| `cellRange` | string | No | The cell range to read \(e.g., "A1:D10"\). If not specified, reads the entire used range. |
#### Output
@@ -67,8 +68,9 @@ Write data to a specific sheet in a Microsoft Excel spreadsheet
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
| `range` | string | No | The range of cells to write to |
| `values` | array | Yes | The data to write to the spreadsheet |
| `sheetName` | string | Yes | The name of the sheet/tab to write to |
| `cellRange` | string | No | The cell range to write to \(e.g., "A1:D10", "A1"\). Defaults to "A1" if not specified. |
| `values` | array | Yes | The data to write as a 2D array \(e.g. \[\["Name", "Age"\], \["Alice", 30\], \["Bob", 25\]\]\) or array of objects. |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |

View File

@@ -84,10 +84,9 @@ Send messages to Slack channels or direct messages. Supports Slack mrkdwn format
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `destinationType` | string | No | Destination type: channel or dm |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Target Slack channel \(e.g., #general\) |
| `dmUserId` | string | No | Target Slack user for direct messages |
| `userId` | string | No | Target Slack user ID for direct messages \(e.g., U1234567890\) |
| `text` | string | Yes | Message text to send \(supports Slack mrkdwn formatting\) |
| `thread_ts` | string | No | Thread timestamp to reply to \(creates thread reply\) |
| `files` | file[] | No | Files to attach to the message |
@@ -133,10 +132,9 @@ Read the latest messages from Slack channels. Retrieve conversation history with
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `destinationType` | string | No | Destination type: channel or dm |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | No | Slack channel to read messages from \(e.g., #general\) |
| `dmUserId` | string | No | Target Slack user for DM conversation |
| `userId` | string | No | User ID for DM conversation \(e.g., U1234567890\) |
| `limit` | number | No | Number of messages to retrieve \(default: 10, max: 15\) |
| `oldest` | string | No | Start of time range \(timestamp\) |
| `latest` | string | No | End of time range \(timestamp\) |

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getSession } from '@/lib/auth'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
@@ -19,32 +19,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
logger.info(
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
)
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
return NextResponse.json({
success: true,
@@ -64,25 +51,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow session and internal JWT auth (not API key)
if (auth.authType === 'api_key') {
return NextResponse.json(
{ error: 'API key auth not supported for this endpoint' },
{ status: 401 }
)
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json()

View File

@@ -19,7 +19,7 @@ export interface RateLimitResult {
export async function checkRateLimit(
request: NextRequest,
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
endpoint: 'logs' | 'logs-detail' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -1,102 +0,0 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowDetailsAPI')
export const revalidate = 0
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflow-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { id } = await params
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
const rows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
variables: workflow.variables,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(eq(workflow.id, id))
.limit(1)
const workflowData = rows[0]
if (!workflowData) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const blockRows = await db
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, id))
const blocksRecord = Object.fromEntries(
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
)
const inputs = extractInputFieldsFromBlocks(blocksRecord)
const response = {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
color: workflowData.color,
folderId: workflowData.folderId,
workspaceId: workflowData.workspaceId,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt?.toISOString() || null,
runCount: workflowData.runCount,
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
variables: workflowData.variables || {},
inputs,
createdAt: workflowData.createdAt.toISOString(),
updatedAt: workflowData.updatedAt.toISOString(),
}
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,184 +0,0 @@
import { db } from '@sim/db'
import { permissions, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const QueryParamsSchema = z.object({
workspaceId: z.string(),
folderId: z.string().optional(),
deployedOnly: z.coerce.boolean().optional().default(false),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
interface CursorData {
sortOrder: number
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const { searchParams } = new URL(request.url)
const rawParams = Object.fromEntries(searchParams.entries())
const validationResult = QueryParamsSchema.safeParse(rawParams)
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: validationResult.error.errors },
{ status: 400 }
)
}
const params = validationResult.data
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
userId,
filters: {
folderId: params.folderId,
deployedOnly: params.deployedOnly,
},
})
const conditions = [
eq(workflow.workspaceId, params.workspaceId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, params.workspaceId),
eq(permissions.userId, userId),
]
if (params.folderId) {
conditions.push(eq(workflow.folderId, params.folderId))
}
if (params.deployedOnly) {
conditions.push(eq(workflow.isDeployed, true))
}
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData) {
const cursorCondition = or(
gt(workflow.sortOrder, cursorData.sortOrder),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
gt(workflow.createdAt, new Date(cursorData.createdAt))
),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
eq(workflow.createdAt, new Date(cursorData.createdAt)),
gt(workflow.id, cursorData.id)
)
)
if (cursorCondition) {
conditions.push(cursorCondition)
}
}
}
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
const rows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
folderId: workflow.folderId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, params.workspaceId),
eq(permissions.userId, userId)
)
)
.where(and(...conditions))
.orderBy(...orderByClause)
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const lastWorkflow = data[data.length - 1]
nextCursor = encodeCursor({
sortOrder: lastWorkflow.sortOrder,
createdAt: lastWorkflow.createdAt.toISOString(),
id: lastWorkflow.id,
})
}
const formattedWorkflows = data.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
color: w.color,
folderId: w.folderId,
workspaceId: w.workspaceId,
isDeployed: w.isDeployed,
deployedAt: w.deployedAt?.toISOString() || null,
runCount: w.runCount,
lastRunAt: w.lastRunAt?.toISOString() || null,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
}))
const limits = await getUserLimits(userId)
const response = createApiResponse(
{
data: formattedWorkflows,
nextCursor,
},
limits,
rateLimit
)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,4 +1,4 @@
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
import React, { type HTMLAttributes, type ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Tooltip } from '@/components/emcn'
@@ -23,16 +23,24 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
)
}
const REMARK_PLUGINS = [remarkGfm]
export default function MarkdownRenderer({
content,
customLinkComponent,
}: {
content: string
customLinkComponent?: typeof LinkWithPreview
}) {
const LinkComponent = customLinkComponent || LinkWithPreview
function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
return {
const customComponents = {
// Paragraph
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1 font-sans text-base text-gray-800 leading-relaxed last:mb-0 dark:text-gray-200'>
{children}
</p>
),
// Headings
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-gray-900 dark:text-gray-100'>
{children}
@@ -54,6 +62,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</h4>
),
// Lists
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-gray-800 dark:text-gray-200'
@@ -80,6 +89,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</li>
),
// Code blocks
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
let codeProps: HTMLAttributes<HTMLElement> = {}
let codeContent: ReactNode = children
@@ -110,6 +120,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
)
},
// Inline code
code: ({
inline,
className,
@@ -133,20 +144,24 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
)
},
// Blockquotes
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-4 border-gray-300 border-l-4 py-1 pl-4 font-sans text-gray-700 italic dark:border-gray-600 dark:text-gray-300'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-8 border-gray-500/[.07] border-t dark:border-gray-400/[.07]' />,
// Links
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkComponent href={href || '#'} {...props}>
{children}
</LinkComponent>
),
// Tables
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-4 w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-gray-300 font-sans text-sm dark:border-gray-700'>
@@ -178,6 +193,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
src={src}
@@ -187,33 +203,15 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
/>
),
}
}
const DEFAULT_COMPONENTS = createCustomComponents(LinkWithPreview)
const MarkdownRenderer = memo(function MarkdownRenderer({
content,
customLinkComponent,
}: {
content: string
customLinkComponent?: typeof LinkWithPreview
}) {
const components = useMemo(() => {
if (!customLinkComponent) {
return DEFAULT_COMPONENTS
}
return createCustomComponents(customLinkComponent)
}, [customLinkComponent])
// Pre-process content to fix common issues
const processedContent = content.trim()
return (
<div className='space-y-4 break-words font-sans text-[#0D0D0D] text-base leading-relaxed dark:text-gray-100'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={customComponents}>
{processedContent}
</ReactMarkdown>
</div>
)
})
export default MarkdownRenderer
}

View File

@@ -7,7 +7,7 @@ import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import '@/app/_styles/globals.css'
import { OneDollarStats } from '@/components/analytics/onedollarstats'
import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags'
import { isReactGrabEnabled } from '@/lib/core/config/feature-flags'
import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
@@ -35,13 +35,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang='en' suppressHydrationWarning>
<head>
{isReactScanEnabled && (
<Script
src='https://unpkg.com/react-scan/dist/auto.global.js'
crossOrigin='anonymous'
strategy='beforeInteractive'
/>
)}
{isReactGrabEnabled && (
<Script
src='https://unpkg.com/react-grab/dist/index.global.js'

View File

@@ -1,13 +1,10 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SupportFooter } from '@/app/(auth)/components/support-footer'
import { InviteLayout } from '@/app/invite/components'
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { useBrandConfig } from '@/lib/branding/branding'
interface UnsubscribeData {
success: boolean
@@ -30,6 +27,7 @@ function UnsubscribeContent() {
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const brand = useBrandConfig()
const email = searchParams.get('email')
const token = searchParams.get('token')
@@ -111,7 +109,7 @@ function UnsubscribeContent() {
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch {
} catch (error) {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
@@ -120,171 +118,272 @@ function UnsubscribeContent() {
if (loading) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Invalid Unsubscribe Link
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.history.back()}>Go Back</BrandedButton>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
'_blank'
)
}
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (data?.isTransactional) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Important Account Emails
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Transactional emails like password resets, account confirmations, and security alerts
cannot be unsubscribed from as they contain essential information for your account.
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
if (unsubscribed) {
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Successfully Unsubscribed
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => window.close()}>Close</BrandedButton>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</CardContent>
</Card>
</div>
)
}
const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll
return (
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Email Preferences
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Choose which emails you'd like to stop receiving.
</p>
<p className={`${inter.className} mt-2 font-[380] text-[14px] text-muted-foreground`}>
{data?.email}
</p>
</div>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center p-4 before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{processing
? 'Unsubscribing...'
: data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton
onClick={() => handleUnsubscribe('all')}
disabled={processing || isAlreadyUnsubscribedFromAll}
loading={processing}
loadingText='Unsubscribing'
>
{isAlreadyUnsubscribedFromAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</BrandedButton>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<div className='py-2 text-center'>
<span className={`${inter.className} font-[380] text-[14px] text-muted-foreground`}>
or choose specific types
</span>
</div>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<BrandedButton
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeMarketing
}
>
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<BrandedButton
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeUpdates
}
>
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</BrandedButton>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<BrandedButton
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
isAlreadyUnsubscribedFromAll ||
data?.currentPreferences.unsubscribeNotifications
}
>
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</BrandedButton>
</div>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<div className={`${inter.className} mt-6 max-w-[410px] text-center`}>
<p className='font-[380] text-[13px] text-muted-foreground'>
You'll continue receiving important account emails like password resets and security
alerts.
</p>
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
className='text-muted-foreground hover:underline'
>
{brand.supportEmail}
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -292,20 +391,13 @@ export default function Unsubscribe() {
return (
<Suspense
fallback={
<InviteLayout>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Validating your unsubscribe link...
</p>
</div>
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<SupportFooter position='absolute' />
</InviteLayout>
<div className='before:-z-50 relative flex min-h-screen items-center justify-center before:pointer-events-none before:fixed before:inset-0 before:bg-white'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
}
>
<UnsubscribeContent />

View File

@@ -4,13 +4,13 @@ import type React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import {
useWorkspacePermissions,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
import { useNotificationStore } from '@/stores/notifications'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
@@ -64,8 +64,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
// Track whether we've already surfaced an offline notification to avoid duplicates
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
// Get operation error state from collaborative workflow
const { hasOperationError } = useCollaborativeWorkflow()
const addNotification = useNotificationStore((state) => state.addNotification)

View File

@@ -48,17 +48,17 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId, setPendingSelection } = useWorkflowRegistry()
const { activeWorkflowId } = useWorkflowRegistry()
const blocks = useWorkflowStore((state) => state.blocks)
const subBlockStore = useSubBlockStore()
const handleDuplicateBlock = useCallback(() => {
const blocks = useWorkflowStore.getState().blocks
const sourceBlock = blocks[blockId]
if (!sourceBlock) return
const newId = crypto.randomUUID()
const newName = getUniqueBlockName(sourceBlock.name, blocks)
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId || '']?.[blockId] || {}
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {}
const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({
sourceBlock,
@@ -68,10 +68,18 @@ export const ActionBar = memo(
subBlockValues,
})
setPendingSelection([newId])
collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues })
}, [blockId, activeWorkflowId, collaborativeBatchAddBlocks, setPendingSelection])
}, [
blockId,
blocks,
activeWorkflowId,
subBlockStore.workflowValues,
collaborativeBatchAddBlocks,
])
/**
* Optimized single store subscription for all block data
*/
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
useCallback(
(state) => {

View File

@@ -3,11 +3,13 @@ import ReactMarkdown from 'react-markdown'
import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/core/utils/cn'
import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowBlockProps } from '../workflow-block/types'

View File

@@ -6,8 +6,6 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Code, Tooltip } from '@/components/emcn'
const REMARK_PLUGINS = [remarkGfm]
/**
* Recursively extracts text content from React elements
* @param element - React node to extract text from
@@ -151,12 +149,14 @@ interface CopilotMarkdownRendererProps {
* Tighter spacing compared to traditional prose for better chat UX
*/
const markdownComponents = {
// Paragraphs - tight spacing, no margin on last
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className='mb-1.5 font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] last:mb-0 dark:font-[470]'>
{children}
</p>
),
// Headings - minimal margins for chat context
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className='mt-2 mb-1 font-season font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
@@ -178,6 +178,7 @@ const markdownComponents = {
</h4>
),
// Lists - compact spacing
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul
className='my-1 space-y-0.5 pl-5 font-base font-season text-[var(--text-primary)] dark:font-[470]'
@@ -203,6 +204,7 @@ const markdownComponents = {
</li>
),
// Code blocks - handled by CodeBlock component
pre: ({ children }: React.HTMLAttributes<HTMLPreElement>) => {
let codeContent: React.ReactNode = children
let language = 'code'
@@ -241,6 +243,7 @@ const markdownComponents = {
return <CodeBlock code={actualCodeText} language={language} />
},
// Inline code
code: ({
className,
children,
@@ -254,6 +257,7 @@ const markdownComponents = {
</code>
),
// Text formatting
strong: ({ children }: React.HTMLAttributes<HTMLElement>) => (
<strong className='font-semibold text-[var(--text-primary)]'>{children}</strong>
),
@@ -267,18 +271,22 @@ const markdownComponents = {
<i className='text-[var(--text-primary)] italic'>{children}</i>
),
// Blockquote - compact
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className='my-1.5 border-[var(--border-1)] border-l-2 py-0.5 pl-3 font-season text-[var(--text-secondary)] text-sm italic'>
{children}
</blockquote>
),
// Horizontal rule
hr: () => <hr className='my-3 border-[var(--divider)] border-t' />,
// Links
a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<LinkWithPreview href={href || '#'}>{children}</LinkWithPreview>
),
// Tables - compact
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='min-w-full table-auto border border-[var(--border-1)] font-season text-xs'>
@@ -306,6 +314,7 @@ const markdownComponents = {
</td>
),
// Images
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img src={src} alt={alt || 'Image'} className='my-2 h-auto max-w-full rounded-md' {...props} />
),
@@ -321,7 +330,7 @@ const markdownComponents = {
function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) {
return (
<div className='max-w-full break-words font-base font-season text-[var(--text-primary)] text-sm leading-[1.4] dark:font-[470] [&_*]:max-w-full [&_a]:break-all [&_code:not(pre_code)]:break-words [&_li]:break-words [&_p]:break-words'>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>

View File

@@ -1,2 +1,4 @@
export { useCheckpointManagement } from './use-checkpoint-management'
export { useMessageEditing } from './use-message-editing'
export { useMessageFeedback } from './use-message-feedback'
export { useSuccessTimers } from './use-success-timers'

View File

@@ -0,0 +1,138 @@
'use client'
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import type { CopilotMessage } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
const logger = createLogger('useMessageFeedback')
const WORKFLOW_TOOL_NAMES = ['edit_workflow']
interface UseMessageFeedbackProps {
setShowUpvoteSuccess: (show: boolean) => void
setShowDownvoteSuccess: (show: boolean) => void
}
/**
* Custom hook to handle message feedback (upvote/downvote)
*
* @param message - The copilot message
* @param messages - Array of all messages in the chat
* @param props - Success state setters from useSuccessTimers
* @returns Feedback management utilities
*/
export function useMessageFeedback(
message: CopilotMessage,
messages: CopilotMessage[],
props: UseMessageFeedbackProps
) {
const { setShowUpvoteSuccess, setShowDownvoteSuccess } = props
const { currentChat } = useCopilotStore()
/**
* Gets the full assistant response content from message
*/
const getFullAssistantContent = useCallback((message: CopilotMessage) => {
if (message.content?.trim()) {
return message.content
}
if (message.contentBlocks && message.contentBlocks.length > 0) {
return message.contentBlocks
.filter((block) => block.type === 'text')
.map((block) => block.content)
.join('')
}
return message.content || ''
}, [])
/**
* Finds the last user query before this assistant message
*/
const getLastUserQuery = useCallback(() => {
const messageIndex = messages.findIndex((msg) => msg.id === message.id)
if (messageIndex === -1) return null
for (let i = messageIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return messages[i].content
}
}
return null
}, [messages, message.id])
/**
* Submits feedback to the API
*/
const submitFeedback = useCallback(
async (isPositive: boolean) => {
if (!currentChat?.id) {
logger.error('No current chat ID available for feedback submission')
return
}
const userQuery = getLastUserQuery()
if (!userQuery) {
logger.error('No user query found for feedback submission')
return
}
const agentResponse = getFullAssistantContent(message)
if (!agentResponse.trim()) {
logger.error('No agent response content available for feedback submission')
return
}
try {
const requestBody = {
chatId: currentChat.id,
userQuery,
agentResponse,
isPositiveFeedback: isPositive,
}
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`Failed to submit feedback: ${response.statusText}`)
}
await response.json()
} catch (error) {
logger.error('Error submitting feedback:', error)
}
},
[currentChat, getLastUserQuery, getFullAssistantContent, message]
)
/**
* Handles upvote action
*/
const handleUpvote = useCallback(async () => {
setShowDownvoteSuccess(false)
setShowUpvoteSuccess(true)
await submitFeedback(true)
}, [submitFeedback])
/**
* Handles downvote action
*/
const handleDownvote = useCallback(async () => {
setShowUpvoteSuccess(false)
setShowDownvoteSuccess(true)
await submitFeedback(false)
}, [submitFeedback])
return {
handleUpvote,
handleDownvote,
}
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
/**
* Duration to show success indicators (in milliseconds)
*/
const SUCCESS_DISPLAY_DURATION = 2000
/**
* Custom hook to manage auto-hiding success states
* Automatically hides success indicators after a set duration
*
* @returns Success state management utilities
*/
export function useSuccessTimers() {
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [showUpvoteSuccess, setShowUpvoteSuccess] = useState(false)
const [showDownvoteSuccess, setShowDownvoteSuccess] = useState(false)
/**
* Auto-hide copy success indicator after duration
*/
useEffect(() => {
if (showCopySuccess) {
const timer = setTimeout(() => {
setShowCopySuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showCopySuccess])
/**
* Auto-hide upvote success indicator after duration
*/
useEffect(() => {
if (showUpvoteSuccess) {
const timer = setTimeout(() => {
setShowUpvoteSuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showUpvoteSuccess])
/**
* Auto-hide downvote success indicator after duration
*/
useEffect(() => {
if (showDownvoteSuccess) {
const timer = setTimeout(() => {
setShowDownvoteSuccess(false)
}, SUCCESS_DISPLAY_DURATION)
return () => clearTimeout(timer)
}
}, [showDownvoteSuccess])
/**
* Handles copy to clipboard action
* @param content - Content to copy to clipboard
*/
const handleCopy = useCallback((content: string) => {
navigator.clipboard.writeText(content)
setShowCopySuccess(true)
}, [])
return {
// State
showCopySuccess,
showUpvoteSuccess,
showDownvoteSuccess,
// Operations
handleCopy,
setShowUpvoteSuccess,
setShowDownvoteSuccess,
}
}

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp, LayoutList } from 'lucide-react'
import Editor from 'react-simple-code-editor'
@@ -530,7 +530,7 @@ function splitActionVerb(text: string): [string | null, string] {
* For special tool calls, uses a gradient color. For normal tools, highlights action verbs
* in a lighter color with the rest in default gray.
*/
const ShimmerOverlayText = memo(function ShimmerOverlayText({
function ShimmerOverlayText({
text,
active = false,
className,
@@ -622,7 +622,256 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
`}</style>
</span>
)
})
}
/**
* SubAgentToolCall renders a nested tool call from a subagent in a muted/thinking style.
*/
function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCall }) {
// Get live toolCall from store to ensure we have the latest state and params
const liveToolCall = useCopilotStore((s) =>
toolCallProp.id ? s.toolCallsById[toolCallProp.id] : undefined
)
const toolCall = liveToolCall || toolCallProp
const displayName = getDisplayNameForSubAgent(toolCall)
const isLoading =
toolCall.state === ClientToolCallState.generating ||
toolCall.state === ClientToolCallState.pending ||
toolCall.state === ClientToolCallState.executing
const showButtons = shouldShowRunSkipButtons(toolCall)
const isSpecial = isSpecialToolCall(toolCall)
// Get params for table rendering
const params =
(toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {}
// Render table for tools that support it
const renderSubAgentTable = () => {
if (toolCall.name === 'set_environment_variables') {
const variables = params.variables || params.env_vars || {}
const entries = Array.isArray(variables)
? variables.map((v: any, i: number) => [v.name || `var_${i}`, v.value || ''])
: Object.entries(variables).map(([key, val]) => {
if (typeof val === 'object' && val !== null && 'value' in (val as any)) {
return [key, (val as any).value]
}
return [key, val]
})
if (entries.length === 0) return null
return (
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
<thead className='bg-transparent'>
<tr className='border-[var(--border-1)] border-b bg-transparent'>
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Variable
</th>
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
<tbody className='bg-transparent'>
{entries.map((entry) => {
const [key, value] = entry as [string, any]
return (
<tr key={key} className='border-[var(--border-1)] border-t bg-transparent'>
<td className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[6px]'>
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
{key}
</span>
</td>
<td className='w-[64%] bg-transparent px-[10px] py-[6px]'>
<span className='font-mono text-[var(--text-muted)] text-xs'>
{String(value)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
if (toolCall.name === 'set_global_workflow_variables') {
const ops = Array.isArray(params.operations) ? (params.operations as any[]) : []
if (ops.length === 0) return null
return (
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<div className='grid grid-cols-3 gap-0 border-[var(--border-1)] border-b bg-[var(--surface-4)] py-1.5'>
<div className='self-start px-2 font-medium font-season text-[10px] text-[var(--text-secondary)] uppercase tracking-wide'>
Name
</div>
<div className='self-start px-2 font-medium font-season text-[10px] text-[var(--text-secondary)] uppercase tracking-wide'>
Type
</div>
<div className='self-start px-2 font-medium font-season text-[10px] text-[var(--text-secondary)] uppercase tracking-wide'>
Value
</div>
</div>
<div className='divide-y divide-[var(--border-1)]'>
{ops.map((op, idx) => (
<div key={idx} className='grid grid-cols-3 gap-0 py-1.5'>
<div className='min-w-0 self-start px-2'>
<span className='font-season text-[var(--text-primary)] text-xs'>
{String(op.name || '')}
</span>
</div>
<div className='self-start px-2'>
<span className='rounded border border-[var(--border-1)] px-1 py-0.5 font-[470] font-season text-[10px] text-[var(--text-primary)]'>
{String(op.type || '')}
</span>
</div>
<div className='min-w-0 self-start px-2'>
<span className='font-[470] font-mono text-[var(--text-muted)] text-xs'>
{op.value !== undefined ? String(op.value) : '—'}
</span>
</div>
</div>
))}
</div>
</div>
)
}
if (toolCall.name === 'run_workflow') {
let inputs = params.input || params.inputs || params.workflow_input
if (typeof inputs === 'string') {
try {
inputs = JSON.parse(inputs)
} catch {
inputs = {}
}
}
if (params.workflow_input && typeof params.workflow_input === 'object') {
inputs = params.workflow_input
}
if (!inputs || typeof inputs !== 'object') {
const { workflowId, workflow_input, ...rest } = params
inputs = rest
}
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
if (inputEntries.length === 0) return null
/**
* Format a value for display - handles objects, arrays, and primitives
*/
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-'
if (typeof value === 'string') return value || '-'
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
/**
* Check if a value is a complex type (object or array)
*/
const isComplex = (value: unknown): boolean => {
return typeof value === 'object' && value !== null
}
return (
<div className='mt-1.5 w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
{/* Header */}
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Input</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{inputEntries.length}
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
{inputEntries.map(([key, value], index) => {
const formattedValue = formatValue(value)
const needsCodeViewer = isComplex(value)
return (
<div
key={key}
className={clsx(
'flex flex-col gap-1 px-[10px] py-[6px]',
index > 0 && 'border-[var(--border-1)] border-t'
)}
>
{/* Input key */}
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
{/* Value display */}
{needsCodeViewer ? (
<Code.Viewer
code={formattedValue}
language='json'
showGutter={false}
className='max-h-[80px] min-h-0'
/>
) : (
<span className='font-mono text-[11px] text-[var(--text-muted)] leading-[1.3]'>
{formattedValue}
</span>
)}
</div>
)
})}
</div>
</div>
)
}
return null
}
// For edit_workflow, only show the WorkflowEditSummary component (replaces text display)
const isEditWorkflow = toolCall.name === 'edit_workflow'
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
return (
<div className='py-0.5'>
{/* Hide text display for edit_workflow when we have operations to show in summary */}
{!(isEditWorkflow && hasOperations) && (
<ShimmerOverlayText
text={displayName}
active={isLoading && !showButtons}
isSpecial={isSpecial}
className='font-[470] font-season text-[12px] text-[var(--text-tertiary)]'
/>
)}
{renderSubAgentTable()}
{/* WorkflowEditSummary is rendered outside SubAgentContent for edit subagent */}
{showButtons && <RunSkipButtons toolCall={toolCall} />}
</div>
)
}
/**
* Get display name for subagent tool calls
*/
function getDisplayNameForSubAgent(toolCall: CopilotToolCall): string {
const fromStore = toolCall.display?.text
if (fromStore) return fromStore
const stateVerb = getStateVerb(toolCall.state)
const formattedName = formatToolName(toolCall.name)
return `${stateVerb} ${formattedName}`
}
/**
* Max height for subagent content before internal scrolling kicks in
*/
const SUBAGENT_MAX_HEIGHT = 200
/**
* Interval for auto-scroll during streaming (ms)
*/
const SUBAGENT_SCROLL_INTERVAL = 100
/**
* Get the outer collapse header label for completed subagent tools.
@@ -633,6 +882,236 @@ function getSubagentCompletionLabel(toolName: string): string {
return labels?.completed ?? 'Thought'
}
/**
* Get display labels for subagent tools.
* Uses the tool's UI config.
*/
function getSubagentLabels(toolName: string, isStreaming: boolean): string {
const labels = getSubagentLabelsFromConfig(toolName, isStreaming)
if (labels) {
return isStreaming ? labels.streaming : labels.completed
}
return isStreaming ? 'Processing' : 'Processed'
}
/**
* SubAgentContent renders the streamed content and tool calls from a subagent
* with thinking-style styling (same as ThinkingBlock).
* Auto-collapses when streaming ends and has internal scrolling for long content.
*/
function SubAgentContent({
blocks,
isStreaming = false,
toolName = 'debug',
}: {
blocks?: SubAgentContentBlock[]
isStreaming?: boolean
toolName?: string
}) {
const [isExpanded, setIsExpanded] = useState(false)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const userCollapsedRef = useRef<boolean>(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
// Check if there are any tool calls (which means thinking should close)
const hasToolCalls = useMemo(() => {
if (!blocks) return false
return blocks.some((b) => b.type === 'subagent_tool_call' && b.toolCall)
}, [blocks])
// Auto-expand when streaming with content, auto-collapse when done or when tool call comes in
useEffect(() => {
if (!isStreaming || hasToolCalls) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
return
}
if (!userCollapsedRef.current && blocks && blocks.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, blocks, hasToolCalls])
// Handle scroll events to detect user scrolling away
useEffect(() => {
const container = scrollContainerRef.current
if (!container || !isExpanded) return
const handleScroll = () => {
if (programmaticScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 20
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -2
if (movedUp && !isNearBottom) {
setUserHasScrolledAway(true)
}
// Re-stick if user scrolls back to bottom
if (userHasScrolledAway && isNearBottom) {
setUserHasScrolledAway(false)
}
lastScrollTopRef.current = scrollTop
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
return () => container.removeEventListener('scroll', handleScroll)
}, [isExpanded, userHasScrolledAway])
// Smart auto-scroll: only scroll if user hasn't scrolled away
useEffect(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return
const intervalId = window.setInterval(() => {
const container = scrollContainerRef.current
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 50
if (isNearBottom) {
programmaticScrollRef.current = true
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 150)
}
}, SUBAGENT_SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
if (!blocks || blocks.length === 0) return null
const hasContent = blocks.length > 0
// Show "done" label when streaming ends OR when tool calls are present
const isThinkingDone = !isStreaming || hasToolCalls
const label = getSubagentLabels(toolName, !isThinkingDone)
return (
<div>
{/* Define shimmer keyframes */}
{!isThinkingDone && (
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
)}
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}}
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
disabled={!hasContent}
>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
{!isThinkingDone && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
</span>
</span>
)}
</span>
{hasContent && (
<ChevronUp
className={clsx(
'h-3 w-3 transition-all group-hover:opacity-100',
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
)}
aria-hidden='true'
/>
)}
</button>
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-150 ease-out',
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{blocks.map((block, index) => {
if (block.type === 'subagent_text' && block.content) {
const isLastBlock = index === blocks.length - 1
// Strip special tags from display (they're rendered separately)
const parsed = parseSpecialTags(block.content)
const displayContent = parsed.cleanContent
if (!displayContent) return null
return (
<pre
key={`subagent-text-${index}`}
className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'
>
{displayContent}
{!isThinkingDone && isLastBlock && (
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
)}
</pre>
)
}
// All tool calls are rendered at top level, skip here
return null
})}
</div>
{/* Render PlanSteps for plan subagent when content contains <plan> tag */}
{toolName === 'plan' &&
(() => {
// Combine all text content from blocks
const allText = blocks
.filter((b) => b.type === 'subagent_text' && b.content)
.map((b) => b.content)
.join('')
const parsed = parseSpecialTags(allText)
if (parsed.plan && Object.keys(parsed.plan).length > 0) {
return <PlanSteps steps={parsed.plan} streaming={!isThinkingDone} />
}
return null
})()}
</div>
)
}
/**
* SubAgentThinkingContent renders subagent blocks as simple thinking text (ThinkingBlock).
* Used for inline rendering within regular tool calls that have subagent content.
@@ -644,6 +1123,7 @@ function SubAgentThinkingContent({
blocks: SubAgentContentBlock[]
isStreaming?: boolean
}) {
// Combine all text content from blocks
let allRawText = ''
let cleanText = ''
for (const block of blocks) {
@@ -654,10 +1134,12 @@ function SubAgentThinkingContent({
}
}
// Parse plan from all text
const allParsed = parseSpecialTags(allRawText)
if (!cleanText.trim() && !allParsed.plan) return null
// Check if special tags are present
const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0)
return (
@@ -690,7 +1172,7 @@ const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research'])
* - When done (not streaming): Most subagents stay expanded, only specific ones collapse
* - Exception: plan, debug, research, info subagents collapse into a header
*/
const SubagentContentRenderer = memo(function SubagentContentRenderer({
function SubagentContentRenderer({
toolCall,
shouldCollapse,
}: {
@@ -700,26 +1182,36 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
const [isExpanded, setIsExpanded] = useState(true)
const [duration, setDuration] = useState(0)
const startTimeRef = useRef<number>(Date.now())
const wasStreamingRef = useRef(false)
const isStreaming = !!toolCall.subAgentStreaming
// Reset start time when streaming begins
useEffect(() => {
if (isStreaming && !wasStreamingRef.current) {
if (isStreaming) {
startTimeRef.current = Date.now()
wasStreamingRef.current = true
} else if (!isStreaming && wasStreamingRef.current) {
setDuration(Date.now() - startTimeRef.current)
wasStreamingRef.current = false
setDuration(0)
}
}, [isStreaming])
// Update duration timer during streaming
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, 100)
return () => clearInterval(interval)
}, [isStreaming])
// Auto-collapse when streaming ends (only for collapsible subagents)
useEffect(() => {
if (!isStreaming && shouldCollapse) {
setIsExpanded(false)
}
}, [isStreaming, shouldCollapse])
// Build segments: each segment is either text content or a tool call
const segments: Array<
{ type: 'text'; content: string } | { type: 'tool'; block: SubAgentContentBlock }
> = []
@@ -743,6 +1235,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
segments.push({ type: 'text', content: currentText })
}
// Parse plan and options
const allParsed = parseSpecialTags(allRawText)
const hasSpecialTags = !!(
(allParsed.plan && Object.keys(allParsed.plan).length > 0) ||
@@ -754,11 +1247,15 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
return `${seconds}s`
}
// Outer header uses subagent-specific label
const outerLabel = getSubagentCompletionLabel(toolCall.name)
const durationText = `${outerLabel} for ${formatDuration(duration)}`
// Check if we have a plan to render outside the collapsible
const hasPlan = allParsed.plan && Object.keys(allParsed.plan).length > 0
// Render the collapsible content (thinking blocks + tool calls, NOT plan)
// Inner thinking text always uses "Thought" label
const renderCollapsibleContent = () => (
<>
{segments.map((segment, index) => {
@@ -778,6 +1275,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
)
}
if (segment.type === 'tool' && segment.block.toolCall) {
// For edit subagent's edit_workflow tool: only show the diff summary, skip the tool call header
if (toolCall.name === 'edit' && segment.block.toolCall.name === 'edit_workflow') {
return (
<div key={`tool-${segment.block.toolCall.id || index}`}>
@@ -796,6 +1294,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
</>
)
// During streaming OR for non-collapsible subagents: show content at top level
if (isStreaming || !shouldCollapse) {
return (
<div className='w-full space-y-1.5'>
@@ -805,6 +1304,8 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
)
}
// Completed collapsible subagent (plan, debug, research, info): show collapsible header
// Plan artifact stays outside the collapsible
return (
<div className='w-full'>
<button
@@ -835,7 +1336,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
{hasPlan && <PlanSteps steps={allParsed.plan!} />}
</div>
)
})
}
/**
* Determines if a tool call is "special" and should display with gradient styling.
@@ -850,15 +1351,15 @@ function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
* Displays: workflow name with stats (+N green, N orange, -N red)
* Expands inline on click to show individual blocks with their icons.
*/
const WorkflowEditSummary = memo(function WorkflowEditSummary({
toolCall,
}: {
toolCall: CopilotToolCall
}) {
function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
// Get block data from current workflow state
const blocks = useWorkflowStore((s) => s.blocks)
// Cache block info on first render (before diff is applied) so we can show
// deleted blocks properly even after they're removed from the workflow
const cachedBlockInfoRef = useRef<Record<string, { name: string; type: string }>>({})
// Update cache with current block info (only add, never remove)
useEffect(() => {
for (const [blockId, block] of Object.entries(blocks)) {
if (!cachedBlockInfoRef.current[blockId]) {
@@ -870,18 +1371,22 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
}
}, [blocks])
// Show for edit_workflow regardless of state
if (toolCall.name !== 'edit_workflow') {
return null
}
// Extract operations from tool call params
const params =
(toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {}
let operations = Array.isArray(params.operations) ? params.operations : []
// Fallback: check if operations are at top level of toolCall
if (operations.length === 0 && Array.isArray((toolCall as any).operations)) {
operations = (toolCall as any).operations
}
// Group operations by type with block info
interface SubBlockPreview {
id: string
title: string
@@ -907,20 +1412,25 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
const blockId = op.block_id
if (!blockId) continue
// Get block info from current workflow state, cached state, or operation params
const currentBlock = blocks[blockId]
const cachedBlock = cachedBlockInfoRef.current[blockId]
let blockName = currentBlock?.name || cachedBlock?.name || ''
let blockType = currentBlock?.type || cachedBlock?.type || ''
// For add operations, get info from params (type is stored as params.type)
if (op.operation_type === 'add' && op.params) {
blockName = blockName || op.params.name || ''
blockType = blockType || op.params.type || ''
}
// For edit operations, also check params.type if block not in current state
if (op.operation_type === 'edit' && op.params && !blockType) {
blockType = op.params.type || ''
}
// Skip edge-only edit operations (like how we don't highlight blocks on canvas for edge changes)
// An edit is edge-only if params only contains 'connections' and nothing else meaningful
if (op.operation_type === 'edit' && op.params) {
const paramKeys = Object.keys(op.params)
const isEdgeOnlyEdit = paramKeys.length === 1 && paramKeys[0] === 'connections'
@@ -929,7 +1439,9 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
}
}
// For delete operations, check if block info was provided in operation
if (op.operation_type === 'delete') {
// Some delete operations may include block_name and block_type
blockName = blockName || op.block_name || ''
blockType = blockType || op.block_type || ''
}
@@ -941,12 +1453,16 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
const change: BlockChange = { blockId, blockName, blockType }
// Extract subblock info from operation params, ordered by block config
if (op.params?.inputs && typeof op.params.inputs === 'object') {
const inputs = op.params.inputs as Record<string, unknown>
const blockConfig = getBlock(blockType)
// Build subBlocks array
const subBlocks: SubBlockPreview[] = []
// Special handling for condition blocks - parse conditions JSON and render as separate rows
// This matches how the canvas renders condition blocks with "if", "else if", "else" rows
if (blockType === 'condition' && 'conditions' in inputs) {
const conditionsValue = inputs.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -968,26 +1484,35 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
}
}
} catch {
// Fallback: show default if/else
subBlocks.push({ id: 'if', title: 'if', value: '', isPassword: false })
subBlocks.push({ id: 'else', title: 'else', value: '', isPassword: false })
}
} else {
// Filter visible subblocks from config (same logic as canvas preview)
const visibleSubBlocks =
blockConfig?.subBlocks?.filter((sb) => {
// Skip hidden subblocks
if (sb.hidden) return false
if (sb.hideFromPreview) return false
// Skip advanced mode subblocks (not visible by default)
if (sb.mode === 'advanced') return false
// Skip trigger mode subblocks
if (sb.mode === 'trigger') return false
return true
}) ?? []
// Track seen ids to dedupe (same pattern as canvas preview using id as key)
const seenIds = new Set<string>()
// Add subblocks that are visible in config, in config order (first config per id wins)
for (const subBlockConfig of visibleSubBlocks) {
// Skip if we've already added this id (handles configs with same id but different conditions)
if (seenIds.has(subBlockConfig.id)) continue
if (subBlockConfig.id in inputs) {
const value = inputs[subBlockConfig.id]
// Skip empty values and connections
if (value === null || value === undefined || value === '') continue
seenIds.add(subBlockConfig.id)
subBlocks.push({
@@ -1028,10 +1553,12 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
return null
}
// Get block config by type (for icon and bgColor)
const getBlockConfig = (blockType: string) => {
return getBlock(blockType)
}
// Render a single block item with action icon and details
const renderBlockItem = (change: BlockChange, type: 'add' | 'edit' | 'delete') => {
const blockConfig = getBlockConfig(change.blockType)
const Icon = blockConfig?.icon
@@ -1104,20 +1631,23 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
{deletedBlocks.map((change) => renderBlockItem(change, 'delete'))}
</div>
)
})
}
/**
* Checks if a tool is an integration tool (server-side executed, not a client tool)
*/
function isIntegrationTool(toolName: string): boolean {
// Any tool NOT in CLASS_TOOL_METADATA is an integration tool (server-side execution)
return !CLASS_TOOL_METADATA[toolName]
}
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
// First check UI config for interrupt
if (hasInterruptFromConfig(toolCall.name) && toolCall.state === 'pending') {
return true
}
// Then check instance-level interrupt
const instance = getClientTool(toolCall.id)
let hasInterrupt = !!instance?.getInterruptDisplays?.()
if (!hasInterrupt) {
@@ -1132,10 +1662,12 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
} catch {}
}
// Show buttons for client tools with interrupts
if (hasInterrupt && toolCall.state === 'pending') {
return true
}
// Always show buttons for integration tools in pending state (they need user confirmation)
const mode = useCopilotStore.getState().mode
if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') {
return true
@@ -1152,14 +1684,19 @@ async function handleRun(
) {
const instance = getClientTool(toolCall.id)
// Handle integration tools (server-side execution)
if (!instance && isIntegrationTool(toolCall.name)) {
// Set executing state immediately for UI feedback
setToolCallState(toolCall, 'executing')
onStateChange?.('executing')
try {
await useCopilotStore.getState().executeIntegrationTool(toolCall.id)
// Note: executeIntegrationTool handles success/error state updates internally
} catch (e) {
// If executeIntegrationTool throws, ensure we update state to error
setToolCallState(toolCall, 'error', { error: e instanceof Error ? e.message : String(e) })
onStateChange?.('error')
// Notify backend about the error so agent doesn't hang
try {
await fetch('/api/copilot/tools/mark-complete', {
method: 'POST',
@@ -1173,6 +1710,7 @@ async function handleRun(
}),
})
} catch {
// Last resort: log error if we can't notify backend
console.error('[handleRun] Failed to notify backend of tool error:', toolCall.id)
}
}
@@ -1197,10 +1735,13 @@ async function handleRun(
async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any) {
const instance = getClientTool(toolCall.id)
// Handle integration tools (skip by marking as rejected and notifying backend)
if (!instance && isIntegrationTool(toolCall.name)) {
setToolCallState(toolCall, 'rejected')
onStateChange?.('rejected')
// Notify backend that tool was skipped - this is CRITICAL for the agent to continue
// Retry up to 3 times if the notification fails
let notified = false
for (let attempt = 0; attempt < 3 && !notified; attempt++) {
try {
@@ -1219,6 +1760,7 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt
notified = true
}
} catch (e) {
// Wait briefly before retry
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
@@ -1241,6 +1783,7 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt
}
function getDisplayName(toolCall: CopilotToolCall): string {
// Prefer display resolved in the copilot store (SSOT)
const fromStore = (toolCall as any).display?.text
if (fromStore) return fromStore
try {
@@ -1249,6 +1792,8 @@ function getDisplayName(toolCall: CopilotToolCall): string {
if (byState?.text) return byState.text
} catch {}
// For integration tools, format the tool name nicely
// e.g., "google_calendar_list_events" -> "Running Google Calendar List Events"
const stateVerb = getStateVerb(toolCall.state)
const formattedName = formatToolName(toolCall.name)
return `${stateVerb} ${formattedName}`
@@ -1763,9 +2308,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
if (toolCall.name === 'run_workflow') {
// Get inputs - could be in multiple locations
let inputs = editedParams.input || editedParams.inputs || editedParams.workflow_input
let isNestedInWorkflowInput = false
// If input is a JSON string, parse it
if (typeof inputs === 'string') {
try {
inputs = JSON.parse(inputs)
@@ -1774,11 +2321,13 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
}
// Check if workflow_input exists and contains the actual inputs
if (editedParams.workflow_input && typeof editedParams.workflow_input === 'object') {
inputs = editedParams.workflow_input
isNestedInWorkflowInput = true
}
// If no inputs object found, treat base editedParams as inputs (excluding system fields)
if (!inputs || typeof inputs !== 'object') {
const { workflowId, workflow_input, ...rest } = editedParams
inputs = rest
@@ -1787,6 +2336,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
// Don't show the section if there are no inputs
if (inputEntries.length === 0) {
return null
}
@@ -1798,6 +2348,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
// For objects and arrays, use JSON.stringify with formatting
try {
return JSON.stringify(value, null, 2)
} catch {
@@ -1809,9 +2360,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
* Parse a string value back to its original type if possible
*/
const parseInputValue = (value: string, originalValue: unknown): unknown => {
// If original was a primitive, keep as string
if (typeof originalValue !== 'object' || originalValue === null) {
return value
}
// Try to parse as JSON for objects/arrays
try {
return JSON.parse(value)
} catch {
@@ -1920,6 +2473,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
return null
}
// Special handling for tools with alwaysExpanded config (e.g., set_environment_variables)
const isAlwaysExpanded = toolUIConfig?.alwaysExpanded
if (
(isAlwaysExpanded || toolCall.name === 'set_environment_variables') &&
@@ -1977,6 +2531,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
)
}
// Special rendering for tools with 'code' customRenderer (e.g., function_execute)
if (toolUIConfig?.customRenderer === 'code' || toolCall.name === 'function_execute') {
const code = params.code || ''
const isFunctionExecuteClickable = isAutoAllowed
@@ -2038,6 +2593,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
)
}
// Determine if tool name should be clickable (expandable tools or auto-allowed integration tools)
const isToolNameClickable = isExpandableTool || isAutoAllowed
const handleToolNameClick = () => {
@@ -2048,6 +2604,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
}
}
// For edit_workflow, hide text display when we have operations (WorkflowEditSummary replaces it)
const isEditWorkflow = toolCall.name === 'edit_workflow'
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
@@ -2092,11 +2649,13 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
onClick={async () => {
try {
const instance = getClientTool(toolCall.id)
// Transition to background state locally so UI updates immediately
instance?.setState?.((ClientToolCallState as any).background)
await instance?.markToolComplete?.(
200,
'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete'
)
// Optionally force a re-render; store should sync state from server
forceUpdate({})
onStateChange?.('background')
} catch {}
@@ -2113,16 +2672,21 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
onClick={async () => {
try {
const instance = getClientTool(toolCall.id)
// Get elapsed seconds before waking
const elapsedSeconds = instance?.getElapsedSeconds?.() || 0
// Transition to background state locally so UI updates immediately
// Pass elapsed seconds in the result so dynamic text can use it
instance?.setState?.((ClientToolCallState as any).background, {
result: { _elapsedSeconds: elapsedSeconds },
})
// Update the tool call params in the store to include elapsed time for display
const { updateToolCallParams } = useCopilotStore.getState()
updateToolCallParams?.(toolCall.id, { _elapsedSeconds: Math.round(elapsedSeconds) })
await instance?.markToolComplete?.(
200,
`User woke you up after ${Math.round(elapsedSeconds)} seconds`
)
// Optionally force a re-render; store should sync state from server
forceUpdate({})
onStateChange?.('background')
} catch {}

View File

@@ -151,7 +151,7 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
useShallow(useCallback((state) => Object.keys(state.blocks), []))
)
const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const registryWorkflows = useWorkflowRegistry((state) => state.workflows)
const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase)
const isLoadingWorkflows =
hydrationPhase === 'idle' ||

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import type { useMentionMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-menu'
import type { ChatContext } from '@/stores/panel'
@@ -39,11 +39,11 @@ export function useMentionTokens({
setSelectedContexts,
}: UseMentionTokensProps) {
/**
* Memoized mention ranges - computed once when message or selectedContexts change.
* This prevents expensive O(n×m) string searches from running on every keystroke
* when other callbacks access the ranges.
* Computes all mention ranges in the message (both @mentions and /commands)
*
* @returns Array of mention ranges sorted by start position
*/
const memoizedMentionRanges = useMemo((): MentionRange[] => {
const computeMentionRanges = useCallback((): MentionRange[] => {
const ranges: MentionRange[] = []
if (!message || selectedContexts.length === 0) return ranges
@@ -93,45 +93,35 @@ export function useMentionTokens({
/**
* Finds a mention range containing the given position
*/
const computeMentionRanges = useCallback(
(): MentionRange[] => memoizedMentionRanges,
[memoizedMentionRanges]
)
/**
* Finds a mention range containing the given position.
* Uses memoized ranges directly for better performance.
*
* @param pos - Position to check
* @returns Mention range if found, undefined otherwise
*/
const findRangeContaining = useCallback(
(pos: number): MentionRange | undefined => {
return memoizedMentionRanges.find((r) => pos > r.start && pos < r.end)
const ranges = computeMentionRanges()
return ranges.find((r) => pos > r.start && pos < r.end)
},
[memoizedMentionRanges]
[computeMentionRanges]
)
/**
* Removes contexts for mention tokens that overlap with a text selection.
* Uses memoized ranges directly for better performance.
* Removes contexts for mention tokens that overlap with a text selection
*
* @param selStart - Selection start position
* @param selEnd - Selection end position
*/
const removeContextsInSelection = useCallback(
(selStart: number, selEnd: number) => {
const overlappingRanges = memoizedMentionRanges.filter(
(r) => !(selEnd <= r.start || selStart >= r.end)
)
const ranges = computeMentionRanges()
const overlappingRanges = ranges.filter((r) => !(selEnd <= r.start || selStart >= r.end))
if (overlappingRanges.length > 0) {
const labelsToRemove = new Set(overlappingRanges.map((r) => r.label))
setSelectedContexts((prev) => prev.filter((c) => !c.label || !labelsToRemove.has(c.label)))
}
},
[memoizedMentionRanges, setSelectedContexts]
[computeMentionRanges, setSelectedContexts]
)
/**

View File

@@ -655,13 +655,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
[insertTriggerAndOpenMenu]
)
const handleModelSelect = useCallback(
(model: string) => {
setSelectedModel(model as any)
},
[setSelectedModel]
)
const canSubmit = message.trim().length > 0 && !disabled && !isLoading
const showAbortButton = isLoading && onAbort
@@ -870,7 +863,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
<ModelSelector
selectedModel={selectedModel}
isNearTop={isNearTop}
onModelSelect={handleModelSelect}
onModelSelect={(model: string) => setSelectedModel(model as any)}
/>
</div>

View File

@@ -11,6 +11,8 @@ import {
ButtonGroupItem,
Checkbox,
Code,
Combobox,
type ComboboxOption,
Input,
Label,
TagInput,
@@ -269,6 +271,14 @@ export function A2aDeploy({
onNeedsRepublishChange?.(!!needsRepublish)
}, [needsRepublish, onNeedsRepublishChange])
const authSchemeOptions: ComboboxOption[] = useMemo(
() => [
{ label: 'API Key', value: 'apiKey' },
{ label: 'None (Public)', value: 'none' },
],
[]
)
const canSave = name.trim().length > 0 && description.trim().length > 0
useEffect(() => {
onCanSaveChange?.(canSave)
@@ -748,18 +758,17 @@ console.log(data);`
/>
</div>
{/* Access */}
{/* Authentication */}
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Access
Authentication
</Label>
<ButtonGroup
<Combobox
options={authSchemeOptions}
value={authScheme}
onValueChange={(value) => setAuthScheme(value as AuthScheme)}
>
<ButtonGroupItem value='apiKey'>API Key</ButtonGroupItem>
<ButtonGroupItem value='none'>Public</ButtonGroupItem>
</ButtonGroup>
onChange={(v) => setAuthScheme(v as AuthScheme)}
placeholder='Select authentication...'
/>
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authScheme === 'none'
? 'Anyone can call this agent without authentication'

View File

@@ -452,6 +452,39 @@ console.log(limits);`
</div>
)}
{/* <div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
URL
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleCopy('endpoint', info.endpoint)}
aria-label='Copy endpoint'
className='!p-1.5 -my-1.5'
>
{copied.endpoint ? (
<Check className='h-3 w-3' />
) : (
<Clipboard className='h-3 w-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Code.Viewer
code={info.endpoint}
language='javascript'
wrapText
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
/>
</div> */}
<div>
<div className='mb-[6.5px] flex items-center justify-between'>
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

View File

@@ -409,11 +409,7 @@ export function ChatDeploy({
<ModalHeader>Delete Chat</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingChat?.title || 'this chat'}
</span>
?{' '}
Are you sure you want to delete this chat?{' '}
<span className='text-[var(--text-error)]'>
This will remove the chat at "{getEmailDomain()}/chat/{existingChat?.identifier}"
and make it unavailable to all users.
@@ -428,7 +424,7 @@ export function ChatDeploy({
>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={isDeleting}>
<Button variant='default' onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>

View File

@@ -1,260 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type NormalizedField = InputFormatField & { name: string }
interface ApiInfoModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
}
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
const blocks = useWorkflowStore((state) => state.blocks)
const setValue = useSubBlockStore((state) => state.setValue)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const [description, setDescription] = useState('')
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
const [isSaving, setIsSaving] = useState(false)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const initialDescriptionRef = useRef('')
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
const starterBlockId = useMemo(() => {
for (const [blockId, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockType = (block as { type?: string }).type
if (blockType && isValidStartBlockType(blockType)) {
return blockId
}
}
return null
}, [blocks])
const inputFormat = useMemo((): NormalizedField[] => {
if (!starterBlockId) return []
const storeValue = subBlockValues[starterBlockId]?.inputFormat
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
if (normalized.length > 0) return normalized
const startBlock = blocks[starterBlockId]
const blockValue = startBlock?.subBlocks?.inputFormat?.value
return normalizeInputFormatValue(blockValue) as NormalizedField[]
}, [starterBlockId, subBlockValues, blocks])
useEffect(() => {
if (open) {
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
const isDefaultDescription =
!workflowMetadata?.description ||
workflowMetadata.description === workflowMetadata.name ||
normalizedDesc === 'new workflow' ||
normalizedDesc === 'your first workflow - start building here!'
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
setDescription(initialDescription)
initialDescriptionRef.current = initialDescription
const descriptions: Record<string, string> = {}
for (const field of inputFormat) {
if (field.description) {
descriptions[field.name] = field.description
}
}
setParamDescriptions(descriptions)
initialParamDescriptionsRef.current = { ...descriptions }
}
}, [open, workflowMetadata, inputFormat])
const hasChanges = useMemo(() => {
if (description !== initialDescriptionRef.current) return true
for (const field of inputFormat) {
const currentValue = (paramDescriptions[field.name] || '').trim()
const initialValue = (initialParamDescriptionsRef.current[field.name] || '').trim()
if (currentValue !== initialValue) return true
}
return false
}, [description, paramDescriptions, inputFormat])
const handleParamDescriptionChange = (fieldName: string, value: string) => {
setParamDescriptions((prev) => ({
...prev,
[fieldName]: value,
}))
}
const handleCloseAttempt = useCallback(() => {
if (hasChanges && !isSaving) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, isSaving, onOpenChange])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
setParamDescriptions({ ...initialParamDescriptionsRef.current })
onOpenChange(false)
}, [onOpenChange])
const handleSave = useCallback(async () => {
if (!workflowId) return
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId !== workflowId) {
return
}
setIsSaving(true)
try {
if (description.trim() !== (workflowMetadata?.description || '')) {
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
}
if (starterBlockId) {
const updatedValue = inputFormat.map((field) => ({
...field,
description: paramDescriptions[field.name]?.trim() || undefined,
}))
setValue(starterBlockId, 'inputFormat', updatedValue)
}
onOpenChange(false)
} finally {
setIsSaving(false)
}
}, [
workflowId,
description,
workflowMetadata,
updateWorkflow,
starterBlockId,
inputFormat,
paramDescriptions,
setValue,
onOpenChange,
])
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>
<span>Edit API Info</span>
</ModalHeader>
<ModalBody className='space-y-[12px]'>
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Description
</Label>
<Textarea
placeholder='Describe what this workflow API does...'
className='min-h-[80px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{inputFormat.length > 0 && (
<div>
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Parameters ({inputFormat.length})
</Label>
<div className='flex flex-col gap-[8px]'>
{inputFormat.map((field) => (
<div
key={field.name}
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name}
</span>
<Badge size='sm'>{field.type || 'string'}</Badge>
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Description</Label>
<Input
value={paramDescriptions[field.name] || ''}
onChange={(e) =>
handleParamDescriptionChange(field.name, e.target.value)
}
placeholder={`Enter description for ${field.name}`}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='max-w-[400px]'>
<ModalHeader>
<span>Unsaved Changes</span>
</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleDiscardChanges}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -375,11 +375,8 @@ export function TemplateDeploy({
<ModalHeader>Delete Template</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingTemplate?.name || formData.name || 'this template'}
</span>
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
Are you sure you want to delete this template?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>

View File

@@ -43,7 +43,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { A2aDeploy } from './components/a2a/a2a'
import { ApiDeploy } from './components/api/api'
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
import { ApiInfoModal } from './components/general/components/api-info-modal'
import { GeneralDeploy } from './components/general/general'
import { McpDeploy } from './components/mcp/mcp'
import { TemplateDeploy } from './components/template/template'
@@ -111,7 +110,6 @@ export function DeployModal({
const [chatSuccess, setChatSuccess] = useState(false)
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { config: permissionConfig } = usePermissionConfig()
@@ -391,6 +389,11 @@ export function DeployModal({
form?.requestSubmit()
}, [])
const handleA2aFormSubmit = useCallback(() => {
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
form?.requestSubmit()
}, [])
const handleA2aPublish = useCallback(() => {
const form = document.getElementById('a2a-deploy-form')
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
@@ -591,11 +594,7 @@ export function DeployModal({
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-between'>
<div>
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
Edit API Info
</Button>
</div>
<div />
<div className='flex items-center gap-2'>
<Button
variant='tertiary'
@@ -847,11 +846,7 @@ export function DeployModal({
<ModalHeader>Delete A2A Agent</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{existingA2aAgent?.name || 'this agent'}
</span>
?{' '}
Are you sure you want to delete this agent?{' '}
<span className='text-[var(--text-error)]'>
This will permanently remove the agent configuration.
</span>
@@ -881,14 +876,6 @@ export function DeployModal({
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
{workflowId && (
<ApiInfoModal
open={isApiInfoModalOpen}
onOpenChange={setIsApiInfoModalOpen}
workflowId={workflowId}
/>
)}
</>
)
}

View File

@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import 'prismjs/components/prism-python'
@@ -170,7 +170,7 @@ interface CodeProps {
hideInternalWand?: boolean
}
export const Code = memo(function Code({
export function Code({
blockId,
subBlockId,
placeholder = 'Write JavaScript...',
@@ -206,8 +206,6 @@ export const Code = memo(function Code({
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
const hasEditedSinceFocusRef = useRef(false)
const codeRef = useRef(code)
codeRef.current = code
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const emitTagSelection = useTagSelection(blockId, subBlockId)
@@ -309,18 +307,25 @@ export const Code = memo(function Code({
? getDefaultValueString()
: storeValue
const lastValidationStatus = useRef<boolean>(true)
useEffect(() => {
if (!onValidationChange) return
const isValid = !shouldValidateJson || isValidJson
const nextStatus = shouldValidateJson ? isValidJson : true
if (lastValidationStatus.current === nextStatus) {
return
}
if (isValid) {
onValidationChange(true)
lastValidationStatus.current = nextStatus
if (!shouldValidateJson) {
onValidationChange(nextStatus)
return
}
const timeoutId = setTimeout(() => {
onValidationChange(false)
onValidationChange(nextStatus)
}, 150)
return () => clearTimeout(timeoutId)
@@ -332,7 +337,7 @@ export const Code = memo(function Code({
}
handleStreamChunkRef.current = (chunk: string) => {
setCode((prev: string) => prev + chunk)
setCode((prev) => prev + chunk)
}
handleGeneratedContentRef.current = (generatedCode: string) => {
@@ -429,12 +434,12 @@ export const Code = memo(function Code({
`
document.body.appendChild(tempContainer)
lines.forEach((line: string) => {
lines.forEach((line) => {
const lineDiv = document.createElement('div')
if (line.includes('<') && line.includes('>')) {
const parts = line.split(/(<[^>]+>)/g)
parts.forEach((part: string) => {
parts.forEach((part) => {
const span = document.createElement('span')
span.textContent = part
lineDiv.appendChild(span)
@@ -467,6 +472,7 @@ export const Code = memo(function Code({
}
}, [code])
// Event Handlers
/**
* Handles drag-and-drop events for inserting reference tags into the code editor.
* @param e - The drag event
@@ -494,6 +500,7 @@ export const Code = memo(function Code({
textarea.selectionStart = newCursorPosition
textarea.selectionEnd = newCursorPosition
// Show tag dropdown after cursor is positioned
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
@@ -552,45 +559,44 @@ export const Code = memo(function Code({
}
}
// Helper Functions
/**
* Determines whether a `<...>` segment should be highlighted as a reference.
* @param part - The code segment to check
* @returns True if the segment should be highlighted as a reference
*/
const shouldHighlightReference = useCallback(
(part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
const shouldHighlightReference = (part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
if (!isLikelyReferenceSegment(part)) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const split = splitReferenceSegment(part)
if (!split) {
return false
}
const reference = split.reference
const reference = split.reference
if (!accessiblePrefixes) {
return true
}
if (!accessiblePrefixes) {
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const normalizedPrefix = normalizeName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
return true
}
return accessiblePrefixes.has(normalizedPrefix)
},
[accessiblePrefixes]
)
return accessiblePrefixes.has(normalizedPrefix)
}
// Expose wand control handlers to parent via ref
useImperativeHandle(
wandControlRef,
() => ({
@@ -603,62 +609,6 @@ export const Code = memo(function Code({
[generateCodeStream, isPromptVisible, isAiStreaming]
)
const highlightCode = useMemo(
() => createHighlightFunction(effectiveLanguage, shouldHighlightReference),
[effectiveLanguage, shouldHighlightReference]
)
const handleValueChange = useCallback(
(newCode: string) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}
},
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement | HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
}
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
},
[isAiStreaming]
)
const handleEditorFocus = useCallback(() => {
hasEditedSinceFocusRef.current = false
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}, [isPreview, disabled, readOnly])
/**
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
* @returns Array of React elements representing the line numbers
@@ -667,7 +617,7 @@ export const Code = memo(function Code({
const numbers: ReactElement[] = []
let lineNumber = 1
visualLineHeights.forEach((height: number) => {
visualLineHeights.forEach((height) => {
const isActive = lineNumber === activeLineNumber
numbers.push(
<div
@@ -774,10 +724,50 @@ export const Code = memo(function Code({
<Editor
value={code}
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onFocus={handleEditorFocus}
highlight={highlightCode}
onValueChange={(newCode) => {
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
hasEditedSinceFocusRef.current = true
setCode(newCode)
setStoreValue(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
}
if (isAiStreaming) {
e.preventDefault()
}
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !hasEditedSinceFocusRef.current) {
e.preventDefault()
}
}}
onFocus={() => {
hasEditedSinceFocusRef.current = false
// Show tag dropdown on focus when code is empty
if (!isPreview && !disabled && !readOnly && code.trim() === '') {
setShowTags(true)
setCursorPosition(0)
}
}}
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
/>
@@ -820,4 +810,4 @@ export const Code = memo(function Code({
</CodeEditor.Container>
</>
)
})
}

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
@@ -72,7 +71,7 @@ interface ComboBoxProps {
dependsOn?: SubBlockConfig['dependsOn']
}
export const ComboBox = memo(function ComboBox({
export function ComboBox({
options,
defaultValue,
blockId,
@@ -113,8 +112,7 @@ export const ComboBox = memo(function ComboBox({
)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
),
isEqual
)
)
// State management
@@ -283,17 +281,34 @@ export const ComboBox = memo(function ComboBox({
setStoreInitialized(true)
}, [])
// Check if current value is valid (exists in allowed options)
const isValueValid = useMemo(() => {
if (value === null || value === undefined) return false
return evaluatedOptions.some((opt) => getOptionValue(opt) === value)
}, [value, evaluatedOptions, getOptionValue])
// Set default value once store is initialized and permissions are loaded
// Also reset if current value becomes invalid (e.g., provider was blocked)
useEffect(() => {
if (isPermissionLoading) return
if (!storeInitialized) return
if (defaultOptionValue === undefined) return
// Only set default when no value exists (initial block add)
if (value === null || value === undefined) {
const needsDefault = value === null || value === undefined
const needsReset = subBlockId === 'model' && value && !isValueValid
if (needsDefault || needsReset) {
setStoreValue(defaultOptionValue)
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue, isPermissionLoading])
}, [
storeInitialized,
value,
defaultOptionValue,
setStoreValue,
isPermissionLoading,
subBlockId,
isValueValid,
])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {
@@ -422,18 +437,6 @@ export const ComboBox = memo(function ComboBox({
[reactFlowInstance]
)
/**
* Handles combobox open state changes to trigger option fetching
*/
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
void fetchOptionsIfNeeded()
}
},
[fetchOptionsIfNeeded]
)
/**
* Gets the icon for the currently selected option
*/
@@ -463,75 +466,6 @@ export const ComboBox = memo(function ComboBox({
)
}, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon])
const ctrlOnChangeRef = useRef<
((e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const onDropRef = useRef<
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const onDragOverRef = useRef<
((e: React.DragEvent<HTMLTextAreaElement | HTMLInputElement>) => void) | null
>(null)
const inputRefFromController = useRef<HTMLInputElement | null>(null)
const comboboxOnChange = useCallback(
(newValue: string) => {
const matchedComboboxOption = comboboxOptions.find((option) => option.value === newValue)
if (matchedComboboxOption) {
setInputValue(matchedComboboxOption.label)
} else {
setInputValue(newValue)
}
// Use controller's handler so env vars, tags, and DnD still work
const syntheticEvent = {
target: { value: newValue, selectionStart: newValue.length },
} as React.ChangeEvent<HTMLInputElement>
ctrlOnChangeRef.current?.(syntheticEvent)
},
[comboboxOptions, setInputValue]
)
const comboboxInputProps = useMemo(
() => ({
onDrop: ((e: React.DragEvent<HTMLInputElement>) => {
onDropRef.current?.(e)
}) as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: ((e: React.DragEvent<HTMLInputElement>) => {
onDragOverRef.current?.(e)
}) as (e: React.DragEvent<HTMLInputElement>) => void,
onWheel: handleWheel,
autoComplete: 'off' as const,
}),
[handleWheel]
)
// Stable onChange for SubBlockInputController
const controllerOnChange = useCallback(
(newValue: string) => {
if (isPreview) {
return
}
const matchedOption = evaluatedOptions.find((option) => {
if (typeof option === 'string') {
return option === newValue
}
return option.id === newValue
})
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
},
[isPreview, evaluatedOptions, setStoreValue]
)
return (
<div className='relative w-full'>
<SubBlockInputController
@@ -539,43 +473,76 @@ export const ComboBox = memo(function ComboBox({
subBlockId={subBlockId}
config={config}
value={propValue}
onChange={controllerOnChange}
onChange={(newValue) => {
if (isPreview) {
return
}
const matchedOption = evaluatedOptions.find((option) => {
if (typeof option === 'string') {
return option === newValue
}
return option.id === newValue
})
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
}}
isPreview={isPreview}
disabled={disabled}
previewValue={previewValue}
>
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => {
// Update refs with latest handlers from render prop
ctrlOnChangeRef.current = ctrlOnChange
onDropRef.current = onDrop
onDragOverRef.current = onDragOver
// Store the input ref for passing to Combobox
if (ref.current) {
inputRefFromController.current = ref.current as HTMLInputElement
}
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => (
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={value ?? ''}
onChange={(newValue) => {
const matchedComboboxOption = comboboxOptions.find(
(option) => option.value === newValue
)
if (matchedComboboxOption) {
setInputValue(matchedComboboxOption.label)
} else {
setInputValue(newValue)
}
return (
<Combobox
options={comboboxOptions}
value={inputValue}
selectedValue={value ?? ''}
onChange={comboboxOnChange}
placeholder={placeholder}
disabled={disabled}
editable
overlayContent={overlayContent}
inputRef={ref as React.RefObject<HTMLInputElement>}
filterOptions
searchable={config.searchable}
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
inputProps={comboboxInputProps}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={handleOpenChange}
/>
)
}}
// Use controller's handler so env vars, tags, and DnD still work
const syntheticEvent = {
target: { value: newValue, selectionStart: newValue.length },
} as React.ChangeEvent<HTMLInputElement>
ctrlOnChange(syntheticEvent)
}}
placeholder={placeholder}
disabled={disabled}
editable
overlayContent={overlayContent}
inputRef={ref as React.RefObject<HTMLInputElement>}
filterOptions
searchable={config.searchable}
className={cn('allow-scroll overflow-x-auto', selectedOptionIcon && 'pl-[28px]')}
inputProps={{
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
onWheel: handleWheel,
autoComplete: 'off',
}}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
/>
)}
</SubBlockInputController>
</div>
)
})
}

View File

@@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { ChevronDown, ChevronUp, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import Editor from 'react-simple-code-editor'
import { useUpdateNodeInternals } from 'reactflow'
@@ -39,16 +39,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('ConditionInput')
/**
* Default height for router textareas in pixels
*/
const ROUTER_DEFAULT_HEIGHT_PX = 100
/**
* Minimum height for router textareas in pixels
*/
const ROUTER_MIN_HEIGHT_PX = 80
/**
* Represents a single conditional block (if/else if/else).
*/
@@ -753,61 +743,6 @@ export function ConditionInput({
}
}, [conditionalBlocks, isRouterMode])
// State for tracking individual router textarea heights
const [routerHeights, setRouterHeights] = useState<{ [key: string]: number }>({})
const isResizing = useRef(false)
/**
* Gets the height for a specific router block, returning default if not set.
*
* @param blockId - ID of the router block
* @returns Height in pixels
*/
const getRouterHeight = (blockId: string): number => {
return routerHeights[blockId] ?? ROUTER_DEFAULT_HEIGHT_PX
}
/**
* Handles mouse-based resize for router textareas.
*
* @param e - Mouse event from the resize handle
* @param blockId - ID of the block being resized
*/
const startRouterResize = (e: React.MouseEvent, blockId: string) => {
if (isPreview || disabled) return
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startY = e.clientY
const startHeight = getRouterHeight(blockId)
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(ROUTER_MIN_HEIGHT_PX, startHeight + deltaY)
// Update the textarea height directly for smooth resizing
const textarea = inputRefs.current.get(blockId)
if (textarea) {
textarea.style.height = `${newHeight}px`
}
// Update state to keep track
setRouterHeights((prev) => ({ ...prev, [blockId]: newHeight }))
}
const handleMouseUp = () => {
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// Show loading or empty state if not ready or no blocks
if (!isReady || conditionalBlocks.length === 0) {
return (
@@ -972,24 +907,10 @@ export function ConditionInput({
}}
placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview}
className='min-h-[100px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={4}
style={{ height: `${getRouterHeight(block.id)}px` }}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2}
/>
{/* Custom resize handle */}
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => startRouterResize(e, block.id)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}

View File

@@ -41,7 +41,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',

View File

@@ -15,7 +15,6 @@ interface DocumentSelectorProps {
onDocumentSelect?: (documentId: string) => void
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
export function DocumentSelector({
@@ -25,15 +24,9 @@ export function DocumentSelector({
onDocumentSelect,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentSelectorProps) {
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
isPreview,
previewContextValues,
})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const normalizedKnowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue

View File

@@ -37,7 +37,6 @@ interface DocumentTagEntryProps {
disabled?: boolean
isPreview?: boolean
previewValue?: any
previewContextValues?: Record<string, unknown>
}
/**
@@ -57,7 +56,6 @@ export function DocumentTagEntry({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: DocumentTagEntryProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -76,12 +74,8 @@ export function DocumentTagEntry({
disabled,
})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
@@ -137,16 +131,11 @@ export function DocumentTagEntry({
}
/**
* Removes a tag by ID, or resets it if it's the last one
* Removes a tag by ID (prevents removing the last tag)
*/
const removeTag = (id: string) => {
if (isReadOnly) return
if (tags.length === 1) {
// Reset the last tag instead of removing it
updateTags([createDefaultTag()])
} else {
updateTags(tags.filter((t) => t.id !== id))
}
if (isReadOnly || tags.length === 1) return
updateTags(tags.filter((t) => t.id !== id))
}
/**
@@ -233,7 +222,6 @@ export function DocumentTagEntry({
/**
* Renders the tag header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderTagHeader = (tag: DocumentTag, index: number) => (
<div
@@ -242,11 +230,9 @@ export function DocumentTagEntry({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
{tag.tagName || `Tag ${index + 1}`}
</span>
{tag.collapsed && tag.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>
)}
{tag.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button
@@ -261,7 +247,7 @@ export function DocumentTagEntry({
<Button
variant='ghost'
onClick={() => removeTag(tag.id)}
disabled={isReadOnly}
disabled={isReadOnly || tags.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -355,7 +341,7 @@ export function DocumentTagEntry({
const tagOptions: ComboboxOption[] = selectableTags.map((t) => ({
value: t.displayName,
label: t.displayName,
label: `${t.displayName} (${FIELD_TYPE_LABELS[t.fieldType] || 'Text'})`,
}))
return (

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
@@ -69,7 +68,7 @@ interface DropdownProps {
* - Special handling for dataMode subblock to convert between JSON and structured formats
* - Integrates with the workflow state management system
*/
export const Dropdown = memo(function Dropdown({
export function Dropdown({
options,
defaultValue,
blockId,
@@ -111,8 +110,7 @@ export const Dropdown = memo(function Dropdown({
)
},
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
),
isEqual
)
)
const [storeInitialized, setStoreInitialized] = useState(false)
@@ -163,18 +161,6 @@ export const Dropdown = memo(function Dropdown({
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
/**
* Handles combobox open state changes to trigger option fetching
*/
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) {
void fetchOptionsIfNeeded()
}
},
[fetchOptionsIfNeeded]
)
const evaluatedOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
@@ -485,7 +471,11 @@ export const Dropdown = memo(function Dropdown({
placeholder={placeholder}
disabled={disabled}
editable={false}
onOpenChange={handleOpenChange}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
overlayContent={multiSelectOverlay}
multiSelect={multiSelect}
isLoading={isLoadingOptions}
@@ -494,4 +484,4 @@ export const Dropdown = memo(function Dropdown({
searchPlaceholder='Search...'
/>
)
})
}

View File

@@ -40,7 +40,6 @@ interface KnowledgeTagFiltersProps {
disabled?: boolean
isPreview?: boolean
previewValue?: string | null
previewContextValues?: Record<string, unknown>
}
/**
@@ -61,19 +60,14 @@ export function KnowledgeTagFilters({
disabled = false,
isPreview = false,
previewValue,
previewContextValues,
}: KnowledgeTagFiltersProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
const emitTagSelection = useTagSelection(blockId, subBlock.id)
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
const [knowledgeBaseIdFromStore] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseIdValue = previewContextValues?.knowledgeBaseId ?? knowledgeBaseIdFromStore
const knowledgeBaseId =
typeof knowledgeBaseIdValue === 'string' && knowledgeBaseIdValue.trim().length > 0
? knowledgeBaseIdValue
: null
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
const knowledgeBaseId = knowledgeBaseIdValue || null
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
@@ -129,16 +123,11 @@ export function KnowledgeTagFilters({
}
/**
* Removes a filter by ID, or resets it if it's the last one
* Removes a filter by ID (prevents removing the last filter)
*/
const removeFilter = (id: string) => {
if (isReadOnly) return
if (filters.length === 1) {
// Reset the last filter instead of removing it
updateFilters([createDefaultFilter()])
} else {
updateFilters(filters.filter((f) => f.id !== id))
}
if (isReadOnly || filters.length === 1) return
updateFilters(filters.filter((f) => f.id !== id))
}
/**
@@ -226,7 +215,6 @@ export function KnowledgeTagFilters({
/**
* Renders the filter header with name, badge, and action buttons
* Shows tag name only when collapsed (as summary), generic label when expanded
*/
const renderFilterHeader = (filter: TagFilter, index: number) => (
<div
@@ -235,11 +223,9 @@ export function KnowledgeTagFilters({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
{filter.tagName || `Filter ${index + 1}`}
</span>
{filter.collapsed && filter.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>
)}
{filter.tagName && <Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge>}
</div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={addFilter} disabled={isReadOnly} className='h-auto p-0'>
@@ -249,7 +235,7 @@ export function KnowledgeTagFilters({
<Button
variant='ghost'
onClick={() => removeFilter(filter.id)}
disabled={isReadOnly}
disabled={isReadOnly || filters.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
@@ -338,7 +324,7 @@ export function KnowledgeTagFilters({
const renderFilterContent = (filter: TagFilter) => {
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
value: tag.displayName,
label: tag.displayName,
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
}))
const operators = getOperatorsForFieldType(filter.fieldType)

View File

@@ -234,45 +234,48 @@ export function LongInput({
}, [value])
// Handle resize functionality
const startResize = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
isResizing.current = true
const startY = e.clientY
const startHeight = height
const startY = e.clientY
const startHeight = height
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!isResizing.current) return
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
const deltaY = moveEvent.clientY - startY
const newHeight = Math.max(MIN_HEIGHT_PX, startHeight + deltaY)
if (textareaRef.current && overlayRef.current) {
textareaRef.current.style.height = `${newHeight}px`
overlayRef.current.style.height = `${newHeight}px`
}
if (containerRef.current) {
containerRef.current.style.height = `${newHeight}px`
}
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
setHeight(newHeight)
}
const handleMouseUp = () => {
if (textareaRef.current) {
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
setHeight(finalHeight)
if (textareaRef.current && overlayRef.current) {
textareaRef.current.style.height = `${newHeight}px`
overlayRef.current.style.height = `${newHeight}px`
}
if (containerRef.current) {
containerRef.current.style.height = `${newHeight}px`
}
// Keep React state in sync so parent layouts (e.g., Editor) update during drag
setHeight(newHeight)
}
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const handleMouseUp = () => {
if (textareaRef.current) {
const finalHeight = Number.parseInt(textareaRef.current.style.height, 10) || height
setHeight(finalHeight)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
isResizing.current = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[height]
)
// Expose wand control handlers to parent via ref
useImperativeHandle(

View File

@@ -1,17 +1,281 @@
import { useCallback, useMemo } from 'react'
import type { RefObject } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Combobox, Label, Slider, Switch } from '@/components/emcn/components'
import { Combobox, Input, Label, Slider, Switch, Textarea } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { formatParameterLabel } from '@/tools/params'
const logger = createLogger('McpDynamicArgs')
interface McpInputWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
isPassword?: boolean
blockId: string
accessiblePrefixes?: Set<string>
}
function McpInputWithTags({
value,
onChange,
placeholder,
disabled,
isPassword,
blockId,
accessiblePrefixes,
}: McpInputWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputNameRef = useRef(`mcp_input_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = inputRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<div className='relative'>
<Input
ref={inputRef}
type={isPassword ? 'password' : 'text'}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
placeholder={placeholder}
disabled={disabled}
name={inputNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
readOnly
onFocus={(e) => {
e.currentTarget.removeAttribute('readOnly')
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
className={cn(!isPassword && 'text-transparent caret-foreground')}
/>
{!isPassword && (
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value?.toString() || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
)}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={inputRef as RefObject<HTMLInputElement>}
/>
</div>
)
}
interface McpTextareaWithTagsProps {
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
blockId: string
accessiblePrefixes?: Set<string>
rows?: number
}
function McpTextareaWithTags({
value,
onChange,
placeholder,
disabled,
blockId,
accessiblePrefixes,
rows = 4,
}: McpTextareaWithTagsProps) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const textareaNameRef = useRef(`mcp_textarea_${Math.random()}`)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
onChange(newValue)
setCursorPosition(newCursorPosition)
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const dropPosition = textareaRef.current?.selectionStart ?? value.length ?? 0
const currentValue = value ?? ''
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
} catch (error) {
logger.error('Failed to parse drop data:', { error })
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
return (
<div className='relative'>
<Textarea
ref={textareaRef}
value={value || ''}
onChange={handleChange}
onDrop={handleDrop}
onDragOver={handleDragOver}
onFocus={() => {
// Show tag dropdown on focus when input is empty
if (!disabled && (value?.trim() === '' || !value)) {
setShowTags(true)
setCursorPosition(0)
}
}}
placeholder={placeholder}
disabled={disabled}
rows={rows}
name={textareaNameRef.current}
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
data-lpignore='true'
data-1p-ignore
className={cn('min-h-[80px] resize-none text-transparent caret-foreground')}
/>
<div className='pointer-events-none absolute inset-0 overflow-auto whitespace-pre-wrap break-words px-[8px] py-[8px] font-medium font-sans text-sm'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
inputRef={textareaRef as RefObject<HTMLTextAreaElement>}
/>
</div>
)
}
interface McpDynamicArgsProps {
blockId: string
subBlockId: string
@@ -20,27 +284,6 @@ interface McpDynamicArgsProps {
previewValue?: any
}
/**
* Creates a minimal SubBlockConfig for MCP tool parameters
*/
function createParamConfig(
paramName: string,
paramSchema: any,
inputType: 'long-input' | 'short-input'
): SubBlockConfig {
const placeholder =
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}`
return {
id: paramName,
type: inputType,
title: formatParameterLabel(paramName),
placeholder,
}
}
export function McpDynamicArgs({
blockId,
subBlockId,
@@ -54,6 +297,7 @@ export function McpDynamicArgs({
const [selectedTool] = useSubBlockValue(blockId, 'tool')
const [cachedSchema] = useSubBlockValue(blockId, '_toolSchema')
const [toolArgs, setToolArgs] = useSubBlockValue(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const selectedToolConfig = mcpTools.find((tool) => tool.id === selectedTool)
const toolSchema = cachedSchema || selectedToolConfig?.inputSchema
@@ -64,7 +308,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(previewValue)
} catch (error) {
logger.warn('Failed to parse preview value as JSON:', { error })
console.warn('Failed to parse preview value as JSON:', error)
return previewValue
}
}
@@ -74,7 +318,7 @@ export function McpDynamicArgs({
try {
return JSON.parse(toolArgs)
} catch (error) {
logger.warn('Failed to parse toolArgs as JSON:', { error })
console.warn('Failed to parse toolArgs as JSON:', error)
return {}
}
}
@@ -216,23 +460,24 @@ export function McpDynamicArgs({
)
}
case 'long-input': {
const config = createParamConfig(paramName, paramSchema, 'long-input')
case 'long-input':
return (
<LongInput
<McpTextareaWithTags
key={`${paramName}-long`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
rows={4}
value={value || ''}
onChange={(newValue) => updateParameter(paramName, newValue)}
isPreview={isPreview}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
rows={4}
/>
)
}
default: {
const isPassword =
@@ -240,16 +485,10 @@ export function McpDynamicArgs({
paramName.toLowerCase().includes('password') ||
paramName.toLowerCase().includes('token')
const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer'
const config = createParamConfig(paramName, paramSchema, 'short-input')
return (
<ShortInput
<McpInputWithTags
key={`${paramName}-short`}
blockId={blockId}
subBlockId={`_mcp_${paramName}`}
config={config}
placeholder={config.placeholder}
password={isPassword}
value={value?.toString() || ''}
onChange={(newValue) => {
let processedValue: any = newValue
@@ -267,8 +506,16 @@ export function McpDynamicArgs({
}
updateParameter(paramName, processedValue)
}}
isPreview={isPreview}
placeholder={
paramSchema.type === 'array'
? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values`
: paramSchema.description ||
`Enter ${formatParameterLabel(paramName).toLowerCase()}`
}
disabled={disabled}
isPassword={isPassword}
blockId={blockId}
accessiblePrefixes={accessiblePrefixes}
/>
)
}
@@ -331,40 +578,26 @@ export function McpDynamicArgs({
tabIndex={-1}
readOnly
/>
<div>
<div className='space-y-4'>
{toolSchema.properties &&
Object.entries(toolSchema.properties).map(([paramName, paramSchema], index, entries) => {
Object.entries(toolSchema.properties).map(([paramName, paramSchema]) => {
const inputType = getInputType(paramSchema as any)
const showLabel = inputType !== 'switch'
const showDivider = index < entries.length - 1
return (
<div key={paramName} className='subblock-row'>
<div className='subblock-content flex flex-col gap-[10px]'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
{showDivider && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
<div key={paramName} className='space-y-2'>
{showLabel && (
<Label
className={cn(
'font-medium text-sm',
toolSchema.required?.includes(paramName) &&
'after:ml-1 after:text-red-500 after:content-["*"]'
)}
>
{formatParameterLabel(paramName)}
</Label>
)}
{renderParameterInput(paramName, paramSchema as any)}
</div>
)
})}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
@@ -226,18 +225,14 @@ export function MessagesInput({
[wandHook]
)
const localMessagesRef = useRef(localMessages)
localMessagesRef.current = localMessages
/**
* Initialize local state from stored or preview value
*/
useEffect(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) {
if (!isEqual(localMessagesRef.current, previewValue)) {
setLocalMessages(previewValue)
}
setLocalMessages(previewValue)
} else if (messages && Array.isArray(messages) && messages.length > 0) {
if (!isEqual(localMessagesRef.current, messages)) {
setLocalMessages(messages)
}
setLocalMessages(messages)
}
}, [isPreview, previewValue, messages])

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { Check, Copy, Wand2 } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import { Input } from '@/components/emcn'
@@ -62,7 +62,7 @@ interface ShortInputProps {
* - Copy to clipboard functionality
* - Integrates with ReactFlow for zoom control
*/
export const ShortInput = memo(function ShortInput({
export function ShortInput({
blockId,
subBlockId,
placeholder,
@@ -445,4 +445,4 @@ export const ShortInput = memo(function ShortInput({
</div>
</>
)
})
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useRef } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
@@ -81,8 +81,6 @@ const createDefaultField = (): Field => ({
*/
const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\]/g, '').trim()
const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json')
export function FieldFormat({
blockId,
subBlockId,
@@ -140,50 +138,17 @@ export function FieldFormat({
setStoreValue(fields.filter((field) => field.id !== id))
}
const storeValueRef = useRef(storeValue)
storeValueRef.current = storeValue
/**
* Updates a specific field property
*/
const updateField = (id: string, field: keyof Field, value: any) => {
if (isReadOnly) return
const isReadOnlyRef = useRef(isReadOnly)
isReadOnlyRef.current = isReadOnly
const updatedValue =
field === 'name' && typeof value === 'string' ? validateFieldName(value) : value
const setStoreValueRef = useRef(setStoreValue)
setStoreValueRef.current = setStoreValue
const updateField = useCallback(
(id: string, fieldKey: keyof Field, fieldValue: Field[keyof Field]) => {
if (isReadOnlyRef.current) return
const updatedValue =
fieldKey === 'name' && typeof fieldValue === 'string'
? validateFieldName(fieldValue)
: fieldValue
const currentStoreValue = storeValueRef.current
const currentFields: Field[] =
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
? currentStoreValue
: [createDefaultField()]
setStoreValueRef.current(
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
)
},
[]
)
const editorValueChangeHandlersRef = useRef<Record<string, (newValue: string) => void>>({})
const getEditorValueChangeHandler = useCallback(
(fieldId: string): ((newValue: string) => void) => {
if (!editorValueChangeHandlersRef.current[fieldId]) {
editorValueChangeHandlersRef.current[fieldId] = (newValue: string) => {
updateField(fieldId, 'value', newValue)
}
}
return editorValueChangeHandlersRef.current[fieldId]
},
[updateField]
)
setStoreValue(fields.map((f) => (f.id === id ? { ...f, [field]: updatedValue } : f)))
}
/**
* Toggles the collapsed state of a field
@@ -257,14 +222,15 @@ export function FieldFormat({
placeholder={placeholder}
disabled={isReadOnly}
autoComplete='off'
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
style={{ overflowX: 'auto' }}
/>
<div
ref={(el) => {
if (el) nameOverlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ scrollbarWidth: 'none' }}
style={{ overflowX: 'auto' }}
>
<div
className='w-full whitespace-pre'
@@ -393,8 +359,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -428,8 +398,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -465,8 +439,12 @@ export function FieldFormat({
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
onValueChange={(newValue) => {
if (!isReadOnly) {
updateField(field.id, 'value', newValue)
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
@@ -498,14 +476,15 @@ export function FieldFormat({
placeholder={valuePlaceholder}
disabled={isReadOnly}
autoComplete='off'
className={cn('allow-scroll w-full overflow-x-auto overflow-y-hidden', inputClassName)}
className={cn('allow-scroll w-full overflow-auto', inputClassName)}
style={{ overflowX: 'auto' }}
/>
<div
ref={(el) => {
if (el) overlayRefs.current[field.id] = el
}}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm'
style={{ scrollbarWidth: 'none' }}
style={{ overflowX: 'auto' }}
>
<div
className='w-full whitespace-pre'

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react'
import { usePopoverContext } from '@/components/emcn'
import { useNestedNavigation } from '../tag-dropdown'
import type { BlockTagGroup, NestedBlockTagGroup, NestedTag } from '../types'
import type { BlockTagGroup, NestedBlockTagGroup } from '../types'
/**
* Keyboard navigation handler component that uses popover context
@@ -16,90 +15,6 @@ interface KeyboardNavigationHandlerProps {
handleTagSelect: (tag: string, group?: BlockTagGroup) => void
}
/**
* Recursively finds a folder in nested tags by its ID
*/
const findFolderInNested = (
nestedTags: NestedTag[],
blockId: string,
targetFolderId: string
): NestedTag | null => {
for (const nestedTag of nestedTags) {
const folderId = `${blockId}-${nestedTag.key}`
if (folderId === targetFolderId) {
return nestedTag
}
if (nestedTag.nestedChildren) {
const found = findFolderInNested(nestedTag.nestedChildren, blockId, targetFolderId)
if (found) return found
}
}
return null
}
/**
* Recursively finds folder info for a tag that can be expanded.
* Returns both the folder metadata and the NestedTag object for navigation.
*/
const findFolderInfoForTag = (
nestedTags: NestedTag[],
targetTag: string,
group: NestedBlockTagGroup
): {
id: string
title: string
parentTag: string
group: NestedBlockTagGroup
nestedTag: NestedTag
} | null => {
for (const nestedTag of nestedTags) {
if (
nestedTag.parentTag === targetTag &&
(nestedTag.children?.length || nestedTag.nestedChildren?.length)
) {
return {
id: `${group.blockId}-${nestedTag.key}`,
title: nestedTag.display,
parentTag: nestedTag.parentTag,
group,
nestedTag,
}
}
if (nestedTag.nestedChildren) {
const found = findFolderInfoForTag(nestedTag.nestedChildren, targetTag, group)
if (found) return found
}
}
return null
}
/**
* Recursively checks if a tag is a child of any folder.
* This includes both leaf children and nested folder parent tags.
*/
const isChildOfAnyFolder = (nestedTags: NestedTag[], tag: string): boolean => {
for (const nestedTag of nestedTags) {
if (nestedTag.children) {
for (const child of nestedTag.children) {
if (child.fullTag === tag) {
return true
}
}
}
if (nestedTag.nestedChildren) {
for (const nestedChild of nestedTag.nestedChildren) {
if (nestedChild.parentTag === tag) {
return true
}
}
if (isChildOfAnyFolder(nestedTag.nestedChildren, tag)) {
return true
}
}
}
return false
}
export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps> = ({
visible,
selectedIndex,
@@ -108,66 +23,55 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
nestedBlockTagGroups,
handleTagSelect,
}) => {
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
const nestedNav = useNestedNavigation()
const { openFolder, closeFolder, isInFolder, currentFolder } = usePopoverContext()
const visibleIndices = useMemo(() => {
const indices: number[] = []
const nestedPath = nestedNav?.nestedPath ?? []
if (isInFolder && currentFolder) {
let currentNestedTag: NestedTag | null = null
if (nestedPath.length > 0) {
currentNestedTag = nestedPath[nestedPath.length - 1]
} else {
for (const group of nestedBlockTagGroups) {
const folder = findFolderInNested(group.nestedTags, group.blockId, currentFolder)
if (folder) {
currentNestedTag = folder
break
}
}
}
if (currentNestedTag) {
if (currentNestedTag.parentTag) {
const parentIdx = flatTagList.findIndex(
(item) => item.tag === currentNestedTag!.parentTag
)
if (parentIdx >= 0) {
indices.push(parentIdx)
}
}
if (currentNestedTag.children) {
for (const child of currentNestedTag.children) {
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
if (idx >= 0) {
indices.push(idx)
for (const group of nestedBlockTagGroups) {
for (const nestedTag of group.nestedTags) {
const folderId = `${group.blockId}-${nestedTag.key}`
if (folderId === currentFolder && nestedTag.children) {
// First, add the parent tag itself (so it's navigable as the first item)
if (nestedTag.parentTag) {
const parentIdx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
if (parentIdx >= 0) {
indices.push(parentIdx)
}
}
}
}
if (currentNestedTag.nestedChildren) {
for (const nestedChild of currentNestedTag.nestedChildren) {
if (nestedChild.parentTag) {
const idx = flatTagList.findIndex((item) => item.tag === nestedChild.parentTag)
// Then add all children
for (const child of nestedTag.children) {
const idx = flatTagList.findIndex((item) => item.tag === child.fullTag)
if (idx >= 0) {
indices.push(idx)
}
}
break
}
}
}
} else {
// We're at root level, show all non-child items
// (variables and parent tags, but not their children)
for (let i = 0; i < flatTagList.length; i++) {
const tag = flatTagList[i].tag
// Check if this is a child of a parent folder
let isChild = false
for (const group of nestedBlockTagGroups) {
if (isChildOfAnyFolder(group.nestedTags, tag)) {
isChild = true
break
for (const nestedTag of group.nestedTags) {
if (nestedTag.children) {
for (const child of nestedTag.children) {
if (child.fullTag === tag) {
isChild = true
break
}
}
}
if (isChild) break
}
if (isChild) break
}
if (!isChild) {
@@ -177,16 +81,16 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
}
return indices
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups, nestedNav])
const nestedPathLength = nestedNav?.nestedPath.length ?? 0
}, [isInFolder, currentFolder, flatTagList, nestedBlockTagGroups])
// Auto-select first visible item when entering/exiting folders
useEffect(() => {
if (!visible || visibleIndices.length === 0) return
setSelectedIndex(visibleIndices[0])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, isInFolder, currentFolder, nestedPathLength])
if (!visibleIndices.includes(selectedIndex)) {
setSelectedIndex(visibleIndices[0])
}
}, [visible, isInFolder, currentFolder, visibleIndices, selectedIndex, setSelectedIndex])
useEffect(() => {
if (!visible || !flatTagList.length) return
@@ -213,98 +117,89 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
id: string
title: string
parentTag: string
group: NestedBlockTagGroup
nestedTag: NestedTag
group: BlockTagGroup
} | null = null
if (selected) {
for (const group of nestedBlockTagGroups) {
const folderInfo = findFolderInfoForTag(group.nestedTags, selected.tag, group)
if (folderInfo) {
currentFolderInfo = folderInfo
break
for (const nestedTag of group.nestedTags) {
if (
nestedTag.parentTag === selected.tag &&
nestedTag.children &&
nestedTag.children.length > 0
) {
currentFolderInfo = {
id: `${selected.group?.blockId}-${nestedTag.key}`,
title: nestedTag.display,
parentTag: nestedTag.parentTag,
group,
}
break
}
}
if (currentFolderInfo) break
}
}
const scrollIntoView = () => {
setTimeout(() => {
const selectedItem = document.querySelector<HTMLElement>(
'[data-radix-popper-content-wrapper] [aria-selected="true"]'
)
if (selectedItem) {
selectedItem.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}, 0)
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
setKeyboardNav(true)
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
let newIndex: number
if (currentVisibleIndex === -1) {
newIndex = visibleIndices[0]
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex < visibleIndices.length - 1) {
newIndex = visibleIndices[currentVisibleIndex + 1]
} else {
newIndex = visibleIndices[0]
setSelectedIndex(visibleIndices[currentVisibleIndex + 1])
}
setSelectedIndex(newIndex)
scrollIntoView()
}
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
setKeyboardNav(true)
if (visibleIndices.length > 0) {
const currentVisibleIndex = visibleIndices.indexOf(selectedIndex)
let newIndex: number
if (currentVisibleIndex === -1) {
newIndex = visibleIndices[visibleIndices.length - 1]
setSelectedIndex(visibleIndices[0])
} else if (currentVisibleIndex > 0) {
newIndex = visibleIndices[currentVisibleIndex - 1]
} else {
newIndex = visibleIndices[visibleIndices.length - 1]
setSelectedIndex(visibleIndices[currentVisibleIndex - 1])
}
setSelectedIndex(newIndex)
scrollIntoView()
}
break
case 'Enter':
e.preventDefault()
e.stopPropagation()
if (selected && selectedIndex >= 0 && selectedIndex < flatTagList.length) {
handleTagSelect(selected.tag, selected.group)
}
break
case 'ArrowRight':
if (currentFolderInfo) {
e.preventDefault()
e.stopPropagation()
if (isInFolder && nestedNav) {
nestedNav.navigateIn(currentFolderInfo.nestedTag, currentFolderInfo.group)
} else {
if (currentFolderInfo && !isInFolder) {
// It's a folder, open it
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
} else {
// Not a folder, select it
handleTagSelect(selected.tag, selected.group)
}
}
break
case 'ArrowRight':
if (currentFolderInfo && !isInFolder) {
e.preventDefault()
e.stopPropagation()
openFolderWithSelection(
currentFolderInfo.id,
currentFolderInfo.title,
currentFolderInfo.parentTag,
currentFolderInfo.group
)
}
break
case 'ArrowLeft':
if (isInFolder) {
e.preventDefault()
e.stopPropagation()
if (nestedNav?.navigateBack()) {
return
}
closeFolder()
let firstRootIndex = 0
for (let i = 0; i < flatTagList.length; i++) {
@@ -344,8 +239,6 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
isInFolder,
setSelectedIndex,
handleTagSelect,
nestedNav,
setKeyboardNav,
])
return null

View File

@@ -10,27 +10,14 @@ export interface BlockTagGroup {
}
/**
* Child tag within a nested structure
*/
export interface NestedTagChild {
key: string
display: string
fullTag: string
}
/**
* Nested tag structure for hierarchical display.
* Supports recursive nesting for deeply nested object structures.
* Nested tag structure for hierarchical display
*/
export interface NestedTag {
key: string
display: string
fullTag?: string
parentTag?: string
/** Leaf children (no further nesting) */
children?: NestedTagChild[]
/** Recursively nested folders */
nestedChildren?: NestedTag[]
parentTag?: string // Tag for the parent object when it has children
children?: Array<{ key: string; display: string; fullTag: string }>
}
/**

View File

@@ -1,5 +1,5 @@
import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
@@ -35,7 +35,6 @@ import {
Code,
FileSelectorInput,
FileUpload,
FolderSelectorInput,
LongInput,
ProjectSelectorInput,
SheetSelectorInput,
@@ -46,9 +45,7 @@ import {
TimeInput,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components'
import { DocumentSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-selector/document-selector'
import { DocumentTagEntry } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry'
import { KnowledgeBaseSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector'
import { KnowledgeTagFilters } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters'
import {
type CustomTool,
CustomToolModal,
@@ -78,13 +75,6 @@ import {
isPasswordParameter,
type ToolParameterConfig,
} from '@/tools/params'
import {
buildCanonicalIndex,
buildPreviewContextValues,
type CanonicalIndex,
evaluateSubBlockCondition,
type SubBlockCondition,
} from '@/tools/params-resolver'
const logger = createLogger('ToolInput')
@@ -314,42 +304,6 @@ function SheetSelectorSyncWrapper({
)
}
function FolderSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<FolderSelectorInput
blockId={blockId}
subBlock={{
id: paramId,
type: 'folder-selector' as const,
title: paramId,
serviceId: uiComponent.serviceId,
requiredScopes: uiComponent.requiredScopes || [],
placeholder: uiComponent.placeholder,
dependsOn: uiComponent.dependsOn,
}}
disabled={disabled}
/>
</GenericSyncWrapper>
)
}
function KnowledgeBaseSelectorSyncWrapper({
blockId,
paramId,
@@ -388,7 +342,6 @@ function DocumentSelectorSyncWrapper({
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
@@ -396,7 +349,6 @@ function DocumentSelectorSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -409,67 +361,6 @@ function DocumentSelectorSyncWrapper({
dependsOn: ['knowledgeBaseId'],
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function DocumentTagEntrySyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<DocumentTagEntry
blockId={blockId}
subBlock={{
id: paramId,
type: 'document-tag-entry',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
}
function KnowledgeTagFiltersSyncWrapper({
blockId,
paramId,
value,
onChange,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<KnowledgeTagFilters
blockId={blockId}
subBlock={{
id: paramId,
type: 'knowledge-tag-filters',
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
@@ -606,15 +497,11 @@ function CheckboxListSyncWrapper({
}
function ComboboxSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
disabled,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -625,15 +512,13 @@ function ComboboxSyncWrapper({
)
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select option'}
disabled={disabled}
/>
)
}
@@ -712,8 +597,6 @@ function SlackSelectorSyncWrapper({
}
function WorkflowSelectorSyncWrapper({
blockId,
paramId,
value,
onChange,
uiComponent,
@@ -721,8 +604,6 @@ function WorkflowSelectorSyncWrapper({
workspaceId,
currentWorkflowId,
}: {
blockId: string
paramId: string
value: string
onChange: (value: string) => void
uiComponent: any
@@ -742,17 +623,15 @@ function WorkflowSelectorSyncWrapper({
}))
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
</GenericSyncWrapper>
<Combobox
options={options}
value={value}
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
)
}
@@ -998,7 +877,7 @@ function createToolIcon(bgColor: string, IconComponent: any) {
* - Allows drag-and-drop reordering of selected tools
* - Supports tool usage control (auto/force/none) for compatible LLM providers
*/
export const ToolInput = memo(function ToolInput({
export function ToolInput({
blockId,
subBlockId,
isPreview = false,
@@ -1913,13 +1792,57 @@ export const ToolInput = memo(function ToolInput({
return toolParams?.toolConfig?.oauth
}
/**
* Evaluates parameter conditions to determine if a parameter should be visible.
*
* @remarks
* Supports field value matching with arrays, negation via `not`, and
* compound conditions via `and`. Used for conditional parameter visibility.
*
* @param param - The parameter configuration with optional condition
* @param tool - The current tool instance with its parameter values
* @returns `true` if the parameter should be shown based on its condition
*/
const evaluateParameterCondition = (param: any, tool: StoredTool): boolean => {
if (!('uiComponent' in param) || !param.uiComponent?.condition) return true
const currentValues: Record<string, any> = { operation: tool.operation, ...tool.params }
return evaluateSubBlockCondition(
param.uiComponent.condition as SubBlockCondition,
currentValues
)
const condition = param.uiComponent.condition
const currentValues: Record<string, any> = {
operation: tool.operation,
...tool.params,
}
const fieldValue = currentValues[condition.field]
let result = false
if (Array.isArray(condition.value)) {
result = condition.value.includes(fieldValue)
} else {
result = fieldValue === condition.value
}
if (condition.not) {
result = !result
}
if (condition.and) {
const andFieldValue = currentValues[condition.and.field]
let andResult = false
if (Array.isArray(condition.and.value)) {
andResult = condition.and.value.includes(andFieldValue)
} else {
andResult = andFieldValue === condition.and.value
}
if (condition.and.not) {
andResult = !andResult
}
result = result && andResult
}
return result
}
/**
@@ -2038,7 +1961,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
selectorType='channel-selector'
/>
)
@@ -2052,7 +1975,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
selectorType='user-selector'
/>
)
@@ -2069,11 +1992,10 @@ export const ToolInput = memo(function ToolInput({
placeholder: uiComponent.placeholder,
requiredScopes: uiComponent.requiredScopes,
dependsOn: uiComponent.dependsOn,
canonicalParamId: uiComponent.canonicalParamId ?? param.id,
}}
onProjectSelect={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2098,7 +2020,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2111,20 +2033,7 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'folder-selector':
return (
<FolderSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
previewContextValues={currentToolParams as any}
/>
)
@@ -2143,8 +2052,6 @@ export const ToolInput = memo(function ToolInput({
case 'combobox':
return (
<ComboboxSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2203,8 +2110,6 @@ export const ToolInput = memo(function ToolInput({
case 'workflow-selector':
return (
<WorkflowSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
@@ -2262,31 +2167,6 @@ export const ToolInput = memo(function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'document-tag-entry':
return (
<DocumentTagEntrySyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
case 'knowledge-tag-filters':
return (
<KnowledgeTagFiltersSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
disabled={disabled}
previewContextValues={currentToolParams}
/>
)
@@ -2345,27 +2225,9 @@ export const ToolInput = memo(function ToolInput({
// Get tool parameters using the new utility with block type for UI components
const toolParams =
!isCustomTool && !isMcpTool && currentToolId
? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation,
...tool.params,
})
? getToolParametersConfig(currentToolId, tool.type)
: null
// Build canonical index for proper dependency resolution
const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks
? buildCanonicalIndex(toolBlock.subBlocks)
: null
// Build preview context with canonical resolution
const toolContextValues = toolCanonicalIndex
? buildPreviewContextValues(tool.params || {}, {
blockType: tool.type,
subBlocks: toolBlock!.subBlocks,
canonicalIndex: toolCanonicalIndex,
values: { operation: tool.operation, ...tool.params },
})
: tool.params || {}
// For custom tools, resolve from reference (new format) or use inline (legacy)
const resolvedCustomTool = isCustomTool
? resolveCustomToolFromReference(tool, customTools)
@@ -2728,7 +2590,7 @@ export const ToolInput = memo(function ToolInput({
{param.required && param.visibility === 'user-only' && (
<span className='ml-1'>*</span>
)}
{param.visibility === 'user-or-llm' && (
{(!param.required || param.visibility !== 'user-only') && (
<span className='ml-[6px] text-[12px] text-[var(--text-tertiary)]'>
(optional)
</span>
@@ -2741,7 +2603,7 @@ export const ToolInput = memo(function ToolInput({
tool.params?.[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex,
toolContextValues as Record<string, string>
tool.params || {}
)
) : (
<ShortInput
@@ -2820,4 +2682,4 @@ export const ToolInput = memo(function ToolInput({
/>
</div>
)
})
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useCallback, useMemo } from 'react'
import { isEqual } from 'lodash'
import { useMemo } from 'react'
import {
buildCanonicalIndex,
isNonEmptyValue,
@@ -98,60 +97,47 @@ export function useDependsOnGate(
return rawValue
}
const dependencySelector = useCallback(
(state: ReturnType<typeof useSubBlockStore.getState>) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
// Get values for all dependency fields (both all and any)
const dependencyValuesMap = useSubBlockStore((state) => {
if (allDependsOnFields.length === 0) return {} as Record<string, unknown>
// If previewContextValues are provided (e.g., tool parameters), use those first
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
}
if (!activeWorkflowId) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = null
}
return map
}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
// If previewContextValues are provided (e.g., tool parameters), use those first
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
blockValues,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
},
[
allDependsOnFields,
previewContextValues,
activeWorkflowId,
blockId,
canonicalIndex,
canonicalModeOverrides,
]
)
}
// Get values for all dependency fields (both all and any)
// Use isEqual to prevent re-renders when dependency values haven't actually changed
const dependencyValuesMap = useSubBlockStore(dependencySelector, isEqual)
if (!activeWorkflowId) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = null
}
return map
}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
const resolvedValue = resolveDependencyValue(
key,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
})
const depsSatisfied = useMemo(() => {
// Check all fields (AND logic) - all must be satisfied

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash'
import { useShallow } from 'zustand/react/shallow'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { getProviderFromModel } from '@/providers/utils'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -52,12 +51,16 @@ export function useSubBlockValue<T = any>(
)
)
// Keep a ref to the latest value to prevent unnecessary re-renders
const valueRef = useRef<T | null>(null)
// Streaming refs
const lastEmittedValueRef = useRef<T | null>(null)
const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store, keyed by active workflow id
// Optimized: use shallow equality comparison to prevent re-renders when other fields change
const storeValue = useSubBlockStore(
useCallback(
(state) => {
@@ -67,17 +70,11 @@ export function useSubBlockValue<T = any>(
},
[activeWorkflowId, blockId, subBlockId]
),
(a, b) => isEqual(a, b)
(a, b) => isEqual(a, b) // Use deep equality to prevent re-renders for same values
)
// Check if we're in diff mode and get diff value if available
const { isShowingDiff, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
useShallow((state) => ({
isShowingDiff: state.isShowingDiff,
hasActiveDiff: state.hasActiveDiff,
baselineWorkflow: state.baselineWorkflow,
}))
)
const { isShowingDiff, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
const isBaselineView = hasActiveDiff && !isShowingDiff
const snapshotSubBlock =
isBaselineView && baselineWorkflow
@@ -104,7 +101,7 @@ export function useSubBlockValue<T = any>(
// Compute the modelValue based on block type
const modelValue = isProviderBasedBlock ? (modelSubBlockValue as string) : null
// Emit the value to socket/DB and update local store
// Emit the value to socket/DB
const emitValue = useCallback(
(value: T) => {
collaborativeSetSubblockValue(blockId, subBlockId, value)
@@ -158,6 +155,20 @@ export function useSubBlockValue<T = any>(
return
}
// Update local store immediately for UI responsiveness (non-streaming)
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[currentActiveWorkflowId]: {
...state.workflowValues[currentActiveWorkflowId],
[blockId]: {
...state.workflowValues[currentActiveWorkflowId]?.[blockId],
[subBlockId]: newValue,
},
},
},
}))
// Handle model changes for provider-based blocks - clear API key when provider changes (non-streaming)
if (
subBlockId === 'model' &&
@@ -195,8 +206,6 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isBaselineView,
collaborativeSetSubblockValue,
isProviderBasedBlock,
]
)

View File

@@ -1,5 +1,4 @@
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
@@ -169,8 +168,6 @@ const getPreviewValue = (
* @param isValidJson - Whether the JSON content is valid (for code blocks)
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
* @param wandState - Optional state and handlers for the AI wand feature
* @param canonicalToggle - Optional canonical toggle metadata and handlers
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
* @returns The label JSX element, or `null` for switch types or when no title is defined
*/
const renderLabel = (
@@ -195,8 +192,7 @@ const renderLabel = (
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
},
canonicalToggleIsDisabled?: boolean
}
): JSX.Element | null => {
if (config.type === 'switch') return null
if (!config.title) return null
@@ -204,28 +200,28 @@ const renderLabel = (
const required = isFieldRequired(config, subBlockValues)
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
const canonicalToggleDisabledResolved = canonicalToggleIsDisabled ?? canonicalToggle?.disabled
const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px] whitespace-nowrap'>
{config.title}
{required && <span className='ml-0.5'>*</span>}
{config.type === 'code' &&
config.language === 'json' &&
!isValidJson &&
!wandState?.isStreaming && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{config.type === 'code' && config.language === 'json' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<AlertTriangle
className={cn(
'h-4 w-4 cursor-pointer text-destructive',
!isValidJson ? 'opacity-100' : 'opacity-0'
)}
/>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</Label>
<div className='flex items-center gap-[6px]'>
{showWand && (
@@ -243,11 +239,9 @@ const renderLabel = (
<Input
ref={wandState.searchInputRef}
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
wandState.onSearchChange(e.target.value)
}
onChange={(e) => wandState.onSearchChange(e.target.value)}
onBlur={wandState.onSearchBlur}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
wandState.searchQuery.trim() &&
@@ -268,11 +262,11 @@ const renderLabel = (
<Button
variant='tertiary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e: React.MouseEvent) => {
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e: React.MouseEvent) => {
onClick={(e) => {
e.stopPropagation()
wandState.onSearchSubmit()
}}
@@ -289,7 +283,7 @@ const renderLabel = (
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={canonicalToggle?.onToggle}
disabled={canonicalToggleDisabledResolved}
disabled={canonicalToggleDisabled}
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
>
<ArrowLeftRight
@@ -308,27 +302,22 @@ const renderLabel = (
}
/**
* Compares props for memo equality check.
* Compares props to prevent unnecessary re-renders.
*
* @remarks
* Used with React.memo to optimize performance by skipping re-renders
* when props haven't meaningfully changed.
*
* @param prevProps - Previous component props
* @param nextProps - Next component props
* @returns `true` if props are equal and re-render should be skipped
*/
const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): boolean => {
const subBlockId = prevProps.config.id
const prevValue = prevProps.subBlockValues?.[subBlockId]?.value
const nextValue = nextProps.subBlockValues?.[subBlockId]?.value
const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue)
const configEqual =
prevProps.config.id === nextProps.config.id && prevProps.config.type === nextProps.config.type
return (
prevProps.blockId === nextProps.blockId &&
configEqual &&
prevProps.config === nextProps.config &&
prevProps.isPreview === nextProps.isPreview &&
valueEqual &&
prevProps.subBlockValues === nextProps.subBlockValues &&
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
@@ -952,8 +941,7 @@ function SubBlockComponent({
onSearchCancel: handleSearchCancel,
searchInputRef,
},
canonicalToggle,
Boolean(canonicalToggle?.disabled || disabled || isPreview)
canonicalToggle
)}
{renderInput()}
</div>

View File

@@ -1,14 +1,12 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Button, Tooltip } from '@/components/emcn'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
hasStandaloneAdvancedFields,
isCanonicalPair,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
@@ -35,9 +33,6 @@ import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/**
* Icon component for rendering block icons.
*
@@ -63,15 +58,7 @@ export function Editor() {
toggleConnectionsCollapsed,
shouldFocusRename,
setShouldFocusRename,
} = usePanelEditorStore(
useShallow((state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
toggleConnectionsCollapsed: state.toggleConnectionsCollapsed,
shouldFocusRename: state.shouldFocusRename,
setShouldFocusRename: state.setShouldFocusRename,
}))
)
} = usePanelEditorStore()
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
@@ -99,15 +86,15 @@ export function Editor() {
currentWorkflow.isSnapshotView
)
// Subscribe to block's subblock values
const blockSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId || !currentBlockId) return EMPTY_SUBBLOCK_VALUES
return state.workflowValues[activeWorkflowId]?.[currentBlockId] ?? EMPTY_SUBBLOCK_VALUES
if (!activeWorkflowId || !currentBlockId) return {}
return state.workflowValues[activeWorkflowId]?.[currentBlockId] || {}
},
[activeWorkflowId, currentBlockId]
),
isEqual
)
)
const subBlocksForCanonical = useMemo(() => {
@@ -131,24 +118,10 @@ export function Editor() {
)
const displayAdvancedOptions = advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
if (subBlock.mode !== 'advanced') continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
// Check condition - skip if condition not met for current values
if (
subBlock.condition &&
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
) {
continue
}
return true
}
return false
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
const hasAdvancedOnlyFields = useMemo(
() => hasStandaloneAdvancedFields(subBlocksForCanonical, canonicalIndex),
[subBlocksForCanonical, canonicalIndex]
)
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
@@ -494,9 +467,7 @@ export function Editor() {
onClick={handleToggleAdvancedMode}
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
{displayAdvancedOptions
? 'Hide additional fields'
: 'Show additional fields'}
{displayAdvancedOptions ? 'Hide advanced fields' : 'Show advanced fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>

View File

@@ -43,12 +43,13 @@ export function useBlockConnections(blockId: string) {
)
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const workflowSubBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
)
// Helper function to merge block subBlocks with live values from subblock store
const getMergedSubBlocks = (sourceBlockId: string): Record<string, any> => {
const base = blocks[sourceBlockId]?.subBlocks || {}
const workflowSubBlockValues = workflowId
? (useSubBlockStore.getState().workflowValues[workflowId] ?? {})
: {}
const live = workflowSubBlockValues?.[sourceBlockId] || {}
const merged: Record<string, any> = { ...base }
for (const [subId, liveVal] of Object.entries(live)) {

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { usePanelEditorStore } from '@/stores/panel'
/**
@@ -28,12 +27,7 @@ interface UseConnectionsResizeProps {
* @returns Object containing resize handler
*/
export function useConnectionsResize({ subBlocksRef }: UseConnectionsResizeProps) {
const { connectionsHeight, setConnectionsHeight } = usePanelEditorStore(
useShallow((state) => ({
connectionsHeight: state.connectionsHeight,
setConnectionsHeight: state.setConnectionsHeight,
}))
)
const { connectionsHeight, setConnectionsHeight } = usePanelEditorStore()
const [isResizing, setIsResizing] = useState(false)
const startYRef = useRef<number>(0)

View File

@@ -1,5 +1,4 @@
import { useCallback } from 'react'
import { shallow } from 'zustand/shallow'
import { useCallback, useMemo } from 'react'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -12,36 +11,27 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
* @returns Block display properties (advanced mode, trigger mode)
*/
export function useEditorBlockProperties(blockId: string | null, isSnapshotView: boolean) {
const normalBlockProps = useWorkflowStore(
useCallback(
(state) => {
if (!blockId) return { advancedMode: false, triggerMode: false }
const block = state.blocks?.[blockId]
return {
advancedMode: block?.advancedMode ?? false,
triggerMode: block?.triggerMode ?? false,
}
},
[blockId]
),
shallow
const normalBlocks = useWorkflowStore(useCallback((state) => state.blocks, []))
const baselineBlocks = useWorkflowDiffStore(
useCallback((state) => state.baselineWorkflow?.blocks || {}, [])
)
const baselineBlockProps = useWorkflowDiffStore(
useCallback(
(state) => {
if (!blockId) return { advancedMode: false, triggerMode: false }
const block = state.baselineWorkflow?.blocks?.[blockId]
return {
advancedMode: block?.advancedMode ?? false,
triggerMode: block?.triggerMode ?? false,
}
},
[blockId]
),
shallow
)
const blockProperties = useMemo(() => {
if (!blockId) {
return {
advancedMode: false,
triggerMode: false,
}
}
// Use the appropriate props based on view mode
return isSnapshotView ? baselineBlockProps : normalBlockProps
const blocks = isSnapshotView ? baselineBlocks : normalBlocks
const block = blocks?.[blockId]
return {
advancedMode: block?.advancedMode ?? false,
triggerMode: block?.triggerMode ?? false,
}
}, [blockId, isSnapshotView, normalBlocks, baselineBlocks])
return blockProperties
}

View File

@@ -53,27 +53,22 @@ const SUBFLOW_CONFIG = {
* @returns Subflow editor state and handlers
*/
export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId: string | null) {
const workflowStore = useWorkflowStore()
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
// State
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const [showTagDropdown, setShowTagDropdown] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
// Check if current block is a subflow
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow configuration
const subflowConfig = isSubflow ? SUBFLOW_CONFIG[currentBlock.type as 'loop' | 'parallel'] : null
const nodeConfig = useWorkflowStore(
useCallback(
(state) => {
if (!isSubflow || !subflowConfig || !currentBlockId) return null
return state[subflowConfig.storeKey][currentBlockId] ?? null
},
[isSubflow, subflowConfig, currentBlockId]
)
)
const nodeConfig = isSubflow ? workflowStore[subflowConfig!.storeKey][currentBlockId!] : null
// Get block data for fallback values
const blockData = isSubflow ? currentBlock?.data : null

View File

@@ -1,10 +1,9 @@
'use client'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
BubbleChatClose,
BubbleChatPreview,
@@ -50,6 +49,7 @@ import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/st
import { useVariablesStore } from '@/stores/variables/store'
import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Panel')
/**
@@ -69,22 +69,14 @@ const logger = createLogger('Panel')
*
* @returns Panel on the right side of the workflow
*/
export const Panel = memo(function Panel() {
export function Panel() {
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
const panelRef = useRef<HTMLElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
useShallow((state) => ({
activeTab: state.activeTab,
setActiveTab: state.setActiveTab,
panelWidth: state.panelWidth,
_hasHydrated: state._hasHydrated,
setHasHydrated: state.setHasHydrated,
}))
)
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore()
const copilotRef = useRef<{
createNewChat: () => void
setInputValueAndFocus: (value: string) => void
@@ -105,18 +97,12 @@ export const Panel = memo(function Panel() {
const userPermissions = useUserPermissionsContext()
const { config: permissionConfig } = usePermissionConfig()
const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId })
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
activeWorkflowId: state.activeWorkflowId,
duplicateWorkflow: state.duplicateWorkflow,
hydration: state.hydration,
}))
)
const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry()
const isRegistryLoading =
hydration.phase === 'idle' ||
hydration.phase === 'metadata-loading' ||
hydration.phase === 'state-loading'
const { blocks } = useWorkflowStore()
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Delete workflow hook
@@ -171,18 +157,8 @@ export const Panel = memo(function Panel() {
}, [usageExceeded, handleRunWorkflow])
// Chat state
const { isChatOpen, setIsChatOpen } = useChatStore(
useShallow((state) => ({
isChatOpen: state.isChatOpen,
setIsChatOpen: state.setIsChatOpen,
}))
)
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
useShallow((state) => ({
isOpen: state.isOpen,
setIsOpen: state.setIsOpen,
}))
)
const { isChatOpen, setIsChatOpen } = useChatStore()
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
@@ -607,4 +583,4 @@ export const Panel = memo(function Panel() {
<Variables />
</>
)
})
}

View File

@@ -34,7 +34,6 @@ interface LogRowContextMenuProps {
onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
}
@@ -55,7 +54,6 @@ export function LogRowContextMenu({
onCopyRunId,
onClearFilters,
onClearConsole,
onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null
@@ -98,21 +96,6 @@ export function LogRowContextMenu({
</>
)}
{/* Fix in Copilot - only for error rows */}
{entry && !entry.success && (
<>
<PopoverItem
onClick={() => {
onFixInCopilot(entry)
onClose()
}}
>
Fix in Copilot
</PopoverItem>
<PopoverDivider />
</>
)}
{/* Filter actions */}
{entry && (
<>

View File

@@ -14,12 +14,8 @@ export function getProviderName(providerId: string): string {
}
/**
* Compares two WorkflowBlock props to determine if a re-render should be skipped.
* Used as the comparison function for React.memo.
*
* Note: xPos and yPos are intentionally excluded since WorkflowBlock doesn't use
* position props - ReactFlow handles positioning via CSS transforms. Including them
* would cause unnecessary re-renders during drag (100+ times per drag operation).
* Compares two WorkflowBlock props to determine if a re-render should be skipped
* Used as the comparison function for React.memo
*
* @param prevProps - Previous node props
* @param nextProps - Next node props
@@ -41,7 +37,9 @@ export function shouldSkipBlockRender(
prevProps.data.subBlockValues === nextProps.data.subBlockValues &&
prevProps.data.blockState === nextProps.data.blockState &&
prevProps.selected === nextProps.selected &&
prevProps.dragging === nextProps.dragging
prevProps.dragging === nextProps.dragging &&
prevProps.xPos === nextProps.xPos &&
prevProps.yPos === nextProps.yPos
)
}

View File

@@ -1,6 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
@@ -8,7 +7,6 @@ import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
@@ -30,7 +28,11 @@ import {
shouldSkipBlockRender,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import {
BLOCK_DIMENSIONS,
HANDLE_POSITIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
@@ -47,9 +49,6 @@ import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowBlock')
/** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
/**
* Type guard for table row structure
*/
@@ -324,53 +323,11 @@ export const getDisplayValue = (value: unknown): string => {
return stringValue.trim().length > 0 ? stringValue : '-'
}
interface SubBlockRowProps {
title: string
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workspaceId?: string
workflowId?: string
blockId?: string
allSubBlockValues?: Record<string, { value: unknown }>
displayAdvancedOptions?: boolean
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
}
/**
* Compares SubBlockRow props for memo equality check.
*/
const areSubBlockRowPropsEqual = (
prevProps: SubBlockRowProps,
nextProps: SubBlockRowProps
): boolean => {
const subBlockId = prevProps.subBlock?.id
const prevValue = subBlockId ? prevProps.allSubBlockValues?.[subBlockId]?.value : undefined
const nextValue = subBlockId ? nextProps.allSubBlockValues?.[subBlockId]?.value : undefined
const valueEqual = prevValue === nextValue || isEqual(prevValue, nextValue)
return (
prevProps.title === nextProps.title &&
prevProps.value === nextProps.value &&
prevProps.subBlock === nextProps.subBlock &&
prevProps.rawValue === nextProps.rawValue &&
prevProps.workspaceId === nextProps.workspaceId &&
prevProps.workflowId === nextProps.workflowId &&
prevProps.blockId === nextProps.blockId &&
valueEqual &&
prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions &&
prevProps.canonicalIndex === nextProps.canonicalIndex &&
prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides
)
}
/**
* Renders a single subblock row with title and optional value.
* Automatically hydrates IDs to display names for all selector types.
* Memoized to prevent excessive re-renders when parent components update.
*/
const SubBlockRow = memo(function SubBlockRow({
const SubBlockRow = ({
title,
value,
subBlock,
@@ -382,7 +339,19 @@ const SubBlockRow = memo(function SubBlockRow({
displayAdvancedOptions,
canonicalIndex,
canonicalModeOverrides,
}: SubBlockRowProps) {
}: {
title: string
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workspaceId?: string
workflowId?: string
blockId?: string
allSubBlockValues?: Record<string, { value: unknown }>
displayAdvancedOptions?: boolean
canonicalIndex?: ReturnType<typeof buildCanonicalIndex>
canonicalModeOverrides?: Record<string, 'basic' | 'advanced'>
}) => {
const getStringValue = useCallback(
(key?: string): string | undefined => {
if (!key || !allSubBlockValues) return undefined
@@ -520,34 +489,21 @@ const SubBlockRow = memo(function SubBlockRow({
: `${baseUrl}/api/webhooks/trigger/${blockId}`
}, [subBlock?.id, blockId, allSubBlockValues])
/**
* Subscribe only to variables for this workflow to avoid re-renders from other workflows.
* Uses isEqual for deep comparison since Object.fromEntries creates a new object each time.
*/
const workflowVariables = useVariablesStore(
useCallback(
(state) => {
if (!workflowId) return {}
return Object.fromEntries(
Object.entries(state.variables).filter(([, v]) => v.workflowId === workflowId)
)
},
[workflowId]
),
isEqual
)
const allVariables = useVariablesStore((state) => state.variables)
const variablesDisplayValue = useMemo(() => {
if (subBlock?.type !== 'variables-input' || !isVariableAssignmentsArray(rawValue)) {
return null
}
const variablesArray = Object.values(workflowVariables)
const workflowVariables = Object.values(allVariables).filter(
(v: any) => v.workflowId === workflowId
)
const names = rawValue
.map((a) => {
if (a.variableId) {
const variable = variablesArray.find((v: any) => v.id === a.variableId)
const variable = workflowVariables.find((v: any) => v.id === a.variableId)
return variable?.name
}
if (a.variableName) return a.variableName
@@ -559,7 +515,7 @@ const SubBlockRow = memo(function SubBlockRow({
if (names.length === 1) return names[0]
if (names.length === 2) return `${names[0]}, ${names[1]}`
return `${names[0]}, ${names[1]} +${names.length - 2}`
}, [subBlock?.type, rawValue, workflowVariables])
}, [subBlock?.type, rawValue, workflowId, allVariables])
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
@@ -595,7 +551,7 @@ const SubBlockRow = memo(function SubBlockRow({
)}
</div>
)
}, areSubBlockRowPropsEqual)
}
export const WorkflowBlock = memo(function WorkflowBlock({
id,
@@ -673,15 +629,18 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
/**
* Subscribe to this block's subblock values to track changes for conditional rendering
* of subblocks based on their conditions.
*/
const blockSubBlockValues = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return EMPTY_SUBBLOCK_VALUES
return state.workflowValues[activeWorkflowId]?.[id] ?? EMPTY_SUBBLOCK_VALUES
if (!activeWorkflowId) return {}
return state.workflowValues[activeWorkflowId]?.[id] || {}
},
[activeWorkflowId, id]
),
isEqual
)
)
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { Scan } from 'lucide-react'
@@ -22,29 +22,27 @@ import {
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { useShowActionBar, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useGeneralStore } from '@/stores/settings/general'
import { useTerminalStore } from '@/stores/terminal'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowControls')
/**
* Floating controls for canvas mode, undo/redo, and fit-to-view.
*/
export const WorkflowControls = memo(function WorkflowControls() {
export function WorkflowControls() {
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore()
const { undo, redo } = useCollaborativeWorkflow()
const showWorkflowControls = useShowActionBar()
const showWorkflowControls = useGeneralStore((s) => s.showActionBar)
const updateSetting = useUpdateGeneralSetting()
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
@@ -224,4 +222,4 @@ export const WorkflowControls = memo(function WorkflowControls() {
</Popover>
</>
)
})
}

View File

@@ -1,14 +1,18 @@
export {
clearDragHighlights,
computeClampedPositionUpdates,
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
export { useAccessibleReferencePrefixes } from './use-accessible-reference-prefixes'
export { useAutoLayout } from './use-auto-layout'
export { useBlockDimensions } from './use-block-dimensions'
export { useBlockOutputFields } from './use-block-output-fields'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { useBlockVisual } from './use-block-visual'
export { useCanvasContextMenu } from './use-canvas-context-menu'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useNodeUtilities } from './use-node-utilities'
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'
export { useShiftSelectionLock } from './use-shift-selection-lock'
export { useWand, type WandConfig } from './use-wand'
export { useWorkflowExecution } from './use-workflow-execution'

View File

@@ -2,6 +2,9 @@ import { useEffect, useRef } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
// Re-export for backwards compatibility
export { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
interface BlockDimensions {
width: number
height: number

View File

@@ -50,18 +50,8 @@ export function useBlockVisual({
} = useBlockState(blockId, currentWorkflow, data)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isThisBlockInEditor = currentBlockId === blockId
const activeTabIsEditor = usePanelStore(
useCallback(
(state) => {
if (isPreview || !isThisBlockInEditor) return false
return state.activeTab === 'editor'
},
[isPreview, isThisBlockInEditor]
)
)
const isEditorOpen = !isPreview && isThisBlockInEditor && activeTabIsEditor
const activeTab = usePanelStore((state) => state.activeTab)
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)

View File

@@ -8,14 +8,13 @@ type MenuType = 'block' | 'pane' | null
interface UseCanvasContextMenuProps {
blocks: Record<string, BlockState>
getNodes: () => Node[]
setNodes: (updater: (nodes: Node[]) => Node[]) => void
}
/**
* Hook for managing workflow canvas context menus.
* Handles right-click events, menu state, click-outside detection, and block info extraction.
*/
export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasContextMenuProps) {
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<BlockInfo[]>([])
@@ -45,26 +44,14 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
event.preventDefault()
event.stopPropagation()
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
}))
)
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = isMultiSelect
? selectedNodes.some((n) => n.id === node.id)
? selectedNodes
: [...selectedNodes, node]
: [node]
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
setActiveMenu('block')
},
[getNodes, nodesToBlockInfos, setNodes]
[getNodes, nodesToBlockInfos]
)
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {

View File

@@ -2,15 +2,107 @@ import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useReactFlow } from 'reactflow'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
calculateContainerDimensions,
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
import { getBlock } from '@/blocks/registry'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('NodeUtilities')
/**
* Estimates block dimensions based on block type.
* Uses subblock count to estimate height for blocks that haven't been measured yet.
*
* @param blockType - The type of block (e.g., 'condition', 'agent')
* @returns Estimated width and height for the block
*/
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
const blockConfig = getBlock(blockType)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
const height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
/**
* Clamps a position to keep a block fully inside a container's content area.
* Content area starts after the header and padding, and ends before the right/bottom padding.
*
* @param position - Raw position relative to container origin
* @param containerDimensions - Container width and height
* @param blockDimensions - Block width and height
* @returns Clamped position that keeps block inside content area
*/
export function clampPositionToContainer(
position: { x: number; y: number },
containerDimensions: { width: number; height: number },
blockDimensions: { width: number; height: number }
): { x: number; y: number } {
const { width: containerWidth, height: containerHeight } = containerDimensions
const { width: blockWidth, height: blockHeight } = blockDimensions
// Content area bounds (where blocks can be placed)
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
return {
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
}
}
/**
* Calculates container dimensions based on child block positions.
* Single source of truth for container sizing - ensures consistency between
* live drag updates and final dimension calculations.
*
* @param childPositions - Array of child positions with their dimensions
* @returns Calculated width and height for the container
*/
export function calculateContainerDimensions(
childPositions: Array<{ x: number; y: number; width: number; height: number }>
): { width: number; height: number } {
if (childPositions.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childPositions) {
maxRight = Math.max(maxRight, child.x + child.width)
maxBottom = Math.max(maxBottom, child.y + child.height)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}
/**
* Hook providing utilities for node position, hierarchy, and dimension calculations
*/
@@ -46,6 +138,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
}
}
// Prefer deterministic height published by the block component; fallback to estimate
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
@@ -53,6 +146,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
}
}
// Use shared estimation utility for blocks without measured height
return estimateBlockDimensions(block.type)
},
[blocks, isContainerType]
@@ -136,6 +230,8 @@ export function useNodeUtilities(blocks: Record<string, any>) {
const parentPos = getNodeAbsolutePosition(parentId)
// Child positions are stored relative to the content area (after header and padding)
// Add these offsets when calculating absolute position
const headerHeight = 50
const leftPadding = 16
const topPadding = 16
@@ -218,6 +314,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
})
.map((n) => ({
loopId: n.id,
// Return absolute position so callers can compute relative placement correctly
loopPosition: getNodeAbsolutePosition(n.id),
dimensions: {
width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
@@ -352,6 +449,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
return absPos
}
// Use known defaults per node type without type casting
const isSubflow = node.type === 'subflowNode'
const width = isSubflow
? typeof node.data?.width === 'number'

View File

@@ -1,63 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
interface UseShiftSelectionLockProps {
isHandMode: boolean
}
interface UseShiftSelectionLockResult {
/** Whether a shift-selection is currently active (locked in until mouseup) */
isShiftSelecting: boolean
/** Handler to attach to canvas mousedown */
handleCanvasMouseDown: (event: React.MouseEvent) => void
/** Computed ReactFlow props based on current selection state */
selectionProps: {
selectionOnDrag: boolean
panOnDrag: [number, number] | false
selectionKeyCode: string | null
}
}
/**
* Locks shift-selection mode from mousedown to mouseup.
* Prevents selection from canceling when shift is released mid-drag.
*/
export function useShiftSelectionLock({
isHandMode,
}: UseShiftSelectionLockProps): UseShiftSelectionLockResult {
const [isShiftSelecting, setIsShiftSelecting] = useState(false)
const handleCanvasMouseDown = useCallback(
(event: React.MouseEvent) => {
if (!event.shiftKey) return
const target = event.target as HTMLElement | null
const isPaneTarget = Boolean(target?.closest('.react-flow__pane, .react-flow__selectionpane'))
if (isPaneTarget && isHandMode) {
setIsShiftSelecting(true)
}
if (isPaneTarget) {
event.preventDefault()
window.getSelection()?.removeAllRanges()
}
},
[isHandMode]
)
useEffect(() => {
if (!isShiftSelecting) return
const handleMouseUp = () => setIsShiftSelecting(false)
window.addEventListener('mouseup', handleMouseUp)
return () => window.removeEventListener('mouseup', handleMouseUp)
}, [isShiftSelecting])
const selectionProps = {
selectionOnDrag: !isHandMode || isShiftSelecting,
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
selectionKeyCode: isShiftSelecting ? null : 'Shift',
}
return { isShiftSelecting, handleCanvasMouseDown, selectionProps }
}

View File

@@ -1,5 +1,3 @@
export * from './auto-layout-utils'
export * from './block-ring-utils'
export * from './node-position-utils'
export * from './workflow-canvas-helpers'
export * from './workflow-execution-utils'

View File

@@ -1,95 +0,0 @@
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'
/**
* Estimates block dimensions based on block type.
* Uses subblock count to estimate height for blocks that haven't been measured yet.
*
* @param blockType - The type of block (e.g., 'condition', 'agent')
* @returns Estimated width and height for the block
*/
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
const blockConfig = getBlock(blockType)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
const height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
/**
* Clamps a position to keep a block fully inside a container's content area.
* Content area starts after the header and padding, and ends before the right/bottom padding.
*
* @param position - Raw position relative to container origin
* @param containerDimensions - Container width and height
* @param blockDimensions - Block width and height
* @returns Clamped position that keeps block inside content area
*/
export function clampPositionToContainer(
position: { x: number; y: number },
containerDimensions: { width: number; height: number },
blockDimensions: { width: number; height: number }
): { x: number; y: number } {
const { width: containerWidth, height: containerHeight } = containerDimensions
const { width: blockWidth, height: blockHeight } = blockDimensions
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
return {
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
}
}
/**
* Calculates container dimensions based on child block positions.
* Single source of truth for container sizing - ensures consistency between
* live drag updates and final dimension calculations.
*
* @param childPositions - Array of child positions with their dimensions
* @returns Calculated width and height for the container
*/
export function calculateContainerDimensions(
childPositions: Array<{ x: number; y: number; width: number; height: number }>
): { width: number; height: number } {
if (childPositions.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childPositions) {
maxRight = Math.max(maxRight, child.x + child.width)
maxBottom = Math.max(maxBottom, child.y + child.height)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}

View File

@@ -1,7 +1,7 @@
import type { Edge, Node } from 'reactflow'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**

View File

@@ -42,28 +42,26 @@ import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import {
useAutoLayout,
useCanvasContextMenu,
useCurrentWorkflow,
useNodeUtilities,
useShiftSelectionLock,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
calculateContainerDimensions,
clampPositionToContainer,
clearDragHighlights,
computeClampedPositionUpdates,
estimateBlockDimensions,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
useAutoLayout,
useCurrentWorkflow,
useNodeUtilities,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
import {
calculateContainerDimensions,
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -75,6 +73,7 @@ import { useExecutionStore } from '@/stores/execution'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useNotificationStore } from '@/stores/notifications'
import { useCopilotStore, usePanelEditorStore } from '@/stores/panel'
import { useGeneralStore } from '@/stores/settings/general'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -234,10 +233,8 @@ const WorkflowContent = React.memo(() => {
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const selectedIdsRef = useRef<string[] | null>(null)
const canvasMode = useCanvasModeStore((state) => state.mode)
const isHandMode = canvasMode === 'hand'
const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode })
const [oauthModal, setOauthModal] = useState<{
provider: OAuthProvider
serviceId: string
@@ -267,9 +264,6 @@ const WorkflowContent = React.memo(() => {
preparePasteData,
hasClipboard,
clipboard,
pendingSelection,
setPendingSelection,
clearPendingSelection,
} = useWorkflowRegistry(
useShallow((state) => ({
workflows: state.workflows,
@@ -280,9 +274,6 @@ const WorkflowContent = React.memo(() => {
preparePasteData: state.preparePasteData,
hasClipboard: state.hasClipboard,
clipboard: state.clipboard,
pendingSelection: state.pendingSelection,
setPendingSelection: state.setPendingSelection,
clearPendingSelection: state.clearPendingSelection,
}))
)
@@ -309,15 +300,9 @@ const WorkflowContent = React.memo(() => {
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
const snapToGridSize = useSnapToGridSize()
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const isAutoConnectEnabled = useAutoConnect()
const autoConnectRef = useRef(isAutoConnectEnabled)
useEffect(() => {
autoConnectRef.current = isAutoConnectEnabled
}, [isAutoConnectEnabled])
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
@@ -456,6 +441,9 @@ const WorkflowContent = React.memo(() => {
new Map()
)
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef<Set<string> | null>(null)
/** Re-applies diff markers when blocks change after socket rehydration. */
const blocksRef = useRef(blocks)
useEffect(() => {
@@ -692,10 +680,9 @@ const WorkflowContent = React.memo(() => {
parentId?: string,
extent?: 'parent',
autoConnectEdge?: Edge,
triggerMode?: boolean,
presetSubBlockValues?: Record<string, unknown>
triggerMode?: boolean
) => {
setPendingSelection([id])
pendingSelectionRef.current = new Set([id])
setSelectedEdges(new Map())
const blockData: Record<string, unknown> = { ...(data || {}) }
@@ -723,14 +710,6 @@ const WorkflowContent = React.memo(() => {
}
}
// Apply preset subblock values (e.g., from tool-operation search)
if (presetSubBlockValues) {
if (!subBlockValues[id]) {
subBlockValues[id] = {}
}
Object.assign(subBlockValues[id], presetSubBlockValues)
}
collaborativeBatchAddBlocks(
[block],
autoConnectEdge ? [autoConnectEdge] : [],
@@ -740,7 +719,7 @@ const WorkflowContent = React.memo(() => {
)
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
[collaborativeBatchAddBlocks, setSelectedEdges]
)
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
@@ -874,7 +853,7 @@ const WorkflowContent = React.memo(() => {
handlePaneContextMenu,
handleSelectionContextMenu,
closeMenu: closeContextMenu,
} = useCanvasContextMenu({ blocks, getNodes, setNodes })
} = useCanvasContextMenu({ blocks, getNodes })
const handleContextCopy = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
@@ -902,7 +881,10 @@ const WorkflowContent = React.memo(() => {
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
setPendingSelection(pastedBlocksArray.map((b) => b.id))
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
@@ -912,14 +894,7 @@ const WorkflowContent = React.memo(() => {
pasteData.subBlockValues
)
},
[
preparePasteData,
blocks,
addNotification,
activeWorkflowId,
collaborativeBatchAddBlocks,
setPendingSelection,
]
[preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks]
)
const handleContextPaste = useCallback(() => {
@@ -1233,7 +1208,8 @@ const WorkflowContent = React.memo(() => {
containerId?: string
}
): Edge | undefined => {
if (!autoConnectRef.current) return undefined
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (!isAutoConnectEnabled) return undefined
// Don't auto-connect starter or annotation-only blocks
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
@@ -1498,7 +1474,7 @@ const WorkflowContent = React.memo(() => {
return
}
const { type, enableTriggerMode, presetOperation } = event.detail
const { type, enableTriggerMode } = event.detail
if (!type) return
if (type === 'connectionBlock') return
@@ -1561,8 +1537,7 @@ const WorkflowContent = React.memo(() => {
undefined,
undefined,
autoConnectEdge,
enableTriggerMode,
presetOperation ? { operation: presetOperation } : undefined
enableTriggerMode
)
}
@@ -2066,28 +2041,26 @@ const WorkflowContent = React.memo(() => {
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) {
const pendingSet = new Set(pendingSelection)
clearPendingSelection()
const pendingSelection = pendingSelectionRef.current
pendingSelectionRef.current = null
// Apply pending selection and resolve parent-child conflicts
const withSelection = derivedNodes.map((node) => ({
...node,
selected: pendingSet.has(node.id),
}))
setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks))
return
}
// Preserve existing selection state
setDisplayNodes((currentNodes) => {
if (pendingSelection) {
// Apply pending selection and resolve parent-child conflicts
const withSelection = derivedNodes.map((node) => ({
...node,
selected: pendingSelection.has(node.id),
}))
return resolveParentChildSelectionConflicts(withSelection, blocks)
}
// Preserve existing selection state
const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id))
return derivedNodes.map((node) => ({
...node,
selected: selectedIds.has(node.id),
}))
})
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
}, [derivedNodes, blocks])
/** Handles ActionBar remove-from-subflow events. */
useEffect(() => {
@@ -2164,25 +2137,11 @@ const WorkflowContent = React.memo(() => {
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
selectedIdsRef.current = null
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
if (!hasSelectionChange) return updated
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
return resolved
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
const { currentBlockId, clearCurrentBlock, setCurrentBlockId } =
usePanelEditorStore.getState()
if (selectedIds.length === 1 && selectedIds[0] !== currentBlockId) {
setCurrentBlockId(selectedIds[0])
} else if (selectedIds.length === 0 && currentBlockId) {
clearCurrentBlock()
}
}
},
[blocks]
)
@@ -3051,6 +3010,23 @@ const WorkflowContent = React.memo(() => {
usePanelEditorStore.getState().clearCurrentBlock()
}, [])
/** Prevents native text selection when starting a shift-drag on the pane. */
const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => {
if (!event.shiftKey) return
const target = event.target as HTMLElement | null
if (!target) return
const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane'))
if (!isPaneTarget) return
event.preventDefault()
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
selection.removeAllRanges()
}
}, [])
/**
* Handles node click to select the node in ReactFlow.
* Parent-child conflict resolution happens automatically in onNodesChange.
@@ -3250,10 +3226,9 @@ const WorkflowContent = React.memo(() => {
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectionOnDrag={selectionProps.selectionOnDrag}
selectionOnDrag={!isHandMode}
selectionMode={SelectionMode.Partial}
panOnDrag={selectionProps.panOnDrag}
selectionKeyCode={selectionProps.selectionKeyCode}
panOnDrag={isHandMode ? [0, 1] : false}
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
nodesConnectable={effectivePermissions.canEdit}
nodesDraggable={effectivePermissions.canEdit}

View File

@@ -19,7 +19,7 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import { getBlock } from '@/blocks'

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
@@ -8,7 +8,6 @@ import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
import { useBrandConfig } from '@/lib/branding/branding'
import { cn } from '@/lib/core/utils/cn'
import { getToolOperationsIndex } from '@/lib/search/tool-operations'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { searchItems } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -82,102 +81,13 @@ type SearchItem = {
color?: string
href?: string
shortcut?: string
type: 'block' | 'trigger' | 'tool' | 'tool-operation' | 'workflow' | 'workspace' | 'page' | 'doc'
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
isCurrent?: boolean
blockType?: string
config?: any
operationId?: string
aliases?: string[]
}
interface SearchResultItemProps {
item: SearchItem
visualIndex: number
isSelected: boolean
onItemClick: (item: SearchItem) => void
}
const SearchResultItem = memo(function SearchResultItem({
item,
visualIndex,
isSelected,
onItemClick,
}: SearchResultItemProps) {
const Icon = item.icon
const showColoredIcon =
item.type === 'block' ||
item.type === 'trigger' ||
item.type === 'tool' ||
item.type === 'tool-operation'
const isWorkflow = item.type === 'workflow'
const isWorkspace = item.type === 'workspace'
const handleClick = useCallback(() => {
onItemClick(item)
}, [onItemClick, item])
return (
<button
data-search-item-index={visualIndex}
onClick={handleClick}
onMouseDown={(e) => e.preventDefault()}
className={cn(
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none',
isSelected ? 'bg-[var(--border)] shadow-sm' : 'hover:bg-[var(--border)]'
)}
>
{/* Icon - different rendering for workflows vs others */}
{!isWorkspace && (
<>
{isWorkflow ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: item.color }}
/>
) : (
Icon && (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: showColoredIcon ? item.bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
</div>
)
)}
</>
)}
{/* Content */}
<span
className={cn(
'truncate font-medium',
isSelected
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{item.name}
{item.isCurrent && ' (current)'}
</span>
{/* Shortcut */}
{item.shortcut && (
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)]'>
{item.shortcut}
</span>
)}
</button>
)
})
export const SearchModal = memo(function SearchModal({
export function SearchModal({
open,
onOpenChange,
workflows = [],
@@ -193,7 +103,7 @@ export const SearchModal = memo(function SearchModal({
const { filterBlocks } = usePermissionConfig()
const blocks = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
const filteredAllBlocks = filterBlocks(allBlocks)
@@ -232,10 +142,10 @@ export const SearchModal = memo(function SearchModal({
]
return [...regularBlocks, ...filterBlocks(specialBlocks)]
}, [open, isOnWorkflowPage, filterBlocks])
}, [isOnWorkflowPage, filterBlocks])
const triggers = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
if (!isOnWorkflowPage) return []
const allTriggers = getTriggersForSidebar()
const filteredTriggers = filterBlocks(allTriggers)
@@ -264,10 +174,10 @@ export const SearchModal = memo(function SearchModal({
config: block,
})
)
}, [open, isOnWorkflowPage, filterBlocks])
}, [isOnWorkflowPage, filterBlocks])
const tools = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
const filteredAllBlocks = filterBlocks(allBlocks)
@@ -283,25 +193,7 @@ export const SearchModal = memo(function SearchModal({
type: block.type,
})
)
}, [open, isOnWorkflowPage, filterBlocks])
const toolOperations = useMemo(() => {
if (!open || !isOnWorkflowPage) return []
const allowedBlockTypes = new Set(tools.map((t) => t.type))
return getToolOperationsIndex()
.filter((op) => allowedBlockTypes.has(op.blockType))
.map((op) => ({
id: op.id,
name: `${op.serviceName}: ${op.operationName}`,
icon: op.icon,
bgColor: op.bgColor,
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
}))
}, [open, isOnWorkflowPage, tools])
}, [isOnWorkflowPage, filterBlocks])
const pages = useMemo(
(): PageItem[] => [
@@ -329,8 +221,6 @@ export const SearchModal = memo(function SearchModal({
)
const docs = useMemo((): DocItem[] => {
if (!open) return []
const allBlocks = getAllBlocks()
const docsItems: DocItem[] = []
@@ -347,7 +237,7 @@ export const SearchModal = memo(function SearchModal({
})
return docsItems
}, [open])
}, [])
const allItems = useMemo((): SearchItem[] => {
const items: SearchItem[] = []
@@ -421,19 +311,6 @@ export const SearchModal = memo(function SearchModal({
})
})
toolOperations.forEach((op) => {
items.push({
id: op.id,
name: op.name,
icon: op.icon,
bgColor: op.bgColor,
type: 'tool-operation',
blockType: op.blockType,
operationId: op.operationId,
aliases: op.aliases,
})
})
docs.forEach((doc) => {
items.push({
id: doc.id,
@@ -445,10 +322,10 @@ export const SearchModal = memo(function SearchModal({
})
return items
}, [workspaces, workflows, pages, blocks, triggers, tools, toolOperations, docs])
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
const sectionOrder = useMemo<SearchItem['type'][]>(
() => ['block', 'tool', 'tool-operation', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
[]
)
@@ -495,7 +372,6 @@ export const SearchModal = memo(function SearchModal({
page: [],
trigger: [],
block: [],
'tool-operation': [],
tool: [],
doc: [],
}
@@ -551,17 +427,6 @@ export const SearchModal = memo(function SearchModal({
window.dispatchEvent(event)
}
break
case 'tool-operation':
if (item.blockType && item.operationId) {
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: item.blockType,
presetOperation: item.operationId,
},
})
window.dispatchEvent(event)
}
break
case 'workspace':
if (item.isCurrent) {
break
@@ -642,7 +507,6 @@ export const SearchModal = memo(function SearchModal({
page: 'Pages',
trigger: 'Triggers',
block: 'Blocks',
'tool-operation': 'Tool Operations',
tool: 'Tools',
doc: 'Docs',
}
@@ -685,16 +549,78 @@ export const SearchModal = memo(function SearchModal({
{/* Section items */}
<div className='space-y-[2px]'>
{items.map((item) => {
{items.map((item, itemIndex) => {
const Icon = item.icon
const visualIndex = displayedItemsInVisualOrder.indexOf(item)
const isSelected = visualIndex === selectedIndex
const showColoredIcon =
item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
const isWorkflow = item.type === 'workflow'
const isWorkspace = item.type === 'workspace'
return (
<SearchResultItem
<button
key={`${item.type}-${item.id}`}
item={item}
visualIndex={visualIndex}
isSelected={visualIndex === selectedIndex}
onItemClick={handleItemClick}
/>
data-search-item-index={visualIndex}
onClick={() => handleItemClick(item)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none',
isSelected
? 'bg-[var(--border)] shadow-sm'
: 'hover:bg-[var(--border)]'
)}
>
{/* Icon - different rendering for workflows vs others */}
{!isWorkspace && (
<>
{isWorkflow ? (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: item.color }}
/>
) : (
Icon && (
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{
background: showColoredIcon ? item.bgColor : 'transparent',
}}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? '!h-[10px] !w-[10px] text-white'
: 'h-[14px] w-[14px] text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
/>
</div>
)
)}
</>
)}
{/* Content */}
<span
className={cn(
'truncate font-medium',
isSelected
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)}
>
{item.name}
{item.isCurrent && ' (current)'}
</span>
{/* Shortcut */}
{item.shortcut && (
<span className='ml-auto flex-shrink-0 font-medium text-[13px] text-[var(--text-subtle)]'>
{item.shortcut}
</span>
)}
</button>
)
})}
</div>
@@ -713,4 +639,4 @@ export const SearchModal = memo(function SearchModal({
</DialogPortal>
</Dialog>
)
})
}

View File

@@ -8,19 +8,17 @@ export interface SearchableItem {
name: string
description?: string
type: string
aliases?: string[]
[key: string]: any
}
export interface SearchResult<T extends SearchableItem> {
item: T
score: number
matchType: 'exact' | 'prefix' | 'alias' | 'word-boundary' | 'substring' | 'description'
matchType: 'exact' | 'prefix' | 'word-boundary' | 'substring' | 'description'
}
const SCORE_EXACT_MATCH = 10000
const SCORE_PREFIX_MATCH = 5000
const SCORE_ALIAS_MATCH = 3000
const SCORE_WORD_BOUNDARY = 1000
const SCORE_SUBSTRING_MATCH = 100
const DESCRIPTION_WEIGHT = 0.3
@@ -69,39 +67,6 @@ function calculateFieldScore(
return { score: 0, matchType: null }
}
/**
* Check if query matches any alias in the item's aliases array
* Returns the alias score if a match is found, 0 otherwise
*/
function calculateAliasScore(
query: string,
aliases?: string[]
): { score: number; matchType: 'alias' | null } {
if (!aliases || aliases.length === 0) {
return { score: 0, matchType: null }
}
const normalizedQuery = query.toLowerCase().trim()
for (const alias of aliases) {
const normalizedAlias = alias.toLowerCase().trim()
if (normalizedAlias === normalizedQuery) {
return { score: SCORE_ALIAS_MATCH, matchType: 'alias' }
}
if (normalizedAlias.startsWith(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.8, matchType: 'alias' }
}
if (normalizedQuery.includes(normalizedAlias) || normalizedAlias.includes(normalizedQuery)) {
return { score: SCORE_ALIAS_MATCH * 0.6, matchType: 'alias' }
}
}
return { score: 0, matchType: null }
}
/**
* Search items using tiered matching algorithm
* Returns items sorted by relevance (highest score first)
@@ -125,20 +90,15 @@ export function searchItems<T extends SearchableItem>(
? calculateFieldScore(normalizedQuery, item.description)
: { score: 0, matchType: null }
const aliasMatch = calculateAliasScore(normalizedQuery, item.aliases)
const nameScore = nameMatch.score
const descScore = descMatch.score * DESCRIPTION_WEIGHT
const aliasScore = aliasMatch.score
const bestScore = Math.max(nameScore, descScore, aliasScore)
const bestScore = Math.max(nameScore, descScore)
if (bestScore > 0) {
let matchType: SearchResult<T>['matchType'] = 'substring'
if (nameScore >= descScore && nameScore >= aliasScore) {
if (nameScore >= descScore) {
matchType = nameMatch.matchType || 'substring'
} else if (aliasScore >= descScore) {
matchType = 'alias'
} else {
matchType = 'description'
}
@@ -165,8 +125,6 @@ export function getMatchTypeLabel(matchType: SearchResult<any>['matchType']): st
return 'Exact match'
case 'prefix':
return 'Starts with'
case 'alias':
return 'Similar to'
case 'word-boundary':
return 'Word match'
case 'substring':

View File

@@ -1078,7 +1078,7 @@ export function AccessControl() {
</ModalBody>
<ModalFooter>
<Button
variant='destructive'
variant='default'
onClick={() => {
setShowUnsavedChanges(false)
setShowConfigModal(false)

View File

@@ -294,9 +294,10 @@ export function BYOK() {
Cancel
</Button>
<Button
variant='tertiary'
variant='primary'
onClick={handleSave}
disabled={!apiKeyInput.trim() || upsertKey.isPending}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
{upsertKey.isPending ? 'Saving...' : 'Save'}
</Button>
@@ -320,7 +321,12 @@ export function BYOK() {
<Button variant='default' onClick={() => setDeleteConfirmProvider(null)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={deleteKey.isPending}>
<Button
variant='primary'
onClick={handleDelete}
disabled={deleteKey.isPending}
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
>
{deleteKey.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>

View File

@@ -334,7 +334,7 @@ export function Copilot() {
Cancel
</Button>
<Button
variant='destructive'
variant='ghost'
onClick={handleDeleteKey}
disabled={deleteKeyMutation.isPending}
>

View File

@@ -831,7 +831,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
</p>
</ModalBody>
<ModalFooter>
<Button variant='destructive' onClick={handleCancel}>
<Button variant='default' onClick={handleCancel}>
Discard Changes
</Button>
{hasConflicts || hasInvalidKeys ? (

View File

@@ -32,13 +32,11 @@ import {
UsageLimit,
type UsageLimitRef,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/usage-limit'
import {
useBillingUsageNotifications,
useUpdateGeneralSetting,
} from '@/hooks/queries/general-settings'
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription'
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
import { useGeneralStore } from '@/stores/settings/general'
const CONSTANTS = {
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
@@ -629,7 +627,7 @@ export function Subscription() {
}
function BillingUsageNotificationsToggle() {
const enabled = useBillingUsageNotifications()
const enabled = useGeneralStore((s) => s.isBillingUsageNotificationsEnabled)
const updateSetting = useUpdateGeneralSetting()
const isLoading = updateSetting.isPending

View File

@@ -117,7 +117,7 @@ export function TeamSeats({
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isLoading}>
<Button variant='active' onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import {
Button,
@@ -11,110 +11,10 @@ import {
PopoverDivider,
PopoverFolder,
PopoverItem,
usePopoverContext,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
const GRID_COLUMNS = 6
/**
* Color grid with keyboard navigation support.
* Uses roving tabindex pattern for accessibility.
*/
function ColorGrid({
hexInput,
setHexInput,
}: {
hexInput: string
setHexInput: (color: string) => void
}) {
const { isInFolder } = usePopoverContext()
const [focusedIndex, setFocusedIndex] = useState(-1)
const gridRef = useRef<HTMLDivElement>(null)
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
useEffect(() => {
if (isInFolder && gridRef.current) {
const selectedIndex = WORKFLOW_COLORS.findIndex(
({ color }) => color.toLowerCase() === hexInput.toLowerCase()
)
const initialIndex = selectedIndex >= 0 ? selectedIndex : 0
setFocusedIndex(initialIndex)
setTimeout(() => {
buttonRefs.current[initialIndex]?.focus()
}, 50)
}
}, [isInFolder, hexInput])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
const totalItems = WORKFLOW_COLORS.length
let newIndex = index
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
newIndex = index + 1 < totalItems ? index + 1 : index
break
case 'ArrowLeft':
e.preventDefault()
newIndex = index - 1 >= 0 ? index - 1 : index
break
case 'ArrowDown':
e.preventDefault()
newIndex = index + GRID_COLUMNS < totalItems ? index + GRID_COLUMNS : index
break
case 'ArrowUp':
e.preventDefault()
newIndex = index - GRID_COLUMNS >= 0 ? index - GRID_COLUMNS : index
break
case 'Enter':
case ' ':
e.preventDefault()
setHexInput(WORKFLOW_COLORS[index].color)
return
default:
return
}
if (newIndex !== index) {
setFocusedIndex(newIndex)
buttonRefs.current[newIndex]?.focus()
}
},
[setHexInput]
)
return (
<div ref={gridRef} className='grid grid-cols-6 gap-[4px]' role='grid'>
{WORKFLOW_COLORS.map(({ color, name }, index) => (
<button
key={color}
ref={(el) => {
buttonRefs.current[index] = el
}}
type='button'
role='gridcell'
title={name}
tabIndex={focusedIndex === index ? 0 : -1}
onClick={(e) => {
e.stopPropagation()
setHexInput(color)
}}
onKeyDown={(e) => handleKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
className={cn(
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
)
}
/**
* Validates a hex color string.
* Accepts 3 or 6 character hex codes with or without #.
@@ -449,8 +349,25 @@ export function ContextMenu({
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
>
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
{/* Preset colors with keyboard navigation */}
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
{/* Preset colors */}
<div className='grid grid-cols-6 gap-[4px]'>
{WORKFLOW_COLORS.map(({ color, name }) => (
<button
key={color}
type='button'
title={name}
onClick={(e) => {
e.stopPropagation()
setHexInput(color)
}}
className={cn(
'h-[20px] w-[20px] rounded-[4px]',
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
)}
style={{ backgroundColor: color }}
/>
))}
</div>
{/* Hex input */}
<div className='flex items-center gap-[4px]'>

View File

@@ -97,15 +97,6 @@ export function DeleteModal({
return 'Are you sure you want to delete this folder? This will permanently remove all associated workflows, logs, and knowledge bases.'
}
if (isSingle && displayNames.length > 0) {
return (
<>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{displayNames[0]}</span>? This
will permanently remove all associated workflows, folders, logs, and knowledge bases.
</>
)
}
return 'Are you sure you want to delete this workspace? This will permanently remove all associated workflows, folders, logs, and knowledge bases.'
}

View File

@@ -52,8 +52,8 @@ export function WorkflowItem({
}: WorkflowItemProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const { selectedWorkflows } = useFolderStore()
const { updateWorkflow, workflows } = useWorkflowRegistry()
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
@@ -141,7 +141,6 @@ export function WorkflowItem({
const workflowIds =
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
const { workflows } = useWorkflowRegistry.getState()
const workflowNames = workflowIds
.map((id) => workflows[id]?.name)
.filter((name): name is string => !!name)
@@ -152,7 +151,7 @@ export function WorkflowItem({
}
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
}, [workflow.id, canDeleteWorkflows])
}, [workflow.id, workflows, canDeleteWorkflows])
/**
* Handle right-click - ensure proper selection behavior and capture selection state

View File

@@ -709,7 +709,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</Button>
<Button
type='button'
variant='tertiary'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
className='h-[32px] gap-[8px] px-[12px] font-medium'

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo } from 'react'
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -16,19 +15,19 @@ interface UseWorkflowOperationsProps {
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
const router = useRouter()
const workflows = useWorkflowRegistry(useShallow((state) => state.workflows))
const { workflows } = useWorkflowRegistry()
const workflowsQuery = useWorkflows(workspaceId)
const createWorkflowMutation = useCreateWorkflow()
const regularWorkflows = useMemo(
() =>
Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime()
}),
[workflows, workspaceId]
)
/**
* Filter and sort workflows for the current workspace
*/
const regularWorkflows = Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.sort((a, b) => {
// Sort by creation date (newest first) for stable ordering
return b.createdAt.getTime() - a.createdAt.getTime()
})
const handleCreateWorkflow = useCallback(async (): Promise<string | null> => {
try {
@@ -56,11 +55,13 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
}, [createWorkflowMutation, workspaceId, router])
return {
// State
workflows,
regularWorkflows,
workflowsLoading: workflowsQuery.isLoading,
isCreatingWorkflow: createWorkflowMutation.isPending,
// Operations
handleCreateWorkflow,
}
}

View File

@@ -33,7 +33,7 @@ export function useWorkspaceManagement({
}: UseWorkspaceManagementProps) {
const router = useRouter()
const pathname = usePathname()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
const { switchToWorkspace } = useWorkflowRegistry()
// Workspace management state
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
@@ -95,6 +95,10 @@ export function useWorkspaceManagement({
}
}, [])
/**
* Fetch workspaces for the current user with full validation and URL handling
* Uses refs for workspaceId and router to avoid unnecessary recreations
*/
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
@@ -177,6 +181,10 @@ export function useWorkspaceManagement({
[]
)
/**
* Switch to a different workspace
* Uses refs for activeWorkspace and router to avoid unnecessary recreations
*/
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, return

View File

@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
import Link from 'next/link'
@@ -60,7 +60,7 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item'
*
* @returns Sidebar with workflows panel
*/
export const Sidebar = memo(function Sidebar() {
export function Sidebar() {
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string | undefined
@@ -142,9 +142,11 @@ export const Sidebar = memo(function Sidebar() {
window.removeEventListener(SIDEBAR_SCROLL_EVENT, handleScrollToItem as EventListener)
}, [])
const isSearchModalOpen = useSearchModalStore((state) => state.isOpen)
const setIsSearchModalOpen = useSearchModalStore((state) => state.setOpen)
const openSearchModal = useSearchModalStore((state) => state.open)
const {
isOpen: isSearchModalOpen,
setOpen: setIsSearchModalOpen,
open: openSearchModal,
} = useSearchModalStore()
const {
workspaces,
@@ -174,6 +176,7 @@ export const Sidebar = memo(function Sidebar() {
workspaceId,
})
/** Context menu state for navigation items */
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
const {
isOpen: isNavContextMenuOpen,
@@ -282,6 +285,7 @@ export const Sidebar = memo(function Sidebar() {
const isLoading = workflowsLoading || sessionLoading
const initialScrollDoneRef = useRef(false)
/** Scrolls to active workflow on initial page load only */
useEffect(() => {
if (!workflowId || workflowsLoading || initialScrollDoneRef.current) return
initialScrollDoneRef.current = true
@@ -292,6 +296,7 @@ export const Sidebar = memo(function Sidebar() {
})
}, [workflowId, workflowsLoading])
/** Forces sidebar to minimum width and ensures it's expanded when not on a workflow page */
useEffect(() => {
if (!isOnWorkflowPage) {
if (isCollapsed) {
@@ -301,6 +306,7 @@ export const Sidebar = memo(function Sidebar() {
}
}, [isOnWorkflowPage, isCollapsed, setIsCollapsed, setSidebarWidth])
/** Creates a workflow and scrolls to it */
const handleCreateWorkflow = useCallback(async () => {
const workflowId = await createWorkflow()
if (workflowId) {
@@ -310,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
}
}, [createWorkflow])
/** Creates a folder and scrolls to it */
const handleCreateFolder = useCallback(async () => {
const folderId = await createFolder()
if (folderId) {
@@ -317,10 +324,12 @@ export const Sidebar = memo(function Sidebar() {
}
}, [createFolder])
/** Triggers file input for workflow import */
const handleImportWorkflow = useCallback(() => {
fileInputRef.current?.click()
}, [])
/** Handles workspace switch from popover menu */
const handleWorkspaceSwitch = useCallback(
async (workspace: { id: string; name: string; ownerId: string; role?: string }) => {
if (workspace.id === workspaceId) {
@@ -333,10 +342,12 @@ export const Sidebar = memo(function Sidebar() {
[workspaceId, switchWorkspace]
)
/** Toggles sidebar collapse state */
const handleToggleCollapse = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed, setIsCollapsed])
/** Reverts to active workflow selection when clicking sidebar background */
const handleSidebarClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
const target = e.target as HTMLElement
@@ -349,6 +360,7 @@ export const Sidebar = memo(function Sidebar() {
[workflowId]
)
/** Renames a workspace */
const handleRenameWorkspace = useCallback(
async (workspaceIdToRename: string, newName: string) => {
await updateWorkspaceName(workspaceIdToRename, newName)
@@ -356,6 +368,7 @@ export const Sidebar = memo(function Sidebar() {
[updateWorkspaceName]
)
/** Deletes a workspace */
const handleDeleteWorkspace = useCallback(
async (workspaceIdToDelete: string) => {
const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete)
@@ -366,6 +379,7 @@ export const Sidebar = memo(function Sidebar() {
[workspaces, confirmDeleteWorkspace]
)
/** Leaves a workspace */
const handleLeaveWorkspaceWrapper = useCallback(
async (workspaceIdToLeave: string) => {
const workspaceToLeave = workspaces.find((w) => w.id === workspaceIdToLeave)
@@ -376,6 +390,7 @@ export const Sidebar = memo(function Sidebar() {
[workspaces, handleLeaveWorkspace]
)
/** Duplicates a workspace */
const handleDuplicateWorkspace = useCallback(
async (_workspaceIdToDuplicate: string, workspaceName: string) => {
await duplicateWorkspace(workspaceName)
@@ -383,6 +398,7 @@ export const Sidebar = memo(function Sidebar() {
[duplicateWorkspace]
)
/** Exports a workspace */
const handleExportWorkspace = useCallback(
async (workspaceIdToExport: string, workspaceName: string) => {
await exportWorkspace(workspaceIdToExport, workspaceName)
@@ -390,10 +406,12 @@ export const Sidebar = memo(function Sidebar() {
[exportWorkspace]
)
/** Triggers file input for workspace import */
const handleImportWorkspace = useCallback(() => {
workspaceFileInputRef.current?.click()
}, [])
/** Handles workspace import file selection */
const handleWorkspaceFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
@@ -409,6 +427,7 @@ export const Sidebar = memo(function Sidebar() {
[importWorkspace]
)
/** Resolves workspace ID from params or URL path */
const resolveWorkspaceIdFromPath = useCallback((): string | undefined => {
if (workspaceId) return workspaceId
if (typeof window === 'undefined') return undefined
@@ -420,6 +439,7 @@ export const Sidebar = memo(function Sidebar() {
return parts[idx + 1]
}, [workspaceId])
/** Registers global sidebar commands with the central commands registry */
useRegisterGlobalCommands(() =>
createCommands([
{
@@ -752,4 +772,4 @@ export const Sidebar = memo(function Sidebar() {
/>
</>
)
})
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
@@ -27,21 +27,9 @@ interface UseDuplicateWorkflowProps {
*/
export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWorkflowProps) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
const duplicateMutation = useDuplicateWorkflowMutation()
const workspaceIdRef = useRef(workspaceId)
workspaceIdRef.current = workspaceId
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
/**
* Store a ref to the mutation to access isPending without causing callback recreation.
* The mutateAsync function from React Query is already stable.
*/
const mutationRef = useRef(duplicateMutation)
mutationRef.current = duplicateMutation
/**
* Duplicate the workflow(s)
* @param workflowIds - The workflow ID(s) to duplicate
@@ -52,7 +40,7 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
return
}
if (mutationRef.current.isPending) {
if (duplicateMutation.isPending) {
return
}
@@ -61,8 +49,6 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
const duplicatedIds: string[] = []
try {
const { workflows } = useWorkflowRegistry.getState()
for (const sourceId of workflowIdsToDuplicate) {
const sourceWorkflow = workflows[sourceId]
if (!sourceWorkflow) {
@@ -70,8 +56,8 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
continue
}
const result = await mutationRef.current.mutateAsync({
workspaceId: workspaceIdRef.current,
const result = await duplicateMutation.mutateAsync({
workspaceId,
sourceId,
name: `${sourceWorkflow.name} (Copy)`,
description: sourceWorkflow.description,
@@ -91,16 +77,16 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor
})
if (duplicatedIds.length === 1) {
router.push(`/workspace/${workspaceIdRef.current}/w/${duplicatedIds[0]}`)
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
}
onSuccessRef.current?.()
onSuccess?.()
} catch (error) {
logger.error('Error duplicating workflow(s):', { error })
throw error
}
},
[router]
[duplicateMutation, workflows, workspaceId, router, onSuccess]
)
return {

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
downloadFile,
@@ -23,11 +23,9 @@ interface UseExportWorkflowProps {
* Hook for managing workflow export to JSON or ZIP.
*/
export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
const { workflows } = useWorkflowRegistry()
const [isExporting, setIsExporting] = useState(false)
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
/**
* Export the workflow(s) to JSON or ZIP
* - Single workflow: exports as JSON file
@@ -52,7 +50,6 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
count: workflowIdsToExport.length,
})
const { workflows } = useWorkflowRegistry.getState()
const exportedWorkflows = []
for (const workflowId of workflowIdsToExport) {
@@ -99,7 +96,7 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
})
onSuccessRef.current?.()
onSuccess?.()
} catch (error) {
logger.error('Error exporting workflow(s):', { error })
throw error
@@ -107,7 +104,7 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
setIsExporting(false)
}
},
[isExporting]
[isExporting, workflows, onSuccess]
)
return {

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
@@ -740,60 +739,38 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.operationFailed = handler
}, [])
const contextValue = useMemo(
() => ({
socket,
isConnected,
isConnecting,
currentWorkflowId,
currentSocketId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
emitWorkflowOperation,
emitSubblockUpdate,
emitVariableUpdate,
emitCursorUpdate,
emitSelectionUpdate,
onWorkflowOperation,
onSubblockUpdate,
onVariableUpdate,
onCursorUpdate,
onSelectionUpdate,
onUserJoined,
onUserLeft,
onWorkflowDeleted,
onWorkflowReverted,
onOperationConfirmed,
onOperationFailed,
}),
[
socket,
isConnected,
isConnecting,
currentWorkflowId,
currentSocketId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
emitWorkflowOperation,
emitSubblockUpdate,
emitVariableUpdate,
emitCursorUpdate,
emitSelectionUpdate,
onWorkflowOperation,
onSubblockUpdate,
onVariableUpdate,
onCursorUpdate,
onSelectionUpdate,
onUserJoined,
onUserLeft,
onWorkflowDeleted,
onWorkflowReverted,
onOperationConfirmed,
onOperationFailed,
]
)
return (
<SocketContext.Provider
value={{
socket,
isConnected,
isConnecting,
currentWorkflowId,
currentSocketId,
presenceUsers,
joinWorkflow,
leaveWorkflow,
emitWorkflowOperation,
emitSubblockUpdate,
emitVariableUpdate,
return <SocketContext.Provider value={contextValue}>{children}</SocketContext.Provider>
emitCursorUpdate,
emitSelectionUpdate,
onWorkflowOperation,
onSubblockUpdate,
onVariableUpdate,
onCursorUpdate,
onSelectionUpdate,
onUserJoined,
onUserLeft,
onWorkflowDeleted,
onWorkflowReverted,
onOperationConfirmed,
onOperationFailed,
}}
>
{children}
</SocketContext.Provider>
)
}

View File

@@ -11,7 +11,7 @@ export const BrowserUseBlock: BlockConfig<BrowserUseResponse> = {
'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.',
docsLink: 'https://docs.sim.ai/tools/browser_use',
category: 'tools',
bgColor: '#181C1E',
bgColor: '#E0E0E0',
icon: BrowserUseIcon,
subBlocks: [
{

View File

@@ -84,44 +84,6 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = {
{ label: 'Create project', id: 'github_create_project' },
{ label: 'Update project', id: 'github_update_project' },
{ label: 'Delete project', id: 'github_delete_project' },
// Search Operations
{ label: 'Search code', id: 'github_search_code' },
{ label: 'Search commits', id: 'github_search_commits' },
{ label: 'Search issues', id: 'github_search_issues' },
{ label: 'Search repositories', id: 'github_search_repos' },
{ label: 'Search users', id: 'github_search_users' },
// Commit Operations
{ label: 'List commits', id: 'github_list_commits' },
{ label: 'Get commit', id: 'github_get_commit' },
{ label: 'Compare commits', id: 'github_compare_commits' },
// Gist Operations
{ label: 'Create gist', id: 'github_create_gist' },
{ label: 'Get gist', id: 'github_get_gist' },
{ label: 'List gists', id: 'github_list_gists' },
{ label: 'Update gist', id: 'github_update_gist' },
{ label: 'Delete gist', id: 'github_delete_gist' },
{ label: 'Fork gist', id: 'github_fork_gist' },
{ label: 'Star gist', id: 'github_star_gist' },
{ label: 'Unstar gist', id: 'github_unstar_gist' },
// Fork Operations
{ label: 'Fork repository', id: 'github_fork_repo' },
{ label: 'List forks', id: 'github_list_forks' },
// Milestone Operations
{ label: 'Create milestone', id: 'github_create_milestone' },
{ label: 'Get milestone', id: 'github_get_milestone' },
{ label: 'List milestones', id: 'github_list_milestones' },
{ label: 'Update milestone', id: 'github_update_milestone' },
{ label: 'Delete milestone', id: 'github_delete_milestone' },
// Reaction Operations
{ label: 'Add issue reaction', id: 'github_create_issue_reaction' },
{ label: 'Remove issue reaction', id: 'github_delete_issue_reaction' },
{ label: 'Add comment reaction', id: 'github_create_comment_reaction' },
{ label: 'Remove comment reaction', id: 'github_delete_comment_reaction' },
// Star Operations
{ label: 'Star repository', id: 'github_star_repo' },
{ label: 'Unstar repository', id: 'github_unstar_repo' },
{ label: 'Check if starred', id: 'github_check_star' },
{ label: 'List stargazers', id: 'github_list_stargazers' },
],
value: () => 'github_pr',
},
@@ -1036,440 +998,6 @@ export const GitHubBlock: BlockConfig<GitHubResponse> = {
required: true,
condition: { field: 'operation', value: 'github_delete_project' },
},
// Search operations parameters
{
id: 'q',
title: 'Search Query',
type: 'short-input',
placeholder: 'e.g., react language:typescript',
required: true,
condition: {
field: 'operation',
value: [
'github_search_code',
'github_search_commits',
'github_search_issues',
'github_search_repos',
'github_search_users',
],
},
wandConfig: {
enabled: true,
prompt: `Generate a GitHub search query based on the user's description.
GitHub search supports these qualifiers:
- For repos: language:python, stars:>1000, forks:>100, topic:react, user:owner, org:name, created:>2023-01-01
- For code: repo:owner/name, path:src, extension:ts, language:javascript
- For issues/PRs: is:issue, is:pr, is:open, is:closed, label:bug, author:user, assignee:user
- For commits: repo:owner/name, author:user, committer:user, author-date:>2023-01-01
- For users: type:user, type:org, followers:>100, repos:>10, location:city
Examples:
- "Python repos with more than 1000 stars" -> language:python stars:>1000
- "Open bugs in facebook/react" -> repo:facebook/react is:issue is:open label:bug
- "TypeScript files in src folder" -> language:typescript path:src
Return ONLY the search query - no explanations.`,
placeholder: 'Describe what you want to search for...',
},
},
{
id: 'sort',
title: 'Sort By',
type: 'dropdown',
options: [
{ label: 'Best match', id: '' },
{ label: 'Stars', id: 'stars' },
{ label: 'Forks', id: 'forks' },
{ label: 'Updated', id: 'updated' },
],
condition: { field: 'operation', value: 'github_search_repos' },
},
{
id: 'order',
title: 'Order',
type: 'dropdown',
options: [
{ label: 'Descending', id: 'desc' },
{ label: 'Ascending', id: 'asc' },
],
condition: {
field: 'operation',
value: [
'github_search_code',
'github_search_commits',
'github_search_issues',
'github_search_repos',
'github_search_users',
],
},
},
// Commit operations parameters
{
id: 'sha',
title: 'SHA or Branch',
type: 'short-input',
placeholder: 'e.g., main or abc123',
condition: { field: 'operation', value: 'github_list_commits' },
},
{
id: 'author',
title: 'Author Filter',
type: 'short-input',
placeholder: 'GitHub username or email',
condition: { field: 'operation', value: 'github_list_commits' },
},
{
id: 'since',
title: 'Since Date',
type: 'short-input',
placeholder: 'ISO 8601: 2024-01-01T00:00:00Z',
condition: { field: 'operation', value: ['github_list_commits', 'github_list_gists'] },
wandConfig: {
enabled: true,
prompt: `Generate an ISO 8601 timestamp based on the user's description.
The timestamp should be in the format: YYYY-MM-DDTHH:MM:SSZ (UTC timezone).
Examples:
- "last week" -> Calculate 7 days ago at 00:00:00Z
- "yesterday" -> Calculate yesterday's date at 00:00:00Z
- "beginning of this month" -> First day of current month at 00:00:00Z
- "30 days ago" -> Calculate 30 days before current time
- "January 1st 2024" -> 2024-01-01T00:00:00Z
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the start date (e.g., "last week", "beginning of month")...',
generationType: 'timestamp',
},
},
{
id: 'until',
title: 'Until Date',
type: 'short-input',
placeholder: 'ISO 8601: 2024-12-31T23:59:59Z',
condition: { field: 'operation', value: 'github_list_commits' },
wandConfig: {
enabled: true,
prompt: `Generate an ISO 8601 timestamp based on the user's description.
The timestamp should be in the format: YYYY-MM-DDTHH:MM:SSZ (UTC timezone).
Examples:
- "now" -> Current timestamp
- "end of today" -> Today's date at 23:59:59Z
- "end of last week" -> Calculate end of last week
- "yesterday" -> Yesterday's date at 23:59:59Z
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the end date (e.g., "now", "end of yesterday")...',
generationType: 'timestamp',
},
},
{
id: 'ref',
title: 'Commit Reference',
type: 'short-input',
placeholder: 'SHA, branch, or tag',
required: true,
condition: { field: 'operation', value: 'github_get_commit' },
},
{
id: 'base',
title: 'Base Reference',
type: 'short-input',
placeholder: 'Base branch/tag/SHA',
required: true,
condition: { field: 'operation', value: 'github_compare_commits' },
},
{
id: 'head',
title: 'Head Reference',
type: 'short-input',
placeholder: 'Head branch/tag/SHA',
required: true,
condition: { field: 'operation', value: 'github_compare_commits' },
},
// Gist operations parameters
{
id: 'gist_id',
title: 'Gist ID',
type: 'short-input',
placeholder: 'e.g., aa5a315d61ae9438b18d',
required: true,
condition: {
field: 'operation',
value: [
'github_get_gist',
'github_update_gist',
'github_delete_gist',
'github_fork_gist',
'github_star_gist',
'github_unstar_gist',
],
},
},
{
id: 'description',
title: 'Description',
type: 'short-input',
placeholder: 'Gist description',
condition: { field: 'operation', value: ['github_create_gist', 'github_update_gist'] },
},
{
id: 'files',
title: 'Files (JSON)',
type: 'long-input',
placeholder: '{"file.txt": {"content": "Hello"}}',
required: true,
condition: { field: 'operation', value: 'github_create_gist' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for GitHub Gist files based on the user's description.
The format is: {"filename.ext": {"content": "file contents"}}
Examples:
- "A Python hello world file" -> {"hello.py": {"content": "print('Hello, World!')"}}
- "A README with project title" -> {"README.md": {"content": "# My Project\\n\\nDescription here"}}
- "JavaScript function to add numbers" -> {"add.js": {"content": "function add(a, b) {\\n return a + b;\\n}"}}
- "Two files: index.html and style.css" -> {"index.html": {"content": "<!DOCTYPE html>..."}, "style.css": {"content": "body { margin: 0; }"}}
Return ONLY valid JSON - no explanations, no markdown formatting.`,
placeholder: 'Describe the files you want to create...',
generationType: 'json-object',
},
},
{
id: 'files',
title: 'Files (JSON)',
type: 'long-input',
placeholder: '{"file.txt": {"content": "Updated"}}',
condition: { field: 'operation', value: 'github_update_gist' },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for updating GitHub Gist files based on the user's description.
The format is: {"filename.ext": {"content": "new contents"}}
To delete a file, set its value to null: {"old-file.txt": null}
To rename a file, set the new filename: {"old-name.txt": {"filename": "new-name.txt", "content": "..."}}
Examples:
- "Update hello.py to print goodbye" -> {"hello.py": {"content": "print('Goodbye!')"}}
- "Delete the old readme" -> {"README.md": null}
- "Rename script.js to main.js" -> {"script.js": {"filename": "main.js"}}
Return ONLY valid JSON - no explanations, no markdown formatting.`,
placeholder: 'Describe the file changes...',
generationType: 'json-object',
},
},
{
id: 'gist_public',
title: 'Public',
type: 'dropdown',
options: [
{ label: 'Secret', id: 'false' },
{ label: 'Public', id: 'true' },
],
condition: { field: 'operation', value: 'github_create_gist' },
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'GitHub username (optional)',
condition: { field: 'operation', value: 'github_list_gists' },
},
// Fork operations parameters
{
id: 'organization',
title: 'Organization',
type: 'short-input',
placeholder: 'Fork to org (optional)',
condition: { field: 'operation', value: 'github_fork_repo' },
},
{
id: 'fork_name',
title: 'Fork Name',
type: 'short-input',
placeholder: 'Custom name (optional)',
condition: { field: 'operation', value: 'github_fork_repo' },
},
{
id: 'default_branch_only',
title: 'Default Branch Only',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
condition: { field: 'operation', value: 'github_fork_repo' },
},
{
id: 'fork_sort',
title: 'Sort By',
type: 'dropdown',
options: [
{ label: 'Newest', id: 'newest' },
{ label: 'Oldest', id: 'oldest' },
{ label: 'Stargazers', id: 'stargazers' },
{ label: 'Watchers', id: 'watchers' },
],
condition: { field: 'operation', value: 'github_list_forks' },
},
// Milestone operations parameters
{
id: 'milestone_title',
title: 'Milestone Title',
type: 'short-input',
placeholder: 'e.g., v1.0 Release',
required: true,
condition: { field: 'operation', value: 'github_create_milestone' },
},
{
id: 'milestone_title',
title: 'New Title',
type: 'short-input',
placeholder: 'Updated title (optional)',
condition: { field: 'operation', value: 'github_update_milestone' },
},
{
id: 'milestone_description',
title: 'Description',
type: 'long-input',
placeholder: 'Milestone description',
condition: {
field: 'operation',
value: ['github_create_milestone', 'github_update_milestone'],
},
},
{
id: 'due_on',
title: 'Due Date',
type: 'short-input',
placeholder: 'ISO 8601: 2024-12-31T23:59:59Z',
condition: {
field: 'operation',
value: ['github_create_milestone', 'github_update_milestone'],
},
wandConfig: {
enabled: true,
prompt: `Generate an ISO 8601 timestamp for a milestone due date based on the user's description.
The timestamp should be in the format: YYYY-MM-DDTHH:MM:SSZ (UTC timezone).
Examples:
- "end of this month" -> Last day of current month at 23:59:59Z
- "next Friday" -> Calculate next Friday's date at 23:59:59Z
- "in 2 weeks" -> Calculate 14 days from now at 23:59:59Z
- "December 31st" -> 2024-12-31T23:59:59Z (current year)
- "Q1 2025" -> 2025-03-31T23:59:59Z (end of Q1)
Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the due date (e.g., "end of month", "next Friday")...',
generationType: 'timestamp',
},
},
{
id: 'milestone_number',
title: 'Milestone Number',
type: 'short-input',
placeholder: 'e.g., 1',
required: true,
condition: {
field: 'operation',
value: ['github_get_milestone', 'github_update_milestone', 'github_delete_milestone'],
},
},
{
id: 'milestone_state',
title: 'State Filter',
type: 'dropdown',
options: [
{ label: 'Open', id: 'open' },
{ label: 'Closed', id: 'closed' },
{ label: 'All', id: 'all' },
],
condition: { field: 'operation', value: 'github_list_milestones' },
},
{
id: 'milestone_sort',
title: 'Sort By',
type: 'dropdown',
options: [
{ label: 'Due Date', id: 'due_on' },
{ label: 'Completeness', id: 'completeness' },
],
condition: { field: 'operation', value: 'github_list_milestones' },
},
// Reaction operations parameters
{
id: 'reaction_content',
title: 'Reaction',
type: 'dropdown',
options: [
{ label: '👍 +1', id: '+1' },
{ label: '👎 -1', id: '-1' },
{ label: '😄 Laugh', id: 'laugh' },
{ label: '😕 Confused', id: 'confused' },
{ label: '❤️ Heart', id: 'heart' },
{ label: '🎉 Hooray', id: 'hooray' },
{ label: '🚀 Rocket', id: 'rocket' },
{ label: '👀 Eyes', id: 'eyes' },
],
required: true,
condition: {
field: 'operation',
value: ['github_create_issue_reaction', 'github_create_comment_reaction'],
},
},
{
id: 'issue_number',
title: 'Issue Number',
type: 'short-input',
placeholder: 'e.g., 123',
required: true,
condition: {
field: 'operation',
value: ['github_create_issue_reaction', 'github_delete_issue_reaction'],
},
},
{
id: 'reaction_id',
title: 'Reaction ID',
type: 'short-input',
placeholder: 'e.g., 12345678',
required: true,
condition: {
field: 'operation',
value: ['github_delete_issue_reaction', 'github_delete_comment_reaction'],
},
},
{
id: 'comment_id',
title: 'Comment ID',
type: 'short-input',
placeholder: 'e.g., 987654321',
required: true,
condition: {
field: 'operation',
value: ['github_create_comment_reaction', 'github_delete_comment_reaction'],
},
},
// Star operations parameters - owner/repo already covered by existing subBlocks
{
id: 'per_page',
title: 'Results Per Page',
type: 'short-input',
placeholder: 'e.g., 30 (default: 30, max: 100)',
condition: {
field: 'operation',
value: [
'github_search_code',
'github_search_commits',
'github_search_issues',
'github_search_repos',
'github_search_users',
'github_list_commits',
'github_list_gists',
'github_list_forks',
'github_list_milestones',
'github_list_stargazers',
],
},
},
{
id: 'apiKey',
title: 'GitHub Token',
@@ -1590,44 +1118,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'github_create_project',
'github_update_project',
'github_delete_project',
// Search tools
'github_search_code',
'github_search_commits',
'github_search_issues',
'github_search_repos',
'github_search_users',
// Commit tools
'github_list_commits',
'github_get_commit',
'github_compare_commits',
// Gist tools
'github_create_gist',
'github_get_gist',
'github_list_gists',
'github_update_gist',
'github_delete_gist',
'github_fork_gist',
'github_star_gist',
'github_unstar_gist',
// Fork tools
'github_fork_repo',
'github_list_forks',
// Milestone tools
'github_create_milestone',
'github_get_milestone',
'github_list_milestones',
'github_update_milestone',
'github_delete_milestone',
// Reaction tools
'github_create_issue_reaction',
'github_delete_issue_reaction',
'github_create_comment_reaction',
'github_delete_comment_reaction',
// Star tools
'github_star_repo',
'github_unstar_repo',
'github_check_star',
'github_list_stargazers',
],
config: {
tool: (params) => {
@@ -1744,75 +1234,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
return 'github_update_project'
case 'github_delete_project':
return 'github_delete_project'
// Search operations
case 'github_search_code':
return 'github_search_code'
case 'github_search_commits':
return 'github_search_commits'
case 'github_search_issues':
return 'github_search_issues'
case 'github_search_repos':
return 'github_search_repos'
case 'github_search_users':
return 'github_search_users'
// Commit operations
case 'github_list_commits':
return 'github_list_commits'
case 'github_get_commit':
return 'github_get_commit'
case 'github_compare_commits':
return 'github_compare_commits'
// Gist operations
case 'github_create_gist':
return 'github_create_gist'
case 'github_get_gist':
return 'github_get_gist'
case 'github_list_gists':
return 'github_list_gists'
case 'github_update_gist':
return 'github_update_gist'
case 'github_delete_gist':
return 'github_delete_gist'
case 'github_fork_gist':
return 'github_fork_gist'
case 'github_star_gist':
return 'github_star_gist'
case 'github_unstar_gist':
return 'github_unstar_gist'
// Fork operations
case 'github_fork_repo':
return 'github_fork_repo'
case 'github_list_forks':
return 'github_list_forks'
// Milestone operations
case 'github_create_milestone':
return 'github_create_milestone'
case 'github_get_milestone':
return 'github_get_milestone'
case 'github_list_milestones':
return 'github_list_milestones'
case 'github_update_milestone':
return 'github_update_milestone'
case 'github_delete_milestone':
return 'github_delete_milestone'
// Reaction operations
case 'github_create_issue_reaction':
return 'github_create_issue_reaction'
case 'github_delete_issue_reaction':
return 'github_delete_issue_reaction'
case 'github_create_comment_reaction':
return 'github_create_comment_reaction'
case 'github_delete_comment_reaction':
return 'github_delete_comment_reaction'
// Star operations
case 'github_star_repo':
return 'github_star_repo'
case 'github_unstar_repo':
return 'github_unstar_repo'
case 'github_check_star':
return 'github_check_star'
case 'github_list_stargazers':
return 'github_list_stargazers'
default:
return 'github_repo_info'
}
@@ -1876,38 +1297,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
project_number: { type: 'number', description: 'Project number' },
project_id: { type: 'string', description: 'Project node ID' },
project_public: { type: 'boolean', description: 'Project public status' },
// Search parameters
q: { type: 'string', description: 'Search query with qualifiers' },
sort: { type: 'string', description: 'Sort field' },
order: { type: 'string', description: 'Sort order (asc or desc)' },
// Commit parameters
author: { type: 'string', description: 'Author filter' },
committer: { type: 'string', description: 'Committer filter' },
since: { type: 'string', description: 'Date filter (since)' },
until: { type: 'string', description: 'Date filter (until)' },
// Gist parameters
gist_id: { type: 'string', description: 'Gist ID' },
description: { type: 'string', description: 'Description' },
files: { type: 'string', description: 'Files JSON object' },
gist_public: { type: 'boolean', description: 'Public gist status' },
username: { type: 'string', description: 'GitHub username' },
// Fork parameters
organization: { type: 'string', description: 'Target organization for fork' },
fork_name: { type: 'string', description: 'Custom name for fork' },
default_branch_only: { type: 'boolean', description: 'Fork only default branch' },
fork_sort: { type: 'string', description: 'Fork list sort field' },
// Milestone parameters
milestone_title: { type: 'string', description: 'Milestone title' },
milestone_description: { type: 'string', description: 'Milestone description' },
due_on: { type: 'string', description: 'Milestone due date' },
milestone_number: { type: 'number', description: 'Milestone number' },
milestone_state: { type: 'string', description: 'Milestone state filter' },
milestone_sort: { type: 'string', description: 'Milestone sort field' },
// Reaction parameters
reaction_content: { type: 'string', description: 'Reaction type' },
reaction_id: { type: 'number', description: 'Reaction ID' },
// Pagination parameters
page: { type: 'number', description: 'Page number for pagination' },
},
outputs: {
content: { type: 'string', description: 'Response content' },

Some files were not shown because too many files have changed in this diff Show More