Compare commits

...

41 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
5de7228dd9 improvement(avatar): use selection-update as the source of truth for presence, ignore other socket ops (#2866)
* improvement(avatar): use selection-update as the source of truth for presence, ignore other socket ops

* added logs
2026-01-16 20:17:07 -08:00
Vikhyath Mondreti
75898c69ed fix(start): seed initial subblock values on batch add (#2864) 2026-01-16 20:07:20 -08:00
Vikhyath Mondreti
b14672887b fix(sockets): webhooks logic removal from copilot ops (#2862)
* fix(sockets): dying on deployed webhooks

* fix edit workflow
2026-01-16 19:53:14 -08:00
Waleed
d024c1e489 fix(shift): fix shift select blue ring fading (#2863) 2026-01-16 19:52:51 -08:00
Waleed
d75ea37b3c chore(readme): updated readme (#2861) 2026-01-16 18:18:40 -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
Vikhyath Mondreti
fd23220cc3 fix(slack): tool params should be in line with block (#2860)
* env var pattern outside loop

* fix(slack): tool params should line up with block

* remove comments
2026-01-16 18:00:44 -08:00
Adam Gough
a8d81097fc fix(google-vault): error handling improvement and more params (#2735)
* new error throw and improvement

* fixed critical issues

* restore error thorwing

* restore

* added handler for vault

* updated docs

* restored

* removed google vault from executor

* updated translations

* updated docs

* fixed inputs and outputs

---------

Co-authored-by: aadamgough <adam@sim.ai>
Co-authored-by: waleed <walif6@gmail.com>
2026-01-16 17:59:17 -08:00
Waleed
3768c6379c feat(readme): added deepwiki to readme, consolidated utils (#2856)
* feat(readme): added deepwiki to readme, consolidated utils

* standardized all modals

* updated modal copy

* standardized modals

* streamlined all error msg patterns
2026-01-16 16:07:31 -08:00
Siddharth Ganesan
aa80116b99 fix(copilot): copilot edit router block accepts semantic handles (#2857)
* Fix copilot diff controls

* Fix router block for copilot

* Fix queue

* Fix lint

* Get block options and config for subflows

* Lint
2026-01-16 15:54:28 -08:00
Vikhyath Mondreti
78e4ca9d45 improvement(serializer): canonical subblock, serialization cleanups, schedules/webhooks are deployment version friendly (#2848)
* hide form deployment tab from docs

* progress

* fix resolution

* cleanup code

* fix positioning

* cleanup dead sockets adv mode ops

* address greptile comments

* fix tests plus more simplification

* fix cleanup

* bring back advanced mode with specific definition

* revert feature flags

* improvement(subblock): ui

* resolver change to make all var references optional chaining

* fix(webhooks/schedules): deployment version friendly

* fix tests

* fix credential sets with new lifecycle

* prep merge

* add back migration

* fix display check for adv fields

* fix trigger vs block scoping

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-16 15:23:43 -08:00
Waleed
ce3ddb6ba0 improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX (#2853)
* improvement(deployed-mcp): added the ability to make the visibility for deployed mcp tools public, updated UX

* use reactquery

* migrated chats to use reactquery, upgraded entire deploymodal to use reactquery instead of manual state management

* added hooks for chat chats and updated callers to all use reactquery

* fix

* updated comments

* consolidated utils
2026-01-16 14:18:39 -08:00
Siddharth Ganesan
8361931cdf fix(copilot): fix copilot bugs (#2855)
* Fix edit workflow returning bad state

* Fix block id edit, slash commands at end, thinking tag resolution, add continue button

* Clean up autosend and continue options and enable mention menu

* Cleanup

* Fix thinking tags

* Fix thinking text

* Fix get block options text

* Fix bugs

* Fix redeploy

* Fix loading indicators

* User input expansion

* Normalize copilot subblock ids

* Fix handlecancelcheckpoint
2026-01-16 13:57:55 -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
202 changed files with 27912 additions and 3310 deletions

View File

@@ -9,12 +9,12 @@
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a> <a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20Studio%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-000000?logo=cursor&logoColor=white" alt="Set Up with Cursor"></a>
</p>
### Build Workflows with Ease

View File

@@ -1,3 +1,3 @@
{
"pages": ["index", "basics", "api", "form", "logging", "costs"]
"pages": ["index", "basics", "api", "logging", "costs"]
}

View File

@@ -36,43 +36,47 @@ Connect Google Vault to create exports, list exports, and manage holds within ma
### `google_vault_create_matters_export`
Create an export in a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `exportName` | string | Yes | Name for the export \(avoid special characters\) |
| `corpus` | string | Yes | Data corpus to export \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
| `accountEmails` | string | No | Comma-separated list of user emails to scope export |
| `orgUnitId` | string | No | Organization unit ID to scope export \(alternative to emails\) |
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, e.g., 2024-01-01T00:00:00Z\) |
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, e.g., 2024-12-31T23:59:59Z\) |
| `terms` | string | No | Search query terms to filter exported content |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `export` | json | Created export object |
### `google_vault_list_matters_export`
List exports for a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `pageSize` | number | No | Number of exports to return per page |
| `pageToken` | string | No | Token for pagination |
| `exportId` | string | No | Optional export ID to fetch a specific export |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `exports` | json | Array of export objects |
| `export` | json | Single export object \(when exportId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_vault_download_export_file`
@@ -82,10 +86,10 @@ Download a single file from a Google Vault export (GCS object)
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | No description |
| `bucketName` | string | Yes | No description |
| `objectName` | string | Yes | No description |
| `fileName` | string | No | No description |
| `matterId` | string | Yes | The matter ID |
| `bucketName` | string | Yes | GCS bucket name from cloudStorageSink.files.bucketName |
| `objectName` | string | Yes | GCS object name from cloudStorageSink.files.objectName |
| `fileName` | string | No | Optional filename override for the downloaded file |
#### Output
@@ -95,82 +99,84 @@ Download a single file from a Google Vault export (GCS object)
### `google_vault_create_matters_holds`
Create a hold in a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `holdName` | string | Yes | Name for the hold |
| `corpus` | string | Yes | Data corpus to hold \(MAIL, DRIVE, GROUPS, HANGOUTS_CHAT, VOICE\) |
| `accountEmails` | string | No | Comma-separated list of user emails to put on hold |
| `orgUnitId` | string | No | Organization unit ID to put on hold \(alternative to accounts\) |
| `terms` | string | No | Search terms to filter held content \(for MAIL and GROUPS corpus\) |
| `startTime` | string | No | Start time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
| `endTime` | string | No | End time for date filtering \(ISO 8601 format, for MAIL and GROUPS corpus\) |
| `includeSharedDrives` | boolean | No | Include files in shared drives \(for DRIVE corpus\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `hold` | json | Created hold object |
### `google_vault_list_matters_holds`
List holds for a matter
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `matterId` | string | Yes | The matter ID |
| `pageSize` | number | No | Number of holds to return per page |
| `pageToken` | string | No | Token for pagination |
| `holdId` | string | No | Optional hold ID to fetch a specific hold |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `holds` | json | Array of hold objects |
| `hold` | json | Single hold object \(when holdId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |
### `google_vault_create_matters`
Create a new matter in Google Vault
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `name` | string | Yes | Name for the new matter |
| `description` | string | No | Optional description for the matter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `matter` | json | Created matter object |
### `google_vault_list_matters`
List matters, or get a specific matter if matterId is provided
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `pageSize` | number | No | Number of matters to return per page |
| `pageToken` | string | No | Token for pagination |
| `matterId` | string | No | Optional matter ID to fetch a specific matter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matters` | json | Array of matter objects \(for list_matters\) |
| `exports` | json | Array of export objects \(for list_matters_export\) |
| `holds` | json | Array of hold objects \(for list_matters_holds\) |
| `matter` | json | Created matter object \(for create_matters\) |
| `export` | json | Created export object \(for create_matters_export\) |
| `hold` | json | Created hold object \(for create_matters_holds\) |
| `file` | json | Downloaded export file \(UserFile\) from execution files |
| `nextPageToken` | string | Token for fetching next page of results \(for list operations\) |
| `matters` | json | Array of matter objects |
| `matter` | json | Single matter object \(when matterId is provided\) |
| `nextPageToken` | string | Token for fetching next page of results |

View File

@@ -11,10 +11,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"content/docs/execution/index.mdx",
"content/docs/connections/index.mdx",
".next/dev/types/**/*.ts"
"content/docs/connections/index.mdx"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", ".next"]
}

View File

@@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth'
import { generateChatTitle } from '@/lib/copilot/chat-title'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({
userMessageId: z.string().optional(), // ID from frontend for the user message
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
model: z
.enum([
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-5.1-fast',
'gpt-5.1',
'gpt-5.1-medium',
'gpt-5.1-high',
'gpt-5-codex',
'gpt-5.1-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.2-pro',
'gpt-4o',
'gpt-4.1',
'o3',
'claude-4-sonnet',
'claude-4.5-haiku',
'claude-4.5-sonnet',
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
])
.optional()
.default('claude-4.5-opus'),
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -295,7 +270,8 @@ export async function POST(req: NextRequest) {
}
const defaults = getCopilotModel('chat')
const modelToUse = env.COPILOT_MODEL || defaults.model
const selectedModel = model || defaults.model
const envModel = env.COPILOT_MODEL || defaults.model
let providerConfig: CopilotProviderConfig | undefined
const providerEnv = env.COPILOT_PROVIDER as any
@@ -304,7 +280,7 @@ export async function POST(req: NextRequest) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: modelToUse,
model: envModel,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
@@ -312,7 +288,7 @@ export async function POST(req: NextRequest) {
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: modelToUse,
model: envModel,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
@@ -320,12 +296,15 @@ export async function POST(req: NextRequest) {
} else {
providerConfig = {
provider: providerEnv,
model: modelToUse,
model: selectedModel,
apiKey: env.COPILOT_API_KEY,
}
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
// Determine conversationId to use for this request
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
@@ -345,7 +324,7 @@ export async function POST(req: NextRequest) {
}
} | null = null
if (mode === 'agent') {
if (effectiveMode === 'build') {
// Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability
baseTools = [
@@ -452,8 +431,8 @@ export async function POST(req: NextRequest) {
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
model: model,
mode: mode,
model: selectedModel,
mode: transportMode,
messageId: userMessageIdToUse,
version: SIM_AGENT_VERSION,
...(providerConfig ? { provider: providerConfig } : {}),
@@ -477,7 +456,7 @@ export async function POST(req: NextRequest) {
hasConversationId: !!effectiveConversationId,
hasFileAttachments: processedFileContents.length > 0,
messageLength: message.length,
mode,
mode: effectiveMode,
hasTools: integrationTools.length > 0,
toolCount: integrationTools.length,
hasBaseTools: baseTools.length > 0,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { COPILOT_MODES } from '@/lib/copilot/models'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
@@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({
planArtifact: z.string().nullable().optional(),
config: z
.object({
mode: z.enum(['ask', 'build', 'plan']).optional(),
mode: z.enum(COPILOT_MODES).optional(),
model: z.string().optional(),
})
.nullable()

View File

@@ -14,8 +14,7 @@ import {
import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
import { executeTool } from '@/tools'
import { getTool, resolveToolId } from '@/tools/utils'
@@ -28,45 +27,6 @@ const ExecuteToolSchema = z.object({
workflowId: z.string().optional(),
})
/**
* Resolves all {{ENV_VAR}} references in a value recursively
* Works with strings, arrays, and objects
*/
function resolveEnvVarReferences(value: any, envVars: Record<string, string>): any {
if (typeof value === 'string') {
// Check for exact match: entire string is "{{VAR_NAME}}"
const exactMatchPattern = new RegExp(
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
)
const exactMatch = exactMatchPattern.exec(value)
if (exactMatch) {
const envVarName = exactMatch[1].trim()
return envVars[envVarName] ?? value
}
// Check for embedded references: "prefix {{VAR}} suffix"
const envVarPattern = createEnvVarPattern()
return value.replace(envVarPattern, (match, varName) => {
const trimmedName = varName.trim()
return envVars[trimmedName] ?? match
})
}
if (Array.isArray(value)) {
return value.map((item) => resolveEnvVarReferences(item, envVars))
}
if (value !== null && typeof value === 'object') {
const resolved: Record<string, any> = {}
for (const [key, val] of Object.entries(value)) {
resolved[key] = resolveEnvVarReferences(val, envVars)
}
return resolved
}
return value
}
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
@@ -145,7 +105,17 @@ export async function POST(req: NextRequest) {
// Build execution params starting with LLM-provided arguments
// Resolve all {{ENV_VAR}} references in the arguments
const executionParams: Record<string, any> = resolveEnvVarReferences(toolArgs, decryptedEnvVars)
const executionParams: Record<string, any> = resolveEnvVarReferences(
toolArgs,
decryptedEnvVars,
{
resolveExactMatch: true,
allowEmbedded: true,
trimKeys: true,
onMissing: 'keep',
deep: true,
}
) as Record<string, any>
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {
toolName,

View File

@@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import type { CopilotModelId } from '@/lib/copilot/models'
import { db } from '@/../../packages/db'
import { settings } from '@/../../packages/db/schema'
const logger = createLogger('CopilotUserModelsAPI')
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
@@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'claude-4.5-haiku': true,
'claude-4.5-sonnet': true,
'claude-4.5-opus': true,
// 'claude-4.1-opus': true,
'claude-4.1-opus': false,
'gemini-3-pro': true,
}
@@ -54,7 +55,9 @@ export async function GET(request: NextRequest) {
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
mergedModels[modelId] = enabled
if (modelId in mergedModels) {
mergedModels[modelId as CopilotModelId] = enabled
}
}
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(

View File

@@ -11,6 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -35,10 +36,7 @@ async function getWorkflowInputSchema(workflowId: string): Promise<any[]> {
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
const startBlock = blocks.find(
(block) =>
block.type === 'starter' || block.type === 'start_trigger' || block.type === 'input_trigger'
)
const startBlock = blocks.find((block) => isValidStartBlockType(block.type))
if (!startBlock) {
return []

View File

@@ -9,6 +9,7 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
resolveEnvVarReferences,
} from '@/executor/utils/reference-validation'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -479,9 +480,29 @@ function resolveEnvironmentVariables(
const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> =
[]
const resolverVars: Record<string, string> = {}
Object.entries(params).forEach(([key, value]) => {
if (value) {
resolverVars[key] = String(value)
}
})
Object.entries(envVars).forEach(([key, value]) => {
if (value) {
resolverVars[key] = value
}
})
while ((match = regex.exec(code)) !== null) {
const varName = match[1].trim()
const varValue = envVars[varName] || params[varName] || ''
const resolved = resolveEnvVarReferences(match[0], resolverVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'empty',
deep: false,
})
const varValue =
typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved)
replacements.push({
match: match[0],
index: match.index,

View File

@@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -52,6 +53,8 @@ async function getServer(serverId: string) {
id: workflowMcpServer.id,
name: workflowMcpServer.name,
workspaceId: workflowMcpServer.workspaceId,
isPublic: workflowMcpServer.isPublic,
createdBy: workflowMcpServer.createdBy,
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.id, serverId))
@@ -90,9 +93,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!server.isPublic) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
const body = await request.json()
@@ -138,7 +143,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
id,
serverId,
rpcParams as { name: string; arguments?: Record<string, unknown> },
apiKey
apiKey,
server.isPublic ? server.createdBy : undefined
)
default:
@@ -200,7 +206,8 @@ async function handleToolsCall(
id: RequestId,
serverId: string,
params: { name: string; arguments?: Record<string, unknown> } | undefined,
apiKey?: string | null
apiKey?: string | null,
publicServerOwnerId?: string
): Promise<NextResponse> {
try {
if (!params?.name) {
@@ -243,7 +250,13 @@ async function handleToolsCall(
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['X-API-Key'] = apiKey
if (publicServerOwnerId) {
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (apiKey) {
headers['X-API-Key'] = apiKey
}
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)

View File

@@ -5,8 +5,7 @@ import { McpClient } from '@/lib/mcp/client'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
const logger = createLogger('McpServerTestAPI')
@@ -24,22 +23,23 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
* Resolve environment variables in strings
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const envVarPattern = createEnvVarPattern()
const envMatches = value.match(envVarPattern)
if (!envMatches) return value
const missingVars: string[] = []
const resolvedValue = resolveEnvVarReferences(value, envVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'keep',
deep: false,
missingKeys: missingVars,
}) as string
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
const envValue = envVars[envKey]
if (envValue === undefined) {
if (missingVars.length > 0) {
const uniqueMissing = Array.from(new Set(missingVars))
uniqueMissing.forEach((envKey) => {
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
continue
}
resolvedValue = resolvedValue.replace(match, envValue)
})
}
return resolvedValue
}

View File

@@ -31,6 +31,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
})
@@ -98,6 +99,9 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
if (body.description !== undefined) {
updateData.description = body.description?.trim() || null
}
if (body.isPublic !== undefined) {
updateData.isPublic = body.isPublic
}
const [updatedServer] = await db
.update(workflowMcpServer)

View File

@@ -26,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -72,7 +71,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -139,7 +137,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)

View File

@@ -6,24 +6,10 @@ import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpToolsAPI')
/**
* Check if a workflow has a valid start block by loading from database
*/
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
return hasValidStartBlockInState(normalizedData)
} catch (error) {
logger.warn('Error checking for start block:', error)
return false
}
}
export const dynamic = 'force-dynamic'
interface RouteParams {
@@ -40,7 +26,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -53,7 +38,6 @@ export const GET = withMcpAuth<RouteParams>('read')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Get tools with workflow details
const tools = await db
.select({
id: workflowMcpTool.id,
@@ -107,7 +91,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify server exists and belongs to workspace
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
@@ -120,7 +103,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
// Verify workflow exists and is deployed
const [workflowRecord] = await db
.select({
id: workflow.id,
@@ -137,7 +119,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
}
// Verify workflow belongs to the same workspace
if (workflowRecord.workspaceId !== workspaceId) {
return createMcpErrorResponse(
new Error('Workflow does not belong to this workspace'),
@@ -154,7 +135,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Verify workflow has a valid start block
const hasStartBlock = await hasValidStartBlock(body.workflowId)
if (!hasStartBlock) {
return createMcpErrorResponse(
@@ -164,7 +144,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
)
}
// Check if tool already exists for this workflow
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
@@ -190,7 +169,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
// Create the tool
const toolId = crypto.randomUUID()
const [tool] = await db
.insert(workflowMcpTool)

View File

@@ -1,10 +1,12 @@
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpServersAPI')
@@ -25,18 +27,18 @@ export const GET = withMcpAuth('read')(
createdBy: workflowMcpServer.createdBy,
name: workflowMcpServer.name,
description: workflowMcpServer.description,
isPublic: workflowMcpServer.isPublic,
createdAt: workflowMcpServer.createdAt,
updatedAt: workflowMcpServer.updatedAt,
toolCount: sql<number>`(
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
SELECT COUNT(*)::int
FROM "workflow_mcp_tool"
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
)`.as('tool_count'),
})
.from(workflowMcpServer)
.where(eq(workflowMcpServer.workspaceId, workspaceId))
// Fetch all tools for these servers
const serverIds = servers.map((s) => s.id)
const tools =
serverIds.length > 0
@@ -49,7 +51,6 @@ export const GET = withMcpAuth('read')(
.where(inArray(workflowMcpTool.serverId, serverIds))
: []
// Group tool names by server
const toolNamesByServer: Record<string, string[]> = {}
for (const tool of tools) {
if (!toolNamesByServer[tool.serverId]) {
@@ -58,7 +59,6 @@ export const GET = withMcpAuth('read')(
toolNamesByServer[tool.serverId].push(tool.toolName)
}
// Attach tool names to servers
const serversWithToolNames = servers.map((server) => ({
...server,
toolNames: toolNamesByServer[server.id] || [],
@@ -90,6 +90,7 @@ export const POST = withMcpAuth('write')(
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})
if (!body.name) {
@@ -110,16 +111,76 @@ export const POST = withMcpAuth('write')(
createdBy: userId,
name: body.name.trim(),
description: body.description?.trim() || null,
isPublic: body.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = body.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(inArray(workflow.id, workflowIds))
for (const workflowRecord of workflows) {
if (workflowRecord.workspaceId !== workspaceId) {
logger.warn(
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
)
continue
}
if (!workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
continue
}
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
if (!hasStartBlock) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
continue
}
const toolName = sanitizeToolName(workflowRecord.name)
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const toolId = crypto.randomUUID()
await db.insert(workflowMcpTool).values({
id: toolId,
serverId,
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema: {},
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: workflowRecord.id, toolName })
}
logger.info(
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
addedTools.map((t) => t.toolName)
)
}
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ server }, 201)
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(

View File

@@ -57,6 +57,7 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -92,6 +93,17 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -134,6 +146,7 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -169,6 +182,17 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -206,6 +230,7 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -228,6 +253,17 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})
@@ -265,6 +301,7 @@ describe('Scheduled Workflow Execution API Route', () => {
not: vi.fn((condition) => ({ type: 'not', condition })),
isNull: vi.fn((field) => ({ type: 'isNull', field })),
or: vi.fn((...conditions) => ({ type: 'or', conditions })),
sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })),
}))
vi.doMock('@sim/db', () => {
@@ -310,6 +347,17 @@ describe('Scheduled Workflow Execution API Route', () => {
status: 'status',
nextRunAt: 'nextRunAt',
lastQueuedAt: 'lastQueuedAt',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
workflow: {
id: 'id',
userId: 'userId',
workspaceId: 'workspaceId',
},
}
})

View File

@@ -1,7 +1,7 @@
import { db, workflowSchedule } from '@sim/db'
import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db'
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm'
import { and, eq, isNull, lt, lte, not, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
@@ -37,7 +37,8 @@ export async function GET(request: NextRequest) {
or(
isNull(workflowSchedule.lastQueuedAt),
lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt)
)
),
sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)`
)
)
.returning({

View File

@@ -29,12 +29,23 @@ vi.mock('@sim/db', () => ({
vi.mock('@sim/db/schema', () => ({
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' },
workflowSchedule: {
workflowId: 'workflowId',
blockId: 'blockId',
deploymentVersionId: 'deploymentVersionId',
},
workflowDeploymentVersion: {
id: 'id',
workflowId: 'workflowId',
isActive: 'isActive',
},
}))
vi.mock('drizzle-orm', () => ({
eq: vi.fn(),
and: vi.fn(),
or: vi.fn(),
isNull: vi.fn(),
}))
vi.mock('@/lib/core/utils/request', () => ({
@@ -56,6 +67,11 @@ function mockDbChain(results: any[]) {
where: () => ({
limit: () => results[callIndex++] || [],
}),
leftJoin: () => ({
where: () => ({
limit: () => results[callIndex++] || [],
}),
}),
}),
}))
}
@@ -74,7 +90,16 @@ describe('Schedule GET API', () => {
it('returns schedule data for authorized user', async () => {
mockDbChain([
[{ userId: 'user-1', workspaceId: null }],
[{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }],
[
{
schedule: {
id: 'sched-1',
cronExpression: '0 9 * * *',
status: 'active',
failedCount: 0,
},
},
],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
@@ -128,7 +153,7 @@ describe('Schedule GET API', () => {
it('allows workspace members to view', async () => {
mockDbChain([
[{ userId: 'other-user', workspaceId: 'ws-1' }],
[{ id: 'sched-1', status: 'active', failedCount: 0 }],
[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
@@ -139,7 +164,7 @@ describe('Schedule GET API', () => {
it('indicates disabled schedule with failures', async () => {
mockDbChain([
[{ userId: 'user-1', workspaceId: null }],
[{ id: 'sched-1', status: 'disabled', failedCount: 100 }],
[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }],
])
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowSchedule } from '@sim/db/schema'
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -62,9 +62,24 @@ export async function GET(req: NextRequest) {
}
const schedule = await db
.select()
.select({ schedule: workflowSchedule })
.from(workflowSchedule)
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
...conditions,
or(
eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId))
)
)
)
.limit(1)
const headers = new Headers()
@@ -74,7 +89,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ schedule: null }, { headers })
}
const scheduleData = schedule[0]
const scheduleData = schedule[0].schedule
const isDisabled = scheduleData.status === 'disabled'
const hasFailures = scheduleData.failedCount > 0

View File

@@ -60,7 +60,17 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
if (!deployResult.deploymentVersionId) {
await undeployWorkflow({ workflowId })
return internalErrorResponse('Failed to resolve deployment version')
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
db,
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { webhook, workflow } from '@sim/db/schema'
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -71,7 +71,23 @@ export async function GET(request: NextRequest) {
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.orderBy(desc(webhook.updatedAt))
logger.info(
@@ -149,7 +165,23 @@ export async function POST(request: NextRequest) {
const existingForBlock = await db
.select({ id: webhook.id, path: webhook.path })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.limit(1)
if (existingForBlock.length > 0) {
@@ -225,7 +257,23 @@ export async function POST(request: NextRequest) {
const existingForBlock = await db
.select({ id: webhook.id })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(webhook.workflowId, workflowId),
eq(webhook.blockId, blockId),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
)
)
)
.limit(1)
if (existingForBlock.length > 0) {
targetWebhookId = existingForBlock[0].id

View File

@@ -152,7 +152,6 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
executionTarget: 'deployed',
})
responses.push(response)
}

View File

@@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
.select({
id: chat.id,
identifier: chat.identifier,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
password: chat.password,
isActive: chat.isActive,
})
.from(chat)
@@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
? {
id: deploymentResults[0].id,
identifier: deploymentResults[0].identifier,
title: deploymentResults[0].title,
description: deploymentResults[0].description,
customizations: deploymentResults[0].customizations,
authType: deploymentResults[0].authType,
allowedEmails: deploymentResults[0].allowedEmails,
outputConfigs: deploymentResults[0].outputConfigs,
hasPassword: Boolean(deploymentResults[0].password),
}
: null

View File

@@ -10,7 +10,11 @@ import {
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -131,22 +135,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
@@ -158,14 +146,58 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const deployedAt = deployResult.deployedAt!
const deploymentVersionId = deployResult.deploymentVersionId
if (!deploymentVersionId) {
await undeployWorkflow({ workflowId: id })
return createErrorResponse('Failed to resolve deployment version', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData,
userId: actorUserId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await undeployWorkflow({ workflowId: id })
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to save trigger configuration',
triggerSaveResult.error?.status || 500
)
}
let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {}
const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db)
const scheduleResult = await createSchedulesForDeploy(
id,
normalizedData.blocks,
db,
deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}`
)
} else if (scheduleResult.scheduleId) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId,
})
await undeployWorkflow({ workflowId: id })
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
if (scheduleResult.scheduleId) {
scheduleInfo = {
scheduleId: scheduleResult.scheduleId,
cronExpression: scheduleResult.cronExpression,

View File

@@ -1,10 +1,19 @@
import { db, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowActivateDeploymentAPI')
@@ -19,30 +28,135 @@ export async function POST(
const { id, version } = await params
try {
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const actorUserId = session?.user?.id
if (!actorUserId) {
logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`)
return createErrorResponse('Unable to determine activating user', 400)
}
const versionNum = Number(version)
if (!Number.isFinite(versionNum)) {
return createErrorResponse('Invalid version number', 400)
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return createErrorResponse('Deployment version not found', 404)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return createErrorResponse('Invalid deployed state structure', 500)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
})
if (!triggerSaveResult.success) {
return createErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration',
triggerSaveResult.error?.status || 500
)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: versionRow.id,
})
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
if (result.state) {
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: result.state,
context: 'activate',
})
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionRow.state,
context: 'activate',
})
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)

View File

@@ -110,6 +110,7 @@ type AsyncExecutionParams = {
userId: string
input: any
triggerType: CoreTriggerType
preflighted?: boolean
}
/**
@@ -132,6 +133,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
userId,
input,
triggerType,
preflighted: params.preflighted,
}
try {
@@ -264,6 +266,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId
)
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -272,6 +275,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
preflightEnvVars: shouldPreflightEnvVars,
useDraftState: shouldUseDraftState,
envUserId: isClientSession ? userId : undefined,
})
if (!preprocessResult.success) {
@@ -303,6 +309,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId: actorUserId,
input,
triggerType: loggingTriggerType,
preflighted: shouldPreflightEnvVars,
})
}

View File

@@ -77,7 +77,7 @@ export function DeleteChunkModal({
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' disabled={isDeleting} onClick={onClose}>
<Button variant='default' disabled={isDeleting} onClick={onClose}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteChunk} disabled={isDeleting}>

View File

@@ -392,7 +392,7 @@ export function DocumentTagsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalContent size='sm'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Document Tags</span>
@@ -486,7 +486,7 @@ export function DocumentTagsModal({
/>
)}
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
<span className='text-[12px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
@@ -639,7 +639,7 @@ export function DocumentTagsModal({
/>
)}
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
<span className='text-[12px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}

View File

@@ -48,7 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
const logger = createLogger('Document')
@@ -313,69 +313,22 @@ export function Document({
isFetching: isFetchingChunks,
} = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL)
const [searchResults, setSearchResults] = useState<ChunkData[]>([])
const [isLoadingSearch, setIsLoadingSearch] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
useEffect(() => {
if (!debouncedSearchQuery.trim()) {
setSearchResults([])
setSearchError(null)
return
const {
data: searchResults = [],
isLoading: isLoadingSearch,
error: searchQueryError,
} = useDocumentChunkSearchQuery(
{
knowledgeBaseId,
documentId,
search: debouncedSearchQuery,
},
{
enabled: Boolean(debouncedSearchQuery.trim()),
}
)
let isMounted = true
const searchAllChunks = async () => {
try {
setIsLoadingSearch(true)
setSearchError(null)
const allResults: ChunkData[] = []
let hasMore = true
let offset = 0
const limit = 100
while (hasMore && isMounted) {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}`
)
if (!response.ok) {
throw new Error('Search failed')
}
const result = await response.json()
if (result.success && result.data) {
allResults.push(...result.data)
hasMore = result.pagination?.hasMore || false
offset += limit
} else {
hasMore = false
}
}
if (isMounted) {
setSearchResults(allResults)
}
} catch (err) {
if (isMounted) {
setSearchError(err instanceof Error ? err.message : 'Search failed')
}
} finally {
if (isMounted) {
setIsLoadingSearch(false)
}
}
}
searchAllChunks()
return () => {
isMounted = false
}
}, [debouncedSearchQuery, knowledgeBaseId, documentId])
const searchError = searchQueryError instanceof Error ? searchQueryError.message : null
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)
@@ -1208,15 +1161,19 @@ export function Document({
<ModalHeader>Delete Document</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete "{effectiveDocumentName}"? This will permanently
delete the document and all {documentData?.chunkCount ?? 0} chunk
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
</span>
? This will permanently delete the document and all {documentData?.chunkCount ?? 0}{' '}
chunk
{documentData?.chunkCount === 1 ? '' : 's'} within it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='active'
variant='default'
onClick={() => setShowDeleteDocumentDialog(false)}
disabled={isDeletingDocument}
>

View File

@@ -1523,15 +1523,16 @@ export function KnowledgeBase({
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete "{knowledgeBaseName}"? This will permanently delete
the knowledge base and all {pagination.total} document
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
This will permanently delete the knowledge base and all {pagination.total} document
{pagination.total === 1 ? '' : 's'} within it.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='active'
variant='default'
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
@@ -1549,14 +1550,16 @@ export function KnowledgeBase({
<ModalHeader>Delete Document</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete "
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}"?{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{documents.find((doc) => doc.id === documentToDelete)?.filename ?? 'this document'}
</span>
? <span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='active'
variant='default'
onClick={() => {
setShowDeleteDocumentModal(false)
setDocumentToDelete(null)
@@ -1582,7 +1585,7 @@ export function KnowledgeBase({
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={() => setShowBulkDeleteModal(false)}>
<Button variant='default' onClick={() => setShowBulkDeleteModal(false)}>
Cancel
</Button>
<Button variant='destructive' onClick={confirmBulkDelete} disabled={isBulkOperating}>

View File

@@ -221,14 +221,14 @@ export function AddDocumentsModal({
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalContent size='md'>
<ModalHeader>Add Documents</ModalHeader>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{fileError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
)}
<div className='flex flex-col gap-[8px]'>
@@ -336,7 +336,7 @@ export function AddDocumentsModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{uploadError ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
{uploadError.message}
</p>
) : (

View File

@@ -306,7 +306,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return (
<>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalContent size='sm'>
<ModalHeader>
<div className='flex items-center justify-between'>
<span>Tags</span>
@@ -400,7 +400,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
}}
/>
{tagNameConflict && (
<span className='text-[11px] text-[var(--text-error)]'>
<span className='text-[12px] text-[var(--text-error)]'>
A tag with this name already exists
</span>
)}
@@ -417,7 +417,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
placeholder='Select type'
/>
{!hasAvailableSlots(createTagForm.fieldType) && (
<span className='text-[11px] text-[var(--text-error)]'>
<span className='text-[12px] text-[var(--text-error)]'>
No available slots for this type. Choose a different type.
</span>
)}

View File

@@ -77,7 +77,7 @@ export function RenameDocumentModal({
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalContent size='sm'>
<ModalHeader>Rename Document</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
@@ -108,7 +108,7 @@ export function RenameDocumentModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (

View File

@@ -332,7 +332,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
return (
<Modal open={open} onOpenChange={handleClose}>
<ModalContent>
<ModalContent size='lg'>
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
@@ -528,7 +528,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
)}
{fileError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{fileError}</p>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{fileError}</p>
)}
</div>
</div>
@@ -537,7 +537,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{submitStatus?.type === 'error' || uploadError ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
{uploadError?.message || submitStatus?.message}
</p>
) : (

View File

@@ -38,7 +38,7 @@ export function DeleteKnowledgeBaseModal({
}: DeleteKnowledgeBaseModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -55,7 +55,7 @@ export function DeleteKnowledgeBaseModal({
</p>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={onClose} disabled={isDeleting}>
<Button variant='default' onClick={onClose} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>

View File

@@ -98,7 +98,7 @@ export function EditKnowledgeBaseModal({
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalContent size='sm'>
<ModalHeader>Edit Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
@@ -118,7 +118,7 @@ export function EditKnowledgeBaseModal({
data-form-type='other'
/>
{errors.name && (
<p className='text-[11px] text-[var(--text-error)]'>{errors.name.message}</p>
<p className='text-[12px] text-[var(--text-error)]'>{errors.name.message}</p>
)}
</div>
@@ -132,7 +132,7 @@ export function EditKnowledgeBaseModal({
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
{errors.description && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{errors.description.message}
</p>
)}
@@ -143,7 +143,7 @@ export function EditKnowledgeBaseModal({
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
<p className='min-w-0 flex-1 truncate text-[12px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (

View File

@@ -112,7 +112,7 @@ export function SlackChannelSelector({
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
</p>
)}
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}

View File

@@ -1,9 +1,10 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { X } from 'lucide-react'
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useWorkflows } from '@/hooks/queries/workflows'
interface WorkflowSelectorProps {
workspaceId: string
@@ -25,26 +26,9 @@ export function WorkflowSelector({
onChange,
error,
}: WorkflowSelectorProps) {
const [workflows, setWorkflows] = useState<Array<{ id: string; name: string }>>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
setIsLoading(true)
const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
if (response.ok) {
const data = await response.json()
setWorkflows(data.data || [])
}
} catch {
setWorkflows([])
} finally {
setIsLoading(false)
}
}
load()
}, [workspaceId])
const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, {
syncRegistry: false,
})
const options: ComboboxOption[] = useMemo(() => {
return workflows.map((w) => ({

View File

@@ -634,7 +634,7 @@ export function NotificationSettings({
}}
/>
{formErrors.webhookUrl && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.webhookUrl}</p>
)}
</div>
<div className='flex flex-col gap-[8px]'>
@@ -660,7 +660,7 @@ export function NotificationSettings({
placeholderWithTags='Add email'
/>
{formErrors.emailRecipients && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.emailRecipients}</p>
)}
</div>
)}
@@ -707,7 +707,7 @@ export function NotificationSettings({
/>
)}
{formErrors.slackAccountId && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.slackAccountId}
</p>
)}
@@ -776,7 +776,7 @@ export function NotificationSettings({
allOptionLabel='All levels'
/>
{formErrors.levelFilter && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.levelFilter}</p>
)}
</div>
@@ -822,7 +822,7 @@ export function NotificationSettings({
allOptionLabel='All triggers'
/>
{formErrors.triggerFilter && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.triggerFilter}</p>
)}
</div>
@@ -938,7 +938,7 @@ export function NotificationSettings({
}
/>
{formErrors.consecutiveFailures && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.consecutiveFailures}
</p>
)}
@@ -962,7 +962,7 @@ export function NotificationSettings({
}
/>
{formErrors.failureRatePercent && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.failureRatePercent}
</p>
)}
@@ -982,7 +982,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1004,7 +1004,7 @@ export function NotificationSettings({
}
/>
{formErrors.durationThresholdMs && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.durationThresholdMs}
</p>
)}
@@ -1028,7 +1028,7 @@ export function NotificationSettings({
}
/>
{formErrors.latencySpikePercent && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.latencySpikePercent}
</p>
)}
@@ -1048,7 +1048,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1071,7 +1071,7 @@ export function NotificationSettings({
}
/>
{formErrors.costThresholdDollars && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.costThresholdDollars}
</p>
)}
@@ -1094,7 +1094,7 @@ export function NotificationSettings({
}
/>
{formErrors.inactivityHours && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.inactivityHours}</p>
)}
</div>
)}
@@ -1116,7 +1116,7 @@ export function NotificationSettings({
}
/>
{formErrors.errorCountThreshold && (
<p className='text-[11px] text-[var(--text-error)]'>
<p className='text-[12px] text-[var(--text-error)]'>
{formErrors.errorCountThreshold}
</p>
)}
@@ -1136,7 +1136,7 @@ export function NotificationSettings({
}
/>
{formErrors.windowHours && (
<p className='text-[11px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
<p className='text-[12px] text-[var(--text-error)]'>{formErrors.windowHours}</p>
)}
</div>
</div>
@@ -1261,7 +1261,7 @@ export function NotificationSettings({
</Modal>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -2,6 +2,7 @@ import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -97,7 +98,7 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isStartBlock = isValidStartBlockType(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'

View File

@@ -8,6 +8,7 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
/**
* Block information for context menu actions
@@ -73,9 +74,7 @@ export function BlockMenu({
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some(
(b) => b.type === 'starter' || b.type === 'start_trigger'
)
const hasStarterBlock = selectedBlocks.some((b) => isValidStartBlockType(b.type))
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')

View File

@@ -995,7 +995,7 @@ export function Chat() {
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-3 w-3 shrink-0 text-[var(--text-error)]' />
<div className='flex-1'>
<div className='mb-1 font-medium text-[11px] text-[var(--text-error)]'>
<div className='mb-1 font-medium text-[12px] text-[var(--text-error)]'>
File upload error
</div>
<div className='space-y-1'>

View File

@@ -9,8 +9,6 @@ import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
@@ -19,26 +17,22 @@ const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
)
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
const { updatePreviewToolCallState } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
currentChat: state.currentChat,
messages: state.messages,
}),
[]
)
@@ -54,154 +48,6 @@ export const DiffControls = memo(function DiffControls() {
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
workflowId: activeWorkflowId,
chatId: currentChat?.id,
})
return false
}
try {
logger.info('Creating checkpoint before accepting changes')
// Use the baseline workflow (state before diff) instead of current state
// This ensures reverting to the checkpoint restores the pre-diff state
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
// The baseline already has merged subblock values, but we'll merge again to be safe
// This ensures all user inputs and subblock data are captured
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
// Filter and complete blocks to ensure all required fields are present
// This matches the validation logic from /api/workflows/[id]/state
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
...block,
id: block.id || blockId, // Ensure id field is set
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles:
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 }, // Ensure position exists
}
}
return acc
},
{} as typeof rawState.blocks
)
// Clean the workflow state - only include valid fields, exclude null/undefined values
const workflowState = {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
logger.info('Prepared complete workflow state for checkpoint', {
blocksCount: Object.keys(workflowState.blocks).length,
edgesCount: workflowState.edges.length,
loopsCount: Object.keys(workflowState.loops).length,
parallelsCount: Object.keys(workflowState.parallels).length,
hasRequiredFields: Object.values(workflowState.blocks).every(
(block) => block.id && block.type && block.name && block.position
),
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
Object.values(block.subBlocks || {}).some(
(subblock) => subblock.value !== null && subblock.value !== undefined
)
),
sampleBlock: Object.values(workflowState.blocks)[0],
})
// Find the most recent user message ID from the current chat
const userMessages = messages.filter((msg) => msg.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
const messageId = lastUserMessage?.id
logger.info('Creating checkpoint with message association', {
totalMessages: messages.length,
userMessageCount: userMessages.length,
lastUserMessageId: messageId,
chatId: currentChat.id,
entireMessageArray: messages,
allMessageIds: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content.substring(0, 50),
})),
selectedUserMessages: userMessages.map((m) => ({
id: m.id,
content: m.content.substring(0, 100),
})),
allRawMessageIds: messages.map((m) => m.id),
userMessageIds: userMessages.map((m) => m.id),
checkpointData: {
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId: messageId,
messageFound: !!lastUserMessage,
},
})
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(workflowState),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
logger.info('Checkpoint created successfully', {
messageId,
chatId: currentChat.id,
checkpointId: newCheckpoint?.id,
})
// Update the copilot store immediately to show the checkpoint icon
if (newCheckpoint && messageId) {
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const existingCheckpoints = currentCheckpoints[messageId] || []
const updatedCheckpoints = {
...currentCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
logger.info('Updated copilot store with new checkpoint', {
messageId,
checkpointId: newCheckpoint.id,
})
}
return true
} catch (error) {
logger.error('Failed to create checkpoint:', error)
return false
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
@@ -238,12 +84,8 @@ export const DiffControls = memo(function DiffControls() {
})
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
createCheckpoint().catch((error) => {
logger.warn('Failed to create checkpoint after accept:', error)
})
logger.info('Accept triggered; UI will update optimistically')
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
}, [updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')

View File

@@ -168,12 +168,17 @@ const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }
)
})
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
export const NoteBlock = memo(function NoteBlock({
id,
data,
selected,
}: NodeProps<NoteBlockNodeData>) {
const { type, config, name } = data
const { activeWorkflowId, isEnabled, handleClick, hasRing, ringStyles } = useBlockVisual({
blockId: id,
data,
isSelected: selected,
})
const storedValues = useSubBlockStore(
useCallback(

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
/**
@@ -6,14 +7,23 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
*/
const CHARACTER_DELAY = 3
/**
* Props for the StreamingIndicator component
*/
interface StreamingIndicatorProps {
/** Optional class name for layout adjustments */
className?: string
}
/**
* StreamingIndicator shows animated dots during message streaming
* Used as a standalone indicator when no content has arrived yet
*
* @param props - Component props
* @returns Animated loading indicator
*/
export const StreamingIndicator = memo(() => (
<div className='flex h-[1.25rem] items-center text-muted-foreground'>
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
<div className='flex space-x-0.5'>
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms] [animation-duration:1.2s]' />
<div className='h-1 w-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms] [animation-duration:1.2s]' />

View File

@@ -1,10 +1,20 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Removes thinking tags (raw or escaped) from streamed content.
*/
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.trim()
}
/**
* Max height for thinking content before internal scrolling kicks in
*/
@@ -187,6 +197,9 @@ export function ThinkingBlock({
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
// Strip thinking tags from content on render to handle persisted messages
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
@@ -209,10 +222,10 @@ export function ThinkingBlock({
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
// Reset start time when streaming begins
useEffect(() => {
@@ -298,7 +311,7 @@ export function ThinkingBlock({
return `${seconds}s`
}
const hasContent = content && content.trim().length > 0
const hasContent = cleanContent.length > 0
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
const durationText = `${label} for ${formatDuration(duration)}`
@@ -374,7 +387,10 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
<SmoothThinkingText
content={cleanContent}
isStreaming={isStreaming && !hasFollowingContent}
/>
</div>
</div>
)
@@ -412,7 +428,7 @@ export function ThinkingBlock({
>
{/* Completed thinking text - dimmed with markdown */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={content} />
<CopilotMarkdownRenderer content={cleanContent} />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
@@ -93,6 +93,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
// Checkpoint management hook
const {
showRestoreConfirmation,
@@ -112,7 +114,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
messages,
messageCheckpoints,
onRevertModeChange,
onEditModeChange
onEditModeChange,
() => cancelEditRef.current?.()
)
// Message editing hook
@@ -142,6 +145,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef,
})
cancelEditRef.current = handleCancelEdit
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
@@ -488,8 +493,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Streaming indicator always at bottom during streaming */}
{isStreaming && <StreamingIndicator />}
{isStreaming && (
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
)}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -22,7 +22,8 @@ export function useCheckpointManagement(
messages: CopilotMessage[],
messageCheckpoints: any[],
onRevertModeChange?: (isReverting: boolean) => void,
onEditModeChange?: (isEditing: boolean) => void
onEditModeChange?: (isEditing: boolean) => void,
onCancelEdit?: () => void
) {
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
@@ -57,7 +58,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -93,7 +94,6 @@ export function useCheckpointManagement(
setShowRestoreConfirmation(false)
onRevertModeChange?.(false)
onEditModeChange?.(true)
logger.info('Checkpoint reverted and removed from message', {
messageId: message.id,
@@ -114,7 +114,6 @@ export function useCheckpointManagement(
messages,
currentChat,
onRevertModeChange,
onEditModeChange,
])
/**
@@ -140,7 +139,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -154,6 +153,8 @@ export function useCheckpointManagement(
}
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
const { sendMessage } = useCopilotStore.getState()
if (pendingEditRef.current) {
@@ -173,6 +174,7 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
@@ -180,15 +182,17 @@ export function useCheckpointManagement(
} finally {
setIsProcessingDiscard(false)
}
}, [messageCheckpoints, revertToCheckpoint, message, messages])
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
/**
* Cancels checkpoint discard and clears pending edit
*/
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
pendingEditRef.current = null
}, [])
}, [onEditModeChange, onCancelEdit])
/**
* Continues with edit WITHOUT reverting checkpoint
@@ -214,11 +218,12 @@ export function useCheckpointManagement(
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
pendingEditRef.current = null
}
}, [message, messages])
}, [message, messages, onEditModeChange, onCancelEdit])
/**
* Handles keyboard events for restore confirmation (Escape/Enter)

View File

@@ -166,6 +166,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
fileAttachments: fileAttachments || message.fileAttachments,
contexts: contexts || (message as any).contexts,
messageId: message.id,
queueIfBusy: false,
})
}
},

View File

@@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
blockType = blockType || op.block_type || ''
}
// Fallback name to type or ID
if (!blockName) blockName = blockType || blockId
if (!blockName) blockName = blockType || ''
if (!blockName && !blockType) {
continue
}
const change: BlockChange = { blockId, blockName, blockType }

View File

@@ -22,6 +22,9 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
const escapeRegex = useCallback((value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}, [])
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {
@@ -78,10 +81,10 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const tokenWithSpaces = ` ${prefix}${c.label} `
const tokenAtStart = `${prefix}${c.label} `
// Token can appear with leading space OR at the start of the message
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
const tokenPattern = new RegExp(
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`
)
return tokenPattern.test(message)
})
return filtered.length === prev.length ? prev : filtered
})

View File

@@ -76,6 +76,15 @@ export function useMentionTokens({
ranges.push({ start: idx, end: idx + token.length, label })
fromIndex = idx + token.length
}
// Token at end of message without trailing space: "@label" or " /label"
const tokenAtEnd = `${prefix}${label}`
if (message.endsWith(tokenAtEnd)) {
const idx = message.lastIndexOf(tokenAtEnd)
const hasLeadingSpace = idx > 0 && message[idx - 1] === ' '
const start = hasLeadingSpace ? idx - 1 : idx
ranges.push({ start, end: message.length, label })
}
}
ranges.sort((a, b) => a.start - b.start)

View File

@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled || isLoading) return
if (disabled) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
[disabled, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
@@ -737,7 +737,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert @'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
disabled && 'cursor-not-allowed'
)}
>
<AtSign className='h-3 w-3' strokeWidth={1.75} />
@@ -749,7 +749,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
disabled && 'cursor-not-allowed'
)}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
@@ -816,7 +816,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={fileAttachments.isDragging ? 'Drop files here...' : effectivePlaceholder}
disabled={disabled}
rows={2}
className='relative z-[2] m-0 box-border h-auto min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
className='relative z-[2] m-0 box-border h-auto max-h-[120px] min-h-[48px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-medium font-sans text-sm text-transparent leading-[1.25rem] caret-foreground outline-none [-ms-overflow-style:none] [scrollbar-width:none] [text-rendering:auto] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 dark:placeholder:text-[var(--text-muted)] [&::-webkit-scrollbar]:hidden'
/>
{/* Mention Menu Portal */}

View File

@@ -83,8 +83,7 @@ interface A2aDeployProps {
workflowNeedsRedeployment?: boolean
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onAgentExistsChange?: (exists: boolean) => void
onPublishedChange?: (published: boolean) => void
/** Callback for when republish status changes - depends on local form state */
onNeedsRepublishChange?: (needsRepublish: boolean) => void
onDeployWorkflow?: () => Promise<void>
}
@@ -99,8 +98,6 @@ export function A2aDeploy({
workflowNeedsRedeployment,
onSubmittingChange,
onCanSaveChange,
onAgentExistsChange,
onPublishedChange,
onNeedsRepublishChange,
onDeployWorkflow,
}: A2aDeployProps) {
@@ -236,14 +233,6 @@ export function A2aDeploy({
}
}, [existingAgent, workflowName, workflowDescription])
useEffect(() => {
onAgentExistsChange?.(!!existingAgent)
}, [existingAgent, onAgentExistsChange])
useEffect(() => {
onPublishedChange?.(existingAgent?.isPublished ?? false)
}, [existingAgent?.isPublished, onPublishedChange])
const hasFormChanges = useMemo(() => {
if (!existingAgent) return false
const savedSchemes = existingAgent.authentication?.schemes || []

View File

@@ -29,9 +29,11 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import {
type AuthType,
type ChatFormData,
useChatDeployment,
useIdentifierValidation,
} from './hooks'
useCreateChat,
useDeleteChat,
useUpdateChat,
} from '@/hooks/queries/chats'
import { useIdentifierValidation } from './hooks'
const logger = createLogger('ChatDeploy')
@@ -45,7 +47,6 @@ interface ChatDeployProps {
existingChat: ExistingChat | null
isLoadingChat: boolean
onRefetchChat: () => Promise<void>
onChatExistsChange?: (exists: boolean) => void
chatSubmitting: boolean
setChatSubmitting: (submitting: boolean) => void
onValidationChange?: (isValid: boolean) => void
@@ -97,7 +98,6 @@ export function ChatDeploy({
existingChat,
isLoadingChat,
onRefetchChat,
onChatExistsChange,
chatSubmitting,
setChatSubmitting,
onValidationChange,
@@ -121,8 +121,11 @@ export function ChatDeploy({
const [formData, setFormData] = useState<ChatFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const { deployChat } = useChatDeployment()
const formRef = useRef<HTMLFormElement>(null)
const createChatMutation = useCreateChat()
const updateChatMutation = useUpdateChat()
const deleteChatMutation = useDeleteChat()
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const [hasInitializedForm, setHasInitializedForm] = useState(false)
@@ -231,15 +234,26 @@ export function ChatDeploy({
return
}
const chatUrl = await deployChat(
workflowId,
formData,
deploymentInfo,
existingChat?.id,
imageUrl
)
let chatUrl: string
if (existingChat?.id) {
const result = await updateChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
formData,
imageUrl,
})
chatUrl = result.chatUrl
} else {
const result = await createChatMutation.mutateAsync({
workflowId,
formData,
apiKey: deploymentInfo?.apiKey,
imageUrl,
})
chatUrl = result.chatUrl
}
onChatExistsChange?.(true)
onDeployed?.()
onVersionActivated?.()
@@ -266,18 +280,13 @@ export function ChatDeploy({
try {
setIsDeleting(true)
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
method: 'DELETE',
await deleteChatMutation.mutateAsync({
chatId: existingChat.id,
workflowId,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete chat')
}
setImageUrl(null)
setHasInitializedForm(false)
onChatExistsChange?.(false)
await onRefetchChat()
onDeploymentComplete?.()
@@ -548,7 +557,7 @@ function IdentifierInput({
)}
</div>
</div>
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
<p className='mt-[6.5px] truncate text-[11px] text-[var(--text-secondary)]'>
{isEditingExisting && value ? (
<>
@@ -768,7 +777,7 @@ function AuthSelector({
disabled={disabled}
/>
{emailError && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{emailError}</p>
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{emailError}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{authType === 'email'
@@ -778,7 +787,7 @@ function AuthSelector({
</div>
)}
{error && <p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{error}</p>}
{error && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
)
}

View File

@@ -1,2 +1 @@
export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment'
export { useIdentifierValidation } from './use-identifier-validation'

View File

@@ -1,131 +0,0 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { OutputConfig } from '@/stores/chat/types'
const logger = createLogger('ChatDeployment')
export type AuthType = 'public' | 'password' | 'email' | 'sso'
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
identifier: z
.string()
.min(1, 'Identifier is required')
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
customizations: z.object({
primaryColor: z.string(),
welcomeMessage: z.string(),
imageUrl: z.string().optional(),
}),
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
password: z.string().optional(),
allowedEmails: z.array(z.string()).optional().default([]),
outputConfigs: z
.array(
z.object({
blockId: z.string(),
path: z.string(),
})
)
.optional()
.default([]),
})
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Hook for deploying or updating a chat interface
*/
export function useChatDeployment() {
const deployChat = useCallback(
async (
workflowId: string,
formData: ChatFormData,
deploymentInfo: { apiKey: string } | null,
existingChatId?: string,
imageUrl?: string | null
): Promise<string> => {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
const payload = {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
apiKey: deploymentInfo?.apiKey,
deployApiEnabled: !existingChatId,
}
chatSchema.parse(payload)
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
const method = existingChatId ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await response.json()
if (!response.ok) {
if (result.error === 'Identifier already in use') {
throw new Error('This identifier is already in use')
}
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
}
if (!result.chatUrl) {
throw new Error('Response missing chatUrl')
}
logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl)
return result.chatUrl
},
[]
)
return { deployChat }
}

View File

@@ -216,7 +216,7 @@ export function FormBuilder({
)}
</div>
{titleError && (
<p className='mt-[4px] text-[11px] text-[var(--text-error)]'>{titleError}</p>
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>{titleError}</p>
)}
<div className='mt-[4px] flex items-center gap-[6px]'>
<input

View File

@@ -17,11 +17,18 @@ import { Skeleton } from '@/components/ui'
import { isDev } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import {
type FieldConfig,
useCreateForm,
useDeleteForm,
useFormByWorkflow,
useUpdateForm,
} from '@/hooks/queries/forms'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { EmbedCodeGenerator } from './components/embed-code-generator'
import { FormBuilder } from './components/form-builder'
import { useFormDeployment } from './hooks/use-form-deployment'
import { useIdentifierValidation } from './hooks/use-identifier-validation'
const logger = createLogger('FormDeploy')
@@ -34,38 +41,11 @@ interface FormErrors {
general?: string
}
interface FieldConfig {
name: string
type: string
label: string
description?: string
required?: boolean
}
export interface ExistingForm {
id: string
identifier: string
title: string
description?: string
customizations: {
primaryColor?: string
thankYouMessage?: string
logoUrl?: string
fieldConfigs?: FieldConfig[]
}
authType: 'public' | 'password' | 'email'
hasPassword?: boolean
allowedEmails?: string[]
showBranding: boolean
isActive: boolean
}
interface FormDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingFormChange?: (exists: boolean) => void
formSubmitting?: boolean
setFormSubmitting?: (submitting: boolean) => void
onDeployed?: () => Promise<void>
@@ -81,7 +61,6 @@ export function FormDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingFormChange,
formSubmitting,
setFormSubmitting,
onDeployed,
@@ -95,8 +74,6 @@ export function FormDeploy({
const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public')
const [password, setPassword] = useState('')
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [existingForm, setExistingForm] = useState<ExistingForm | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [formUrl, setFormUrl] = useState('')
const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([])
const [showPasswordField, setShowPasswordField] = useState(false)
@@ -104,7 +81,12 @@ export function FormDeploy({
const [errors, setErrors] = useState<FormErrors>({})
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
const { createForm, updateForm, deleteForm, isSubmitting } = useFormDeployment()
const { data: existingForm, isLoading } = useFormByWorkflow(workflowId)
const createFormMutation = useCreateForm()
const updateFormMutation = useUpdateForm()
const deleteFormMutation = useDeleteForm()
const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending
const {
isChecking: isCheckingIdentifier,
@@ -124,85 +106,54 @@ export function FormDeploy({
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
// Fetch existing form deployment
// Populate form fields when existing form data is loaded
useEffect(() => {
async function fetchExistingForm() {
if (!workflowId) return
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/form/status`)
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.form) {
const detailResponse = await fetch(`/api/form/manage/${data.form.id}`)
if (detailResponse.ok) {
const formDetail = await detailResponse.json()
const form = formDetail.form as ExistingForm
setExistingForm(form)
onExistingFormChange?.(true)
setIdentifier(form.identifier)
setTitle(form.title)
setDescription(form.description || '')
setThankYouMessage(
form.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(form.authType)
setEmailItems(
(form.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (form.customizations?.fieldConfigs) {
setFieldConfigs(form.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${form.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${form.identifier}`
: `https://sim.ai/form/${form.identifier}`
)
}
}
} else {
setExistingForm(null)
onExistingFormChange?.(false)
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}
} catch (err) {
logger.error('Error fetching form deployment:', err)
} finally {
setIsLoading(false)
if (existingForm) {
setIdentifier(existingForm.identifier)
setTitle(existingForm.title)
setDescription(existingForm.description || '')
setThankYouMessage(
existingForm.customizations?.thankYouMessage ||
'Your response has been submitted successfully.'
)
setAuthType(existingForm.authType)
setEmailItems(
(existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true }))
)
if (existingForm.customizations?.fieldConfigs) {
setFieldConfigs(existingForm.customizations.fieldConfigs)
}
const baseUrl = getBaseUrl()
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) host = host.substring(4)
setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`)
} catch {
setFormUrl(
isDev
? `http://localhost:3000/form/${existingForm.identifier}`
: `https://sim.ai/form/${existingForm.identifier}`
)
}
} else if (!isLoading) {
const workflowName =
useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]]
?.name || 'Form'
setTitle(`${workflowName} Form`)
}
}, [existingForm, isLoading])
fetchExistingForm()
}, [workflowId, onExistingFormChange])
// Get input fields from start block and initialize field configs
useEffect(() => {
const blocks = Object.values(useWorkflowStore.getState().blocks)
const startBlock = blocks.find((b) => b.type === 'starter' || b.type === 'start_trigger')
const startBlock = blocks.find((b) => isValidStartBlockType(b.type))
if (startBlock) {
const inputFormat = useSubBlockStore.getState().getValue(startBlock.id, 'inputFormat')
if (inputFormat && Array.isArray(inputFormat)) {
setInputFields(inputFormat)
// Initialize field configs if not already set
if (fieldConfigs.length === 0) {
setFieldConfigs(
inputFormat.map((f: { name: string; type?: string }) => ({
@@ -222,7 +173,6 @@ export function FormDeploy({
const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
// Validate form
useEffect(() => {
const isValid =
inputFields.length > 0 &&
@@ -253,7 +203,6 @@ export function FormDeploy({
e.preventDefault()
setErrors({})
// Validate before submit
if (!isIdentifierValid && identifier !== existingForm?.identifier) {
setError('identifier', 'Please wait for identifier validation to complete')
return
@@ -281,17 +230,21 @@ export function FormDeploy({
try {
if (existingForm) {
await updateForm(existingForm.id, {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
await updateFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
data: {
identifier,
title,
description,
customizations,
authType,
password: password || undefined,
allowedEmails,
},
})
} else {
const result = await createForm({
const result = await createFormMutation.mutateAsync({
workflowId,
identifier,
title,
@@ -304,7 +257,6 @@ export function FormDeploy({
if (result?.formUrl) {
setFormUrl(result.formUrl)
// Open the form in a new window after successful deployment
window.open(result.formUrl, '_blank', 'noopener,noreferrer')
}
}
@@ -318,7 +270,6 @@ export function FormDeploy({
const message = err instanceof Error ? err.message : 'An error occurred'
logger.error('Error deploying form:', err)
// Parse error message and show inline
if (message.toLowerCase().includes('identifier')) {
setError('identifier', message)
} else if (message.toLowerCase().includes('password')) {
@@ -342,8 +293,8 @@ export function FormDeploy({
password,
allowedEmails,
isIdentifierValid,
createForm,
updateForm,
createFormMutation,
updateFormMutation,
onDeployed,
onDeploymentComplete,
]
@@ -353,9 +304,10 @@ export function FormDeploy({
if (!existingForm) return
try {
await deleteForm(existingForm.id)
setExistingForm(null)
onExistingFormChange?.(false)
await deleteFormMutation.mutateAsync({
formId: existingForm.id,
workflowId,
})
setIdentifier('')
setTitle('')
setDescription('')
@@ -363,7 +315,7 @@ export function FormDeploy({
} catch (err) {
logger.error('Error deleting form:', err)
}
}, [existingForm, deleteForm, onExistingFormChange])
}, [existingForm, deleteFormMutation, workflowId])
if (isLoading) {
return (
@@ -447,7 +399,7 @@ export function FormDeploy({
</div>
</div>
{(identifierError || errors.identifier) && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
{identifierError || errors.identifier}
</p>
)}
@@ -531,7 +483,7 @@ export function FormDeploy({
</button>
</div>
{errors.password && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.password}</p>
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.password}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
{existingForm?.hasPassword
@@ -568,7 +520,7 @@ export function FormDeploy({
placeholderWithTags='Add another'
/>
{errors.emails && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.emails}</p>
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.emails}</p>
)}
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Add specific emails or entire domains (@example.com)
@@ -599,7 +551,7 @@ export function FormDeploy({
)}
{errors.general && (
<p className='mt-[6.5px] text-[11px] text-[var(--text-error)]'>{errors.general}</p>
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{errors.general}</p>
)}
<button type='button' data-delete-trigger onClick={handleDelete} className='hidden' />

View File

@@ -1,151 +0,0 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useFormDeployment')
interface CreateFormParams {
workflowId: string
identifier: string
title: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
}
interface UpdateFormParams {
identifier?: string
title?: string
description?: string
customizations?: {
primaryColor?: string
welcomeMessage?: string
thankYouTitle?: string
thankYouMessage?: string
logoUrl?: string
}
authType?: 'public' | 'password' | 'email'
password?: string
allowedEmails?: string[]
showBranding?: boolean
isActive?: boolean
}
interface CreateFormResult {
id: string
formUrl: string
}
export function useFormDeployment() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const createForm = useCallback(
async (params: CreateFormParams): Promise<CreateFormResult | null> => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch('/api/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create form')
}
logger.info('Form created successfully:', { id: data.id })
return {
id: data.id,
formUrl: data.formUrl,
}
} catch (err: any) {
const errorMessage = err.message || 'Failed to create form'
setError(errorMessage)
logger.error('Error creating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
},
[]
)
const updateForm = useCallback(async (formId: string, params: UpdateFormParams) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update form')
}
logger.info('Form updated successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to update form'
setError(errorMessage)
logger.error('Error updating form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
const deleteForm = useCallback(async (formId: string) => {
setIsSubmitting(true)
setError(null)
try {
const response = await fetch(`/api/form/manage/${formId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to delete form')
}
logger.info('Form deleted successfully:', { id: formId })
} catch (err: any) {
const errorMessage = err.message || 'Failed to delete form'
setError(errorMessage)
logger.error('Error deleting form:', err)
throw err
} finally {
setIsSubmitting(false)
}
}, [])
return {
createForm,
updateForm,
deleteForm,
isSubmitting,
error,
}
}

View File

@@ -15,7 +15,7 @@ import {
import { Skeleton } from '@/components/ui'
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isValidStartBlockType } from '@/lib/workflows/triggers/trigger-utils'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import type { InputFormatField } from '@/lib/workflows/types'
import {
useAddWorkflowMcpTool,
@@ -43,7 +43,6 @@ interface McpDeployProps {
onAddedToServer?: () => void
onSubmittingChange?: (submitting: boolean) => void
onCanSaveChange?: (canSave: boolean) => void
onHasServersChange?: (hasServers: boolean) => void
}
/**
@@ -92,7 +91,6 @@ export function McpDeploy({
onAddedToServer,
onSubmittingChange,
onCanSaveChange,
onHasServersChange,
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -257,10 +255,6 @@ export function McpDeploy({
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
useEffect(() => {
onHasServersChange?.(servers.length > 0)
}, [servers.length, onHasServersChange])
/**
* Save tool configuration to all deployed servers
*/

View File

@@ -20,6 +20,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
import { useCreatorProfiles } from '@/hooks/queries/creator-profile'
import {
useCreateTemplate,
useDeleteTemplate,
@@ -47,26 +48,11 @@ const initialFormData: TemplateFormData = {
tags: [],
}
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateStatus {
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
onValidationChange?: (isValid: boolean) => void
onSubmittingChange?: (isSubmitting: boolean) => void
onExistingTemplateChange?: (exists: boolean) => void
onTemplateStatusChange?: (status: TemplateStatus | null) => void
}
export function TemplateDeploy({
@@ -74,13 +60,9 @@ export function TemplateDeploy({
onDeploymentComplete,
onValidationChange,
onSubmittingChange,
onExistingTemplateChange,
onTemplateStatusChange,
}: TemplateDeployProps) {
const { data: session } = useSession()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [isCapturing, setIsCapturing] = useState(false)
const previewContainerRef = useRef<HTMLDivElement>(null)
const ogCaptureRef = useRef<HTMLDivElement>(null)
@@ -88,6 +70,7 @@ export function TemplateDeploy({
const [formData, setFormData] = useState<TemplateFormData>(initialFormData)
const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId)
const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles()
const createMutation = useCreateTemplate()
const updateMutation = useUpdateTemplate()
const deleteMutation = useDeleteTemplate()
@@ -112,63 +95,15 @@ export function TemplateDeploy({
}, [isSubmitting, onSubmittingChange])
useEffect(() => {
onExistingTemplateChange?.(!!existingTemplate)
}, [existingTemplate, onExistingTemplateChange])
useEffect(() => {
if (existingTemplate) {
onTemplateStatusChange?.({
status: existingTemplate.status as 'pending' | 'approved' | 'rejected',
views: existingTemplate.views,
stars: existingTemplate.stars,
})
} else {
onTemplateStatusChange?.(null)
if (creatorProfiles.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorProfiles[0].id)
logger.info('Auto-selected single creator profile:', creatorProfiles[0].name)
}
}, [existingTemplate, onTemplateStatusChange])
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creators')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
return profiles
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
return []
}
}, [creatorProfiles, formData.creatorId])
useEffect(() => {
fetchCreatorOptions()
}, [session?.user?.id])
useEffect(() => {
if (creatorOptions.length === 1 && !formData.creatorId) {
updateField('creatorId', creatorOptions[0].id)
logger.info('Auto-selected single creator profile:', creatorOptions[0].name)
}
}, [creatorOptions, formData.creatorId])
useEffect(() => {
const handleCreatorProfileSaved = async () => {
logger.info('Creator profile saved, refreshing profiles...')
await fetchCreatorOptions()
const handleCreatorProfileSaved = () => {
logger.info('Creator profile saved, reopening deploy modal...')
window.dispatchEvent(new CustomEvent('close-settings'))
setTimeout(() => {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } }))
@@ -357,7 +292,7 @@ export function TemplateDeploy({
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Creator <span className='text-[var(--text-error)]'>*</span>
</Label>
{creatorOptions.length === 0 && !loadingCreators ? (
{creatorProfiles.length === 0 && !loadingCreators ? (
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
A creator profile is required to publish templates.
@@ -385,9 +320,9 @@ export function TemplateDeploy({
</div>
) : (
<Combobox
options={creatorOptions.map((option) => ({
label: option.name,
value: option.id,
options={creatorProfiles.map((profile) => ({
label: profile.name,
value: profile.id,
}))}
value={formData.creatorId}
selectedValue={formData.creatorId}

View File

@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import {
Badge,
Button,
@@ -17,11 +18,22 @@ import {
} from '@/components/emcn'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
import { startsWithUuid } from '@/executor/constants'
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
import { useApiKeys } from '@/hooks/queries/api-keys'
import {
deploymentKeys,
useActivateDeploymentVersion,
useChatDeploymentInfo,
useDeploymentInfo,
useDeploymentVersions,
useDeployWorkflow,
useUndeployWorkflow,
} from '@/hooks/queries/deployments'
import { useTemplateByWorkflow } from '@/hooks/queries/templates'
import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -48,7 +60,7 @@ interface DeployModalProps {
refetchDeployedState: () => Promise<void>
}
interface WorkflowDeploymentInfo {
interface WorkflowDeploymentInfoUI {
isDeployed: boolean
deployedAt?: string
apiKey: string
@@ -69,16 +81,12 @@ export function DeployModal({
isLoadingDeployedState,
refetchDeployedState,
}: DeployModalProps) {
const queryClient = useQueryClient()
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
@@ -86,33 +94,18 @@ export function DeployModal({
const [activeTab, setActiveTab] = useState<TabView>('general')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
const [isChatFormValid, setIsChatFormValid] = useState(false)
const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState<string[]>([])
const [versions, setVersions] = useState<WorkflowDeploymentVersionResponse[]>([])
const [versionsLoading, setVersionsLoading] = useState(false)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [templateFormValid, setTemplateFormValid] = useState(false)
const [templateSubmitting, setTemplateSubmitting] = useState(false)
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
const [hasMcpServers, setHasMcpServers] = useState(false)
const [a2aSubmitting, setA2aSubmitting] = useState(false)
const [a2aCanSave, setA2aCanSave] = useState(false)
const [hasA2aAgent, setHasA2aAgent] = useState(false)
const [isA2aPublished, setIsA2aPublished] = useState(false)
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false)
const [hasExistingTemplate, setHasExistingTemplate] = useState(false)
const [templateStatus, setTemplateStatus] = useState<{
status: 'pending' | 'approved' | 'rejected' | null
views?: number
stars?: number
} | null>(null)
const [existingChat, setExistingChat] = useState<ExistingChat | null>(null)
const [isLoadingChat, setIsLoadingChat] = useState(false)
const [chatSuccess, setChatSuccess] = useState(false)
@@ -133,193 +126,107 @@ export function DeployModal({
const createButtonDisabled =
isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
}
const {
data: deploymentInfoData,
isLoading: isLoadingDeploymentInfo,
refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const getApiHeaderPlaceholder = () =>
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
const {
data: versionsData,
isLoading: versionsLoading,
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const getInputFormatExample = (includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
}
const {
isLoading: isLoadingChat,
chatExists,
existingChat,
refetch: refetchChatInfo,
} = useChatDeploymentInfo(workflowId, { enabled: open })
const fetchChatDeploymentInfo = useCallback(async () => {
if (!workflowId) return
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
const hasMcpServers = mcpServers.length > 0
try {
setIsLoadingChat(true)
const response = await fetch(`/api/workflows/${workflowId}/chat/status`)
const { data: existingA2aAgent } = useA2AAgentByWorkflow(
workflowWorkspaceId || '',
workflowId || ''
)
const hasA2aAgent = !!existingA2aAgent
const isA2aPublished = existingA2aAgent?.isPublished ?? false
if (response.ok) {
const data = await response.json()
if (data.isDeployed && data.deployment) {
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
if (detailResponse.ok) {
const chatDetail = await detailResponse.json()
setExistingChat(chatDetail)
setChatExists(true)
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
}
} else {
setExistingChat(null)
setChatExists(false)
const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', {
enabled: !!workflowId,
})
const hasExistingTemplate = !!existingTemplate
const templateStatus = existingTemplate
? {
status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null,
views: existingTemplate.views,
stars: existingTemplate.stars,
}
} catch (error) {
logger.error('Error fetching chat deployment info:', { error })
setExistingChat(null)
setChatExists(false)
} finally {
setIsLoadingChat(false)
: null
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
const versions = versionsData?.versions ?? []
const getApiKeyLabel = useCallback(
(value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
},
[workflowWorkspaceId]
)
const getApiHeaderPlaceholder = useCallback(
() => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'),
[workflowWorkspaceId]
)
const getInputFormatExample = useCallback(
(includeStreaming = false) => {
return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs)
},
[selectedStreamingOutputs]
)
const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => {
if (!deploymentInfoData?.isDeployed || !workflowId) {
return null
}
}, [workflowId])
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
return {
isDeployed: deploymentInfoData.isDeployed,
deployedAt: deploymentInfoData.deployedAt ?? undefined,
apiKey: getApiKeyLabel(deploymentInfoData.apiKey),
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: deploymentInfoData.needsRedeployment,
}
}, [
deploymentInfoData,
workflowId,
selectedStreamingOutputs,
getInputFormatExample,
getApiHeaderPlaceholder,
getApiKeyLabel,
])
useEffect(() => {
if (open && workflowId) {
setActiveTab('general')
setApiDeployError(null)
fetchChatDeploymentInfo()
}
}, [open, workflowId, fetchChatDeploymentInfo])
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
setIsLoading(false)
return
}
if (deploymentInfo?.isDeployed && !needsRedeployment) {
setIsLoading(false)
return
}
try {
setIsLoading(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`)
if (!response.ok) {
throw new Error('Failed to fetch deployment information')
}
const data = await response.json()
const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
setDeploymentInfo({
isDeployed: data.isDeployed,
deployedAt: data.deployedAt,
apiKey: data.apiKey || placeholderKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment,
})
} catch (error) {
logger.error('Error fetching deployment info:', { error })
} finally {
setIsLoading(false)
}
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async () => {
setApiDeployError(null)
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const responseData = await response.json()
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await refetchDeployedState()
await fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
setApiDeployError(null)
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}
const fetchVersions = useCallback(async () => {
if (!workflowId) return
try {
const res = await fetch(`/api/workflows/${workflowId}/deployments`)
if (res.ok) {
const data = await res.json()
setVersions(Array.isArray(data.versions) ? data.versions : [])
} else {
setVersions([])
}
} catch {
setVersions([])
}
}, [workflowId])
useEffect(() => {
if (open && workflowId) {
setVersionsLoading(true)
fetchVersions().finally(() => setVersionsLoading(false))
}
}, [open, workflowId, fetchVersions])
}, [open, workflowId])
useEffect(() => {
if (!open || selectedStreamingOutputs.length === 0) return
@@ -369,181 +276,88 @@ export function DeployModal({
}
}, [onOpenChange])
const onDeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
try {
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
setApiDeployError(errorMessage)
}
}, [workflowId, deployMutation, refetchDeployedState])
const handlePromoteToLive = useCallback(
async (version: number) => {
if (!workflowId) return
const previousVersions = [...versions]
setVersions((prev) =>
prev.map((v) => ({
...v,
isActive: v.version === version,
}))
)
try {
const response = await fetch(
`/api/workflows/${workflowId}/deployments/${version}/activate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to promote version')
}
const responseData = await response.json()
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel)
refetchDeployedState()
fetchVersions()
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
await activateVersionMutation.mutateAsync({ workflowId, version })
await refetchDeployedState()
} catch (error) {
setVersions(previousVersions)
logger.error('Error promoting version:', { error })
throw error
}
},
[workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs]
[workflowId, activateVersionMutation, refetchDeployedState]
)
const handleUndeploy = async () => {
const handleUndeploy = useCallback(async () => {
if (!workflowId) return
try {
setIsUndeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to undeploy workflow')
}
setDeploymentStatus(workflowId, false)
setChatExists(false)
await undeployMutation.mutateAsync({ workflowId })
setShowUndeployConfirm(false)
onOpenChange(false)
} catch (error: unknown) {
logger.error('Error undeploying workflow:', { error })
} finally {
setIsUndeploying(false)
}
}
}, [workflowId, undeployMutation, onOpenChange])
const handleRedeploy = useCallback(async () => {
if (!workflowId) return
setApiDeployError(null)
const handleRedeploy = async () => {
try {
setIsSubmitting(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to redeploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
getApiKeyLabel(apiKey)
)
if (workflowId) {
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
await refetchDeployedState()
await fetchVersions()
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
} catch (error: unknown) {
logger.error('Error redeploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow'
setApiDeployError(errorMessage)
} finally {
setIsSubmitting(false)
}
}
}, [workflowId, deployMutation, refetchDeployedState])
const handleCloseModal = () => {
setIsSubmitting(false)
const handleCloseModal = useCallback(() => {
setChatSubmitting(false)
setApiDeployError(null)
onOpenChange(false)
}
}, [onOpenChange])
const handleChatDeployed = async () => {
await handlePostDeploymentUpdate()
setChatSuccess(true)
setTimeout(() => setChatSuccess(false), 2000)
}
const handlePostDeploymentUpdate = async () => {
const handleChatDeployed = useCallback(async () => {
if (!workflowId) return
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: false,
})
}
queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) })
queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) })
await refetchDeployedState()
await fetchVersions()
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
}
const handleChatFormSubmit = () => {
setChatSuccess(true)
setTimeout(() => setChatSuccess(false), 2000)
}, [workflowId, queryClient, refetchDeployedState])
const handleRefetchChat = useCallback(async () => {
await refetchChatInfo()
}, [refetchChatInfo])
const handleChatFormSubmit = useCallback(() => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement
@@ -553,9 +367,9 @@ export function DeployModal({
form.requestSubmit()
}
}
}
}, [])
const handleChatDelete = () => {
const handleChatDelete = useCallback(() => {
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
if (form) {
const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement
@@ -563,7 +377,7 @@ export function DeployModal({
deleteButton.click()
}
}
}
}, [])
const handleTemplateFormSubmit = useCallback(() => {
const form = document.getElementById('template-deploy-form') as HTMLFormElement
@@ -623,6 +437,13 @@ export function DeployModal({
deleteTrigger?.click()
}, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending
return (
<>
<Modal open={open} onOpenChange={handleCloseModal}>
@@ -670,7 +491,7 @@ export function DeployModal({
versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal}
fetchVersions={fetchVersions}
fetchVersions={handleFetchVersions}
/>
</ModalTabsContent>
@@ -678,7 +499,7 @@ export function DeployModal({
<ApiDeploy
workflowId={workflowId}
deploymentInfo={deploymentInfo}
isLoading={isLoading}
isLoading={isLoadingDeploymentInfo}
needsRedeployment={needsRedeployment}
apiDeployError={apiDeployError}
getInputFormatExample={getInputFormatExample}
@@ -691,10 +512,9 @@ export function DeployModal({
<ChatDeploy
workflowId={workflowId || ''}
deploymentInfo={deploymentInfo}
existingChat={existingChat}
existingChat={existingChat as ExistingChat | null}
isLoadingChat={isLoadingChat}
onRefetchChat={fetchChatDeploymentInfo}
onChatExistsChange={setChatExists}
onRefetchChat={handleRefetchChat}
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
@@ -711,8 +531,6 @@ export function DeployModal({
onDeploymentComplete={handleCloseModal}
onValidationChange={setTemplateFormValid}
onSubmittingChange={setTemplateSubmitting}
onExistingTemplateChange={setHasExistingTemplate}
onTemplateStatusChange={setTemplateStatus}
/>
)}
</ModalTabsContent>
@@ -741,7 +559,6 @@ export function DeployModal({
isDeployed={isDeployed}
onSubmittingChange={setMcpToolSubmitting}
onCanSaveChange={setMcpToolCanSave}
onHasServersChange={setHasMcpServers}
/>
)}
</ModalTabsContent>
@@ -756,8 +573,6 @@ export function DeployModal({
workflowNeedsRedeployment={needsRedeployment}
onSubmittingChange={setA2aSubmitting}
onCanSaveChange={setA2aCanSave}
onAgentExistsChange={setHasA2aAgent}
onPublishedChange={setIsA2aPublished}
onNeedsRepublishChange={setA2aNeedsRepublish}
onDeployWorkflow={onDeploy}
/>
@@ -843,7 +658,7 @@ export function DeployModal({
onClick={handleMcpToolFormSubmit}
disabled={mcpToolSubmitting || !mcpToolCanSave}
>
{mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'}
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
</Button>
</div>
</ModalFooter>

View File

@@ -2,16 +2,19 @@ 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'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { getProviderFromModel } from '@/providers/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Constants for ComboBox component behavior
@@ -91,15 +94,24 @@ export function ComboBox({
// Dependency tracking for fetchOptions
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
return dependsOnFields.map((depKey) =>
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
)
},
[dependsOnFields, activeWorkflowId, blockId]
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
)
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
@@ -308,6 +308,7 @@ export function OAuthRequiredModal({
serviceId,
newScopes = [],
}: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -348,23 +349,24 @@ export function OAuthRequiredModal({
}, [requiredScopes, newScopesSet])
const handleConnectDirectly = async () => {
setError(null)
try {
const providerId = getProviderIdFromServiceId(serviceId)
onClose()
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
})
if (providerId === 'trello') {
onClose()
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
// Pass the current URL so we can redirect back after OAuth
onClose()
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
@@ -374,8 +376,10 @@ export function OAuthRequiredModal({
providerId,
callbackURL: window.location.href,
})
} catch (error) {
logger.error('Error initiating OAuth flow:', { error })
onClose()
} catch (err) {
logger.error('Error initiating OAuth flow:', { error: err })
setError('Failed to connect. Please try again.')
}
}
@@ -425,10 +429,12 @@ export function OAuthRequiredModal({
</ul>
</div>
)}
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={onClose}>
<Button variant='default' onClick={onClose}>
Cancel
</Button>
<Button variant='tertiary' type='button' onClick={handleConnectDirectly}>

View File

@@ -1,12 +1,15 @@
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'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Dropdown option type - can be a simple string or an object with label, id, and optional icon
@@ -89,15 +92,24 @@ export function Dropdown({
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
return dependsOnFields.map((depKey) =>
resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides)
)
},
[dependsOnFields, activeWorkflowId, blockId]
[dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
)
)

View File

@@ -4,15 +4,19 @@ import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { isDependency } from '@/blocks/utils'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface FileSelectorInputProps {
blockId: string
@@ -42,21 +46,59 @@ export function FileSelectorInput({
previewContextValues,
})
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const [domainValueFromStore] = useSubBlockValue(blockId, 'domain')
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
const domainValue = previewContextValues?.domain ?? domainValueFromStore
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
const teamIdValue = useMemo(
() =>
previewContextValues?.teamId ??
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const siteIdValue = useMemo(
() =>
previewContextValues?.siteId ??
resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const collectionIdValue = useMemo(
() =>
previewContextValues?.collectionId ??
resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const projectIdValue = useMemo(
() =>
previewContextValues?.projectId ??
resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const planIdValue = useMemo(
() =>
previewContextValues?.planId ??
resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -65,7 +107,6 @@ export function FileSelectorInput({
? ((connectedCredential as Record<string, any>).id ?? '')
: ''
// Derive provider from serviceId using OAuth config (same pattern as credential-selector)
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])

View File

@@ -4,14 +4,17 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface ProjectSelectorInputProps {
blockId: string
@@ -32,21 +35,36 @@ export function ProjectSelectorInput({
previewValue,
previewContextValues,
}: ProjectSelectorInputProps) {
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const params = useParams()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
// Use the proper hook to get the current value and setter
const [storeValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId')
const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain')
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const connectedCredential = previewContextValues?.credential ?? blockValues.credential
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
// Derive provider from serviceId using OAuth config
const linearTeamId = useMemo(
() =>
previewContextValues?.teamId ??
resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides),
[previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides]
)
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
@@ -54,7 +72,6 @@ export function ProjectSelectorInput({
effectiveProviderId,
(connectedCredential as string) || ''
)
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled,
@@ -62,12 +79,8 @@ export function ProjectSelectorInput({
previewContextValues,
})
// Jira/Discord upstream fields - use values from previewContextValues or store
const domain = (jiraDomain as string) || ''
// Verify Jira credential belongs to current user; if not, treat as absent
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedProjectId(previewValue)

View File

@@ -4,14 +4,17 @@ import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface SheetSelectorInputProps {
blockId: string
@@ -41,16 +44,32 @@ export function SheetSelectorInput({
previewContextValues,
})
const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential')
const [spreadsheetIdFromStore] = useSubBlockValue(blockId, 'spreadsheetId')
const [manualSpreadsheetIdFromStore] = useSubBlockValue(blockId, 'manualSpreadsheetId')
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
const blockValues = useSubBlockStore((state) => {
if (!activeWorkflowId) return {}
const workflowValues = state.workflowValues[activeWorkflowId] || {}
return (workflowValues as Record<string, Record<string, unknown>>)[blockId] || {}
})
const connectedCredentialFromStore = blockValues.credential
const spreadsheetIdFromStore = useMemo(
() =>
resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides),
[blockValues, canonicalIndex, canonicalModeOverrides]
)
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
const spreadsheetId =
previewContextValues?.spreadsheetId ??
spreadsheetIdFromStore ??
previewContextValues?.manualSpreadsheetId ??
manualSpreadsheetIdFromStore
const spreadsheetId = previewContextValues
? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId)
: spreadsheetIdFromStore
const normalizedCredentialId =
typeof connectedCredential === 'string'
@@ -61,7 +80,6 @@ export function SheetSelectorInput({
const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : ''
// Derive provider from serviceId using OAuth config
const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])

View File

@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle, Wand2 } from 'lucide-react'
import { AlertCircle, ArrowUp } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Input,
Modal,
ModalBody,
ModalContent,
@@ -878,35 +879,53 @@ try {
JSON Schema
</Label>
{schemaError && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{schemaError}</span>
</div>
)}
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
{!isSchemaPromptActive ? (
<button
type='button'
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleSchemaWandClick}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate schema with AI'
>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
Generate
</Button>
) : (
<input
ref={schemaPromptInputRef}
type='text'
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
onChange={(e) => handleSchemaPromptChange(e.target.value)}
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe schema...'
/>
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={schemaPromptInputRef}
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
onChange={(e) => handleSchemaPromptChange(e.target.value)}
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
schemaGeneration.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
<Button
variant='tertiary'
disabled={!schemaPromptInput.trim() || schemaGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleSchemaPromptSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</div>
</div>
@@ -952,35 +971,53 @@ try {
Code
</Label>
{codeError && !codeGeneration.isStreaming && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-[var(--text-error)] text-xs'>
<div className='ml-2 flex min-w-0 items-center gap-1 text-[12px] text-[var(--text-error)]'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{codeError}</span>
</div>
)}
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
<div className='flex min-w-0 items-center justify-end gap-[4px]'>
{!isCodePromptActive ? (
<button
type='button'
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleCodeWandClick}
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate code with AI'
>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
Generate
</Button>
) : (
<input
ref={codePromptInputRef}
type='text'
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
onChange={(e) => handleCodePromptChange(e.target.value)}
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[var(--text-muted)] focus:outline-none'
placeholder='Describe code...'
/>
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={codePromptInputRef}
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
onChange={(e) => handleCodePromptChange(e.target.value)}
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
codeGeneration.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
<Button
variant='tertiary'
disabled={!codePromptInput.trim() || codeGeneration.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
handleCodePromptSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</div>
</div>

View File

@@ -557,7 +557,7 @@ function FileUploadSyncWrapper({
)
}
function ChannelSelectorSyncWrapper({
function SlackSelectorSyncWrapper({
blockId,
paramId,
value,
@@ -565,6 +565,7 @@ function ChannelSelectorSyncWrapper({
uiComponent,
disabled,
previewContextValues,
selectorType,
}: {
blockId: string
paramId: string
@@ -573,6 +574,7 @@ function ChannelSelectorSyncWrapper({
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
selectorType: 'channel-selector' | 'user-selector'
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -580,7 +582,7 @@ function ChannelSelectorSyncWrapper({
blockId={blockId}
subBlock={{
id: paramId,
type: 'channel-selector' as const,
type: selectorType,
title: paramId,
serviceId: uiComponent.serviceId,
placeholder: uiComponent.placeholder,
@@ -1952,7 +1954,7 @@ export function ToolInput({
case 'channel-selector':
return (
<ChannelSelectorSyncWrapper
<SlackSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
@@ -1960,6 +1962,21 @@ export function ToolInput({
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
selectorType='channel-selector'
/>
)
case 'user-selector':
return (
<SlackSelectorSyncWrapper
blockId={blockId}
paramId={param.id}
value={value}
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
selectorType='user-selector'
/>
)

View File

@@ -1,9 +1,16 @@
'use client'
import { useMemo } from 'react'
import {
buildCanonicalIndex,
isNonEmptyValue,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type DependsOnConfig = string[] | { all?: string[]; any?: string[] }
@@ -50,6 +57,13 @@ export function useDependsOnGate(
const previewContextValues = opts?.previewContextValues
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
// Parse dependsOn config to get all/any field lists
const { allFields, anyFields, allDependsOnFields } = useMemo(
@@ -91,7 +105,13 @@ export function useDependsOnGate(
if (previewContextValues) {
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = normalizeDependencyValue(previewContextValues[key])
const resolvedValue = resolveDependencyValue(
key,
previewContextValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
}
@@ -108,32 +128,25 @@ export function useDependsOnGate(
const blockValues = (workflowValues as any)[blockId] || {}
const map: Record<string, unknown> = {}
for (const key of allDependsOnFields) {
map[key] = normalizeDependencyValue((blockValues as any)[key])
const resolvedValue = resolveDependencyValue(
key,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
map[key] = normalizeDependencyValue(resolvedValue)
}
return map
})
// For backward compatibility, also provide array of values
const dependencyValues = useMemo(
() => allDependsOnFields.map((key) => dependencyValuesMap[key]),
[allDependsOnFields, dependencyValuesMap]
) as any[]
const isValueSatisfied = (value: unknown): boolean => {
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
if (Array.isArray(value)) return value.length > 0
return value !== ''
}
const depsSatisfied = useMemo(() => {
// Check all fields (AND logic) - all must be satisfied
const allSatisfied =
allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key]))
allFields.length === 0 || allFields.every((key) => isNonEmptyValue(dependencyValuesMap[key]))
// Check any fields (OR logic) - at least one must be satisfied
const anySatisfied =
anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key]))
anyFields.length === 0 || anyFields.some((key) => isNonEmptyValue(dependencyValuesMap[key]))
return allSatisfied && anySatisfied
}, [allFields, anyFields, dependencyValuesMap])
@@ -146,7 +159,6 @@ export function useDependsOnGate(
return {
dependsOn,
dependencyValues,
depsSatisfied,
blocked,
finalDisabled,

View File

@@ -1,5 +1,5 @@
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { AlertTriangle, ArrowUp } from 'lucide-react'
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
@@ -67,6 +67,11 @@ interface SubBlockProps {
disabled?: boolean
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
}
/**
@@ -182,6 +187,11 @@ const renderLabel = (
onSearchSubmit: () => void
onSearchCancel: () => void
searchInputRef: React.RefObject<HTMLInputElement | null>
},
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
): JSX.Element | null => {
if (config.type === 'switch') return null
@@ -189,13 +199,12 @@ const renderLabel = (
const required = isFieldRequired(config, subBlockValues)
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled
return (
<Label
className='flex items-center justify-between gap-[6px] pl-[2px]'
onClick={(e) => e.preventDefault()}
>
<div className='flex items-center gap-[6px] whitespace-nowrap'>
<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' && (
@@ -213,58 +222,82 @@ const renderLabel = (
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{showWand && (
<>
{!wandState.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wandState.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={wandState.searchInputRef}
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
onChange={(e) => wandState.onSearchChange(e.target.value)}
onBlur={wandState.onSearchBlur}
onKeyDown={(e) => {
if (e.key === 'Enter' && wandState.searchQuery.trim() && !wandState.isStreaming) {
wandState.onSearchSubmit()
} else if (e.key === 'Escape') {
wandState.onSearchCancel()
}
}}
disabled={wandState.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
wandState.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate...'
/>
</Label>
<div className='flex items-center gap-[6px]'>
{showWand && (
<>
{!wandState.isSearchActive ? (
<Button
variant='tertiary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wandState.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wandState.onSearchClick}
>
<ArrowUp className='h-[12px] w-[12px]' />
Generate
</Button>
</div>
)}
</>
)}
</Label>
) : (
<div className='-my-1 flex items-center gap-[4px]'>
<Input
ref={wandState.searchInputRef}
value={wandState.isStreaming ? 'Generating...' : wandState.searchQuery}
onChange={(e) => wandState.onSearchChange(e.target.value)}
onBlur={wandState.onSearchBlur}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
wandState.searchQuery.trim() &&
!wandState.isStreaming
) {
wandState.onSearchSubmit()
} else if (e.key === 'Escape') {
wandState.onSearchCancel()
}
}}
disabled={wandState.isStreaming}
className={cn(
'h-5 max-w-[200px] flex-1 text-[11px]',
wandState.isStreaming && 'text-muted-foreground'
)}
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
disabled={!wandState.searchQuery.trim() || wandState.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wandState.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</>
)}
{showCanonicalToggle && (
<button
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={canonicalToggleDisabled}
aria-label={canonicalToggle?.mode === 'advanced' ? 'Use selector' : 'Enter manual ID'}
>
<ArrowLeftRight
className={cn(
'!h-[12px] !w-[12px]',
canonicalToggle?.mode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</button>
)}
</div>
</div>
)
}
@@ -287,7 +320,9 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.subBlockValues === nextProps.subBlockValues &&
prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
prevProps.canonicalToggle?.mode === nextProps.canonicalToggle?.mode &&
prevProps.canonicalToggle?.disabled === nextProps.canonicalToggle?.disabled
)
}
@@ -316,6 +351,7 @@ function SubBlockComponent({
disabled = false,
fieldDiffStatus,
allowExpandInPreview,
canonicalToggle,
}: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
@@ -887,20 +923,26 @@ function SubBlockComponent({
return (
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
{renderLabel(config, isValidJson, subBlockValues, {
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
disabled: isDisabled,
onSearchClick: handleSearchClick,
onSearchBlur: handleSearchBlur,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchCancel: handleSearchCancel,
searchInputRef,
})}
{renderLabel(
config,
isValidJson,
subBlockValues,
{
isSearchActive,
searchQuery,
isWandEnabled,
isPreview,
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
disabled: isDisabled,
onSearchClick: handleSearchClick,
onSearchBlur: handleSearchBlur,
onSearchChange: handleSearchChange,
onSearchSubmit: handleSearchSubmit,
onSearchCancel: handleSearchCancel,
searchInputRef,
},
canonicalToggle
)}
{renderInput()}
</div>
)

View File

@@ -1,8 +1,15 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Check, ChevronDown, ChevronUp, Pencil } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import {
buildCanonicalIndex,
hasAdvancedValues,
hasStandaloneAdvancedFields,
isCanonicalPair,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ConnectionBlocks,
@@ -20,6 +27,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -89,17 +97,65 @@ export function Editor() {
)
)
const subBlocksForCanonical = useMemo(() => {
const subBlocks = blockConfig?.subBlocks || []
if (!triggerMode) return subBlocks
return subBlocks.filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
)
}, [blockConfig?.subBlocks, triggerMode])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(subBlocksForCanonical),
[subBlocksForCanonical]
)
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
const advancedValuesPresent = hasAdvancedValues(
subBlocksForCanonical,
blockSubBlockValues,
canonicalIndex
)
const displayAdvancedOptions = advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(
() => hasStandaloneAdvancedFields(subBlocksForCanonical, canonicalIndex),
[subBlocksForCanonical, canonicalIndex]
)
// Get subblock layout using custom hook
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
blockConfig || ({} as any),
currentBlockId || '',
advancedMode,
displayAdvancedOptions,
triggerMode,
activeWorkflowId,
blockSubBlockValues,
currentWorkflow.isSnapshotView
)
/**
* Partitions subBlocks into regular fields and standalone advanced-only fields.
* Standalone advanced fields have mode 'advanced' and are not part of a canonical swap pair.
*/
const { regularSubBlocks, advancedOnlySubBlocks } = useMemo(() => {
const regular: typeof subBlocks = []
const advancedOnly: typeof subBlocks = []
for (const subBlock of subBlocks) {
const isStandaloneAdvanced =
subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
if (isStandaloneAdvanced) {
advancedOnly.push(subBlock)
} else {
regular.push(subBlock)
}
}
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
// Get block connections
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
@@ -109,21 +165,23 @@ export function Editor() {
})
// Collaborative actions
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
useCollaborativeWorkflow()
const {
collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode,
} = useCollaborativeWorkflow()
// Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
// Rename state
const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
// Mode toggle handlers
const handleToggleAdvancedMode = useCallback(() => {
if (currentBlockId && userPermissions.canEdit) {
collaborativeToggleBlockAdvancedMode(currentBlockId)
}
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
/**
* Handles starting the rename process.
*/
@@ -183,9 +241,6 @@ export function Editor() {
}
}
// Check if block has advanced mode or trigger mode available
const hasAdvancedMode = blockConfig?.subBlocks?.some((sb) => sb.mode === 'advanced')
// Determine if connections are at minimum height (collapsed state)
const isConnectionsAtMinHeight = connectionsHeight <= 35
@@ -278,25 +333,6 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)} */}
{/* Mode toggles - Only show for regular blocks, not subflows */}
{currentBlock && !isSubflow && hasAdvancedMode && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='p-0'
onClick={handleToggleAdvancedMode}
disabled={!userPermissions.canEdit}
aria-label='Toggle advanced mode'
>
<Settings className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Advanced mode</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -342,14 +378,111 @@ export function Editor() {
ref={subBlocksRef}
className='subblocks-section flex flex-1 flex-col overflow-hidden'
>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px]'>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-[8px] pt-[12px] pb-[8px] [overflow-anchor:none]'>
{subBlocks.length === 0 ? (
<div className='flex h-full items-center justify-center text-center text-[#8D8D8D] text-[13px]'>
This block has no subblocks
</div>
) : (
<div className='flex flex-col'>
{subBlocks.map((subBlock, index) => {
{regularSubBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(
currentBlockId || '',
subBlock,
subBlockState
)
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
const canonicalGroup = canonicalId
? canonicalIndex.groupsById[canonicalId]
: undefined
const isCanonicalSwap = isCanonicalPair(canonicalGroup)
const canonicalMode =
canonicalGroup && isCanonicalSwap
? resolveCanonicalMode(
canonicalGroup,
blockSubBlockValues,
canonicalModeOverrides
)
: undefined
const showDivider =
index < regularSubBlocks.length - 1 ||
(!hasAdvancedOnlyFields && index < subBlocks.length - 1)
return (
<div key={stableKey} className='subblock-row'>
<SubBlock
blockId={currentBlockId}
config={subBlock}
isPreview={false}
subBlockValues={subBlockState}
disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined}
allowExpandInPreview={false}
canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId
? {
mode: canonicalMode,
disabled: !userPermissions.canEdit,
onToggle: () => {
if (!currentBlockId) return
const nextMode =
canonicalMode === 'advanced' ? 'basic' : 'advanced'
collaborativeSetBlockCanonicalMode(
currentBlockId,
canonicalId,
nextMode
)
},
}
: undefined
}
/>
{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>
)
})}
{hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
<button
type='button'
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 advanced fields' : 'Show advanced fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
/>
</button>
<div
className='h-[1.25px] flex-1'
style={{
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
}}
/>
</div>
)}
{advancedOnlySubBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(
currentBlockId || '',
subBlock,
@@ -367,7 +500,7 @@ export function Editor() {
fieldDiffStatus={undefined}
allowExpandInPreview={false}
/>
{index < subBlocks.length - 1 && (
{index < advancedOnlySubBlocks.length - 1 && (
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
<div
className='h-[1.25px]'

View File

@@ -1,5 +1,10 @@
import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useCallback, useMemo } from 'react'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -27,6 +32,10 @@ export function useEditorSubblockLayout(
blockSubBlockValues: Record<string, any>,
isSnapshotView: boolean
) {
const blockDataFromStore = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.data, [blockId])
)
return useMemo(() => {
// Guard against missing config or block selection
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
@@ -46,6 +55,7 @@ export function useEditorSubblockLayout(
const mergedState = mergedMap ? mergedMap[blockId] : undefined
const mergedSubBlocks = mergedState?.subBlocks || {}
const blockData = isSnapshotView ? mergedState?.data || {} : blockDataFromStore || {}
const stateToUse = Object.keys(mergedSubBlocks).reduce(
(acc, key) => {
@@ -69,13 +79,29 @@ export function useEditorSubblockLayout(
}
// Filter visible blocks and those that meet their conditions
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
const subBlocksForCanonical = displayTriggerMode
? (config.subBlocks || []).filter(
(subBlock) =>
subBlock.mode === 'trigger' || subBlock.type === ('trigger-config' as SubBlockType)
)
: config.subBlocks || []
const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical)
const effectiveAdvanced = displayAdvancedMode
const canonicalModeOverrides = blockData?.canonicalModes
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
if (block.hidden) return false
// Check required feature if specified - declarative feature gating
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
return false
}
if (!isSubBlockFeatureEnabled(block)) return false
// Special handling for trigger-config type (legacy trigger configuration UI)
if (block.type === ('trigger-config' as SubBlockType)) {
@@ -84,13 +110,8 @@ export function useEditorSubblockLayout(
}
// Filter by mode if specified
if (block.mode) {
if (block.mode === 'basic' && displayAdvancedMode) return false
if (block.mode === 'advanced' && !displayAdvancedMode) return false
if (block.mode === 'trigger') {
// Show trigger mode blocks only when in trigger mode
if (!displayTriggerMode) return false
}
if (block.mode === 'trigger') {
if (!displayTriggerMode) return false
}
// When in trigger mode, hide blocks that don't have mode: 'trigger'
@@ -98,42 +119,22 @@ export function useEditorSubblockLayout(
return false
}
if (
!isSubBlockVisibleForMode(
block,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
// If there's no condition, the block should be shown
if (!block.condition) return true
// If condition is a function, call it to get the actual condition object
const actualCondition =
typeof block.condition === 'function' ? block.condition() : block.condition
// Get the values of the fields this block depends on from the appropriate state
const fieldValue = stateToUse[actualCondition.field]?.value
const andFieldValue = actualCondition.and
? stateToUse[actualCondition.and.field]?.value
: undefined
// Check if the condition value is an array
const isValueMatch = Array.isArray(actualCondition.value)
? fieldValue != null &&
(actualCondition.not
? !actualCondition.value.includes(fieldValue as string | number | boolean)
: actualCondition.value.includes(fieldValue as string | number | boolean))
: actualCondition.not
? fieldValue !== actualCondition.value
: fieldValue === actualCondition.value
// Check both conditions if 'and' is present
const isAndValueMatch =
!actualCondition.and ||
(Array.isArray(actualCondition.and.value)
? andFieldValue != null &&
(actualCondition.and.not
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
: actualCondition.and.not
? andFieldValue !== actualCondition.and.value
: andFieldValue === actualCondition.and.value)
return isValueMatch && isAndValueMatch
return evaluateSubBlockCondition(block.condition, rawValues)
})
return { subBlocks: visibleSubBlocks, stateToUse }
@@ -147,5 +148,6 @@ export function useEditorSubblockLayout(
blockSubBlockValues,
activeWorkflowId,
isSnapshotView,
blockDataFromStore,
])
}

View File

@@ -556,14 +556,17 @@ export function Panel() {
<ModalHeader>Delete Workflow</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
Deleting this workflow will permanently remove all associated blocks, executions, and
configuration.{' '}
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{currentWorkflow?.name ?? 'this workflow'}
</span>
? This will permanently remove all associated blocks, executions, and configuration.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button
variant='active'
variant='default'
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>

View File

@@ -66,7 +66,7 @@ export interface SubflowNodeData {
* @param props - Node properties containing data and id
* @returns Rendered subflow node component
*/
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
const userPermissions = useUserPermissionsContext()
@@ -134,13 +134,15 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor) or preview selected - blue ring
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
* 2. Diff status (version comparison) - green/orange ring
*/
const hasRing = isFocused || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
const isSelected = !isPreview && selected
const hasRing =
isFocused || isSelected || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
(isFocused || isSelected || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
diffStatus === 'edited' && 'ring-[var(--warning)]'
)
@@ -167,7 +169,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isPreviewSelected}
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
>
{!isPreview && (
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />

View File

@@ -3,11 +3,18 @@ import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Badge, Tooltip } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env'
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 {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import {
@@ -201,7 +208,6 @@ const tryParseJson = (value: unknown): unknown => {
export const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
// Try parsing JSON strings first
const parsedValue = tryParseJson(value)
if (isMessagesArray(parsedValue)) {
@@ -330,6 +336,9 @@ const SubBlockRow = ({
workflowId,
blockId,
allSubBlockValues,
displayAdvancedOptions,
canonicalIndex,
canonicalModeOverrides,
}: {
title: string
value?: string
@@ -339,6 +348,9 @@ const SubBlockRow = ({
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 => {
@@ -349,17 +361,43 @@ const SubBlockRow = ({
[allSubBlockValues]
)
const rawValues = useMemo(() => {
if (!allSubBlockValues) return {}
return Object.entries(allSubBlockValues).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
}, [allSubBlockValues])
const dependencyValues = useMemo(() => {
const fields = getDependsOnFields(subBlock?.dependsOn)
if (!fields.length) return {}
return fields.reduce<Record<string, string>>((accumulator, dependency) => {
const dependencyValue = getStringValue(dependency)
if (dependencyValue) {
accumulator[dependency] = dependencyValue
const dependencyValue = resolveDependencyValue(
dependency,
rawValues,
canonicalIndex || buildCanonicalIndex([]),
canonicalModeOverrides
)
const dependencyString =
typeof dependencyValue === 'string' && dependencyValue.length > 0
? dependencyValue
: undefined
if (dependencyString) {
accumulator[dependency] = dependencyString
}
return accumulator
}, {})
}, [getStringValue, subBlock?.dependsOn])
}, [
canonicalIndex,
canonicalModeOverrides,
displayAdvancedOptions,
rawValues,
subBlock?.dependsOn,
])
const credentialSourceId =
subBlock?.type === 'oauth-input' && typeof rawValue === 'string' ? rawValue : undefined
@@ -518,6 +556,7 @@ const SubBlockRow = ({
export const WorkflowBlock = memo(function WorkflowBlock({
id,
data,
selected,
}: NodeProps<WorkflowBlockProps>) {
const { type, config, name, isPending } = data
@@ -535,7 +574,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
hasRing,
ringStyles,
runPathStatus,
} = useBlockVisual({ blockId: id, data, isPending })
} = useBlockVisual({ blockId: id, data, isPending, isSelected: selected })
const currentBlock = currentWorkflow.getBlockById(id)
@@ -583,6 +622,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployChildWorkflow()
const userPermissions = useUserPermissionsContext()
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'
@@ -601,6 +642,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
[activeWorkflowId, id]
)
)
const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks])
const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes
const subBlockRowsData = useMemo(() => {
const rows: SubBlockConfig[][] = []
@@ -623,16 +666,23 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{} as Record<string, { value: unknown }>
)
const effectiveAdvanced = displayAdvancedMode
const rawValues = Object.entries(stateToUse).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
const effectiveAdvanced = userPermissions.canEdit
? displayAdvancedMode
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
const effectiveTrigger = displayTriggerMode
const visibleSubBlocks = config.subBlocks.filter((block) => {
if (block.hidden) return false
if (block.hideFromPreview) return false
if (block.requiresFeature && !isTruthy(getEnv(block.requiresFeature))) {
return false
}
if (!isSubBlockFeatureEnabled(block)) return false
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
@@ -650,40 +700,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({
}
}
if (block.mode === 'basic' && effectiveAdvanced) return false
if (block.mode === 'advanced' && !effectiveAdvanced) return false
if (
!isSubBlockVisibleForMode(
block,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
if (!block.condition) return true
const actualCondition =
typeof block.condition === 'function' ? block.condition() : block.condition
const fieldValue = stateToUse[actualCondition.field]?.value
const andFieldValue = actualCondition.and
? stateToUse[actualCondition.and.field]?.value
: undefined
const isValueMatch = Array.isArray(actualCondition.value)
? fieldValue != null &&
(actualCondition.not
? !actualCondition.value.includes(fieldValue as string | number | boolean)
: actualCondition.value.includes(fieldValue as string | number | boolean))
: actualCondition.not
? fieldValue !== actualCondition.value
: fieldValue === actualCondition.value
const isAndValueMatch =
!actualCondition.and ||
(Array.isArray(actualCondition.and.value)
? andFieldValue != null &&
(actualCondition.and.not
? !actualCondition.and.value.includes(andFieldValue as string | number | boolean)
: actualCondition.and.value.includes(andFieldValue as string | number | boolean))
: actualCondition.and.not
? andFieldValue !== actualCondition.and.value
: andFieldValue === actualCondition.and.value)
return isValueMatch && isAndValueMatch
return evaluateSubBlockCondition(block.condition, rawValues)
})
visibleSubBlocks.forEach((block) => {
@@ -715,12 +746,33 @@ export const WorkflowBlock = memo(function WorkflowBlock({
data.subBlockValues,
currentWorkflow.isDiffMode,
currentBlock,
canonicalModeOverrides,
userPermissions.canEdit,
canonicalIndex,
blockSubBlockValues,
activeWorkflowId,
])
const subBlockRows = subBlockRowsData.rows
const subBlockState = subBlockRowsData.stateToUse
const effectiveAdvanced = useMemo(() => {
const rawValues = Object.entries(subBlockState).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = entry?.value
return acc
},
{}
)
return userPermissions.canEdit
? displayAdvancedMode
: displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex)
}, [
subBlockState,
displayAdvancedMode,
config.subBlocks,
canonicalIndex,
userPermissions.canEdit,
])
/**
* Determine if block has content below the header (subblocks or error row).
@@ -883,7 +935,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
const shouldShowScheduleBadge =
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const userPermissions = useUserPermissionsContext()
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
return (
@@ -1095,6 +1146,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
displayAdvancedOptions={effectiveAdvanced}
canonicalIndex={canonicalIndex}
canonicalModeOverrides={canonicalModeOverrides}
/>
)
})

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
import { normalizeName } from '@/executor/constants'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -26,9 +27,7 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
const accessibleIds = new Set<string>(ancestorIds)
accessibleIds.add(blockId)
const starterBlock = Object.values(blocks).find(
(block) => block.type === 'starter' || block.type === 'start_trigger'
)
const starterBlock = Object.values(blocks).find((block) => isValidStartBlockType(block.type))
if (starterBlock && ancestorIds.includes(starterBlock.id)) {
accessibleIds.add(starterBlock.id)
}

View File

@@ -17,6 +17,8 @@ interface UseBlockVisualProps {
data: WorkflowBlockProps
/** Whether the block is pending execution */
isPending?: boolean
/** Whether the block is selected (via shift-click or selection box) */
isSelected?: boolean
}
/**
@@ -28,7 +30,12 @@ interface UseBlockVisualProps {
* @param props - The hook properties
* @returns Visual state, click handler, and ring styling for the block
*/
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
export function useBlockVisual({
blockId,
data,
isPending = false,
isSelected = false,
}: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
@@ -42,7 +49,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
isDeletedBlock,
} = useBlockState(blockId, currentWorkflow, data)
// Check if the editor panel is open for this block
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const activeTab = usePanelStore((state) => state.activeTab)
const isEditorOpen = !isPreview && currentBlockId === blockId && activeTab === 'editor'
@@ -68,6 +74,7 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
isSelected: isPreview ? false : isSelected,
}),
[
isExecuting,
@@ -78,6 +85,7 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
runPathStatus,
isPreview,
isPreviewSelected,
isSelected,
]
)

View File

@@ -14,6 +14,8 @@ export interface BlockRingOptions {
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
/** Whether the block is selected via shift-click or selection box (shows blue ring) */
isSelected?: boolean
}
/**
@@ -32,11 +34,13 @@ export function getBlockRingStyles(options: BlockRingOptions): {
diffStatus,
runPathStatus,
isPreviewSelection,
isSelected,
} = options
const hasRing =
isExecuting ||
isEditorOpen ||
isSelected ||
isPending ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
@@ -46,25 +50,37 @@ export function getBlockRingStyles(options: BlockRingOptions): {
const ringClassName = cn(
// Executing block: pulsing success ring with prominent thickness (highest priority)
isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Editor open or preview selection: static blue ring
// Editor open, selected, or preview selection: static blue ring
!isExecuting &&
(isEditorOpen || isPreviewSelection) &&
(isEditorOpen || isSelected || isPreviewSelection) &&
'ring-[1.75px] ring-[var(--brand-secondary)]',
// Non-active states use standard ring utilities
!isExecuting && !isEditorOpen && !isPreviewSelection && hasRing && 'ring-[1.75px]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPreviewSelection &&
hasRing &&
'ring-[1.75px]',
// Pending state: warning ring
!isExecuting && !isEditorOpen && isPending && 'ring-[var(--warning)]',
!isExecuting && !isEditorOpen && !isSelected && isPending && 'ring-[var(--warning)]',
// Deleted state (highest priority after active/pending)
!isExecuting && !isEditorOpen && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
isDeletedBlock &&
'ring-[var(--text-error)]',
// Diff states
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-tertiary-2)]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
diffStatus === 'edited' &&
@@ -72,6 +88,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
// Run path states (lowest priority - only show if no other states active)
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
!diffStatus &&
@@ -79,6 +96,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
'ring-[var(--border-success)]',
!isExecuting &&
!isEditorOpen &&
!isSelected &&
!isPending &&
!isDeletedBlock &&
!diffStatus &&

View File

@@ -700,7 +700,23 @@ const WorkflowContent = React.memo(() => {
triggerMode,
})
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
const subBlockValues: Record<string, Record<string, unknown>> = {}
if (block.subBlocks && Object.keys(block.subBlocks).length > 0) {
subBlockValues[id] = {}
for (const [subBlockId, subBlock] of Object.entries(block.subBlocks)) {
if (subBlock.value !== null && subBlock.value !== undefined) {
subBlockValues[id][subBlockId] = subBlock.value
}
}
}
collaborativeBatchAddBlocks(
[block],
autoConnectEdge ? [autoConnectEdge] : [],
{},
{},
subBlockValues
)
usePanelEditorStore.getState().setCurrentBlockId(id)
},
[collaborativeBatchAddBlocks, setSelectedEdges]

View File

@@ -14,6 +14,13 @@ import { ReactFlowProvider } from 'reactflow'
import { Badge, Button, ChevronDown, Code, Combobox, Input, Label } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -24,56 +31,6 @@ import { navigatePath } from '@/executor/variables/resolvers/reference'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
/**
* Evaluate whether a subblock's condition is met based on current values.
*/
function evaluateCondition(
condition: SubBlockConfig['condition'],
subBlockValues: Record<string, { value: unknown } | unknown>
): boolean {
if (!condition) return true
const actualCondition = typeof condition === 'function' ? condition() : condition
const fieldValueObj = subBlockValues[actualCondition.field]
const fieldValue =
fieldValueObj && typeof fieldValueObj === 'object' && 'value' in fieldValueObj
? (fieldValueObj as { value: unknown }).value
: fieldValueObj
const conditionValues = Array.isArray(actualCondition.value)
? actualCondition.value
: [actualCondition.value]
let isMatch = conditionValues.some((v) => v === fieldValue)
if (actualCondition.not) {
isMatch = !isMatch
}
if (actualCondition.and && isMatch) {
const andFieldValueObj = subBlockValues[actualCondition.and.field]
const andFieldValue =
andFieldValueObj && typeof andFieldValueObj === 'object' && 'value' in andFieldValueObj
? (andFieldValueObj as { value: unknown }).value
: andFieldValueObj
const andConditionValues = Array.isArray(actualCondition.and.value)
? actualCondition.and.value
: [actualCondition.and.value]
let andMatch = andConditionValues.some((v) => v === andFieldValue)
if (actualCondition.and.not) {
andMatch = !andMatch
}
isMatch = isMatch && andMatch
}
return isMatch
}
/**
* Format a value for display as JSON string
*/
@@ -1122,15 +1079,44 @@ function BlockDetailsSidebarContent({
)
}
const rawValues = useMemo(() => {
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
if (entry && typeof entry === 'object' && 'value' in entry) {
acc[key] = (entry as { value: unknown }).value
} else {
acc[key] = entry
}
return acc
}, {})
}, [subBlockValues])
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig.subBlocks),
[blockConfig.subBlocks]
)
const canonicalModeOverrides = block.data?.canonicalModes
const effectiveAdvanced =
(block.advancedMode ?? false) ||
hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex)
const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden || subBlock.hideFromPreview) return false
// Only filter out trigger-mode subblocks for non-trigger blocks
// Trigger-only blocks (category 'triggers') should display their trigger subblocks
if (subBlock.mode === 'trigger' && blockConfig.category !== 'triggers') return false
if (subBlock.condition) {
return evaluateCondition(subBlock.condition, subBlockValues)
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (
!isSubBlockVisibleForMode(
subBlock,
effectiveAdvanced,
canonicalIndex,
rawValues,
canonicalModeOverrides
)
) {
return false
}
return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
const statusVariant =

View File

@@ -420,7 +420,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalContent size='md'>
<ModalHeader>Help &amp; Support</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>

View File

@@ -1069,7 +1069,7 @@ export function AccessControl() {
</Modal>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -1185,7 +1185,7 @@ export function AccessControl() {
</div>
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Create Permission Group</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
@@ -1237,7 +1237,7 @@ export function AccessControl() {
</Modal>
<Modal open={!!deletingGroup} onOpenChange={() => setDeletingGroup(null)}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Permission Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -392,7 +392,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -112,7 +112,7 @@ export function CreateApiKeyModal({
<>
{/* Create API Key Dialog */}
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -176,7 +176,7 @@ export function CreateApiKeyModal({
data-form-type='other'
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
{createError}
</p>
)}
@@ -215,7 +215,7 @@ export function CreateApiKeyModal({
}
}}
>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -276,7 +276,7 @@ export function BYOK() {
</Button>
</div>
{error && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
</div>
</ModalBody>
@@ -306,7 +306,7 @@ export function BYOK() {
</Modal>
<Modal open={!!deleteConfirmProvider} onOpenChange={() => setDeleteConfirmProvider(null)}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete API Key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -211,7 +211,7 @@ export function Copilot() {
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -234,7 +234,7 @@ export function Copilot() {
autoFocus
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{createError}</p>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{createError}</p>
)}
</div>
</ModalBody>
@@ -273,7 +273,7 @@ export function Copilot() {
}
}}
>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
@@ -310,7 +310,7 @@ export function Copilot() {
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -824,7 +824,7 @@ export function CredentialSets() {
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
@@ -897,7 +897,7 @@ export function CredentialSets() {
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -925,7 +925,7 @@ export function CredentialSets() {
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -206,7 +206,7 @@ export function CustomTools() {
/>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -821,7 +821,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
</div>
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>

View File

@@ -390,7 +390,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
</div>
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Disconnect Service</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -3,13 +3,17 @@ import { Label } from '@/components/emcn'
interface FormFieldProps {
label: string
children: React.ReactNode
optional?: boolean
}
export function FormField({ label, children }: FormFieldProps) {
export function FormField({ label, children, optional }: FormFieldProps) {
return (
<div className='flex items-center justify-between gap-[12px]'>
<Label className='w-[100px] shrink-0 font-medium text-[13px] text-[var(--text-secondary)]'>
{label}
{optional && (
<span className='ml-1 font-normal text-[11px] text-[var(--text-muted)]'>(optional)</span>
)}
</Label>
<div className='relative flex-1'>{children}</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search, X } from 'lucide-react'
import { ChevronDown, Plus, Search, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -77,10 +77,17 @@ interface EnvVarDropdownConfig {
onClose: () => void
}
interface McpToolSchema {
type: 'object'
properties?: Record<string, unknown>
required?: string[]
}
interface McpTool {
name: string
description?: string
serverId: string
inputSchema?: McpToolSchema
}
interface McpServer {
@@ -381,6 +388,7 @@ export function MCP({ initialServerId }: MCPProps) {
const [refreshingServers, setRefreshingServers] = useState<
Record<string, { status: 'refreshing' | 'refreshed'; workflowsUpdated?: number }>
>({})
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const [showEnvVars, setShowEnvVars] = useState(false)
const [envSearchTerm, setEnvSearchTerm] = useState('')
@@ -669,6 +677,22 @@ export function MCP({ initialServerId }: MCPProps) {
*/
const handleBackToList = useCallback(() => {
setSelectedServerId(null)
setExpandedTools(new Set())
}, [])
/**
* Toggles the expanded state of a tool's parameters.
*/
const toggleToolExpanded = useCallback((toolName: string) => {
setExpandedTools((prev) => {
const newSet = new Set(prev)
if (newSet.has(toolName)) {
newSet.delete(toolName)
} else {
newSet.add(toolName)
}
return newSet
})
}, [])
/**
@@ -843,38 +867,113 @@ export function MCP({ initialServerId }: MCPProps) {
{tools.map((tool) => {
const issues = getStoredToolIssues(server.id, tool.name)
const affectedWorkflows = issues.map((i) => i.workflowName)
const isExpanded = expandedTools.has(tool.name)
const hasParams =
tool.inputSchema?.properties &&
Object.keys(tool.inputSchema.properties).length > 0
const requiredParams = tool.inputSchema?.required || []
return (
<div
key={tool.name}
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
className='overflow-hidden rounded-[6px] border bg-[var(--surface-3)]'
>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
<button
type='button'
onClick={() => hasParams && toggleToolExpanded(tool.name)}
className={cn(
'flex w-full items-start justify-between px-[10px] py-[8px] text-left',
hasParams && 'cursor-pointer hover:bg-[var(--surface-4)]'
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
disabled={!hasParams}
>
<div className='flex-1'>
<div className='flex items-center gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
{tool.name}
</p>
{issues.length > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div>
<Badge
variant={getIssueBadgeVariant(issues[0].issue)}
size='sm'
className='cursor-help'
>
{getIssueBadgeLabel(issues[0].issue)}
</Badge>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Update in: {affectedWorkflows.join(', ')}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{tool.description && (
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
{tool.description}
</p>
)}
</div>
{hasParams && (
<ChevronDown
className={cn(
'mt-[2px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
)}
</button>
{isExpanded && hasParams && (
<div className='border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[10px] py-[8px]'>
<p className='mb-[6px] font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
Parameters
</p>
<div className='flex flex-col gap-[6px]'>
{Object.entries(tool.inputSchema!.properties!).map(
([paramName, param]) => {
const isRequired = requiredParams.includes(paramName)
const paramType =
typeof param === 'object' && param !== null
? (param as { type?: string }).type || 'any'
: 'any'
const paramDesc =
typeof param === 'object' && param !== null
? (param as { description?: string }).description
: undefined
return (
<div
key={paramName}
className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[8px] py-[6px]'
>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{paramName}
</span>
<Badge variant='outline' size='sm'>
{paramType}
</Badge>
{isRequired && (
<Badge variant='default' size='sm'>
required
</Badge>
)}
</div>
{paramDesc && (
<p className='mt-[3px] text-[11px] text-[var(--text-tertiary)] leading-relaxed'>
{paramDesc}
</p>
)}
</div>
)
}
)}
</div>
</div>
)}
</div>
)
@@ -1071,7 +1170,7 @@ export function MCP({ initialServerId }: MCPProps) {
</div>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete MCP Server</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>

View File

@@ -245,10 +245,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate
)}, then downgrade to free plan.`}{' '}
{!isCancelAtPeriodEnd && (
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
)}
)}, then downgrade to free plan. You can restore your subscription at any time.`}
</p>
{!isCancelAtPeriodEnd && (
@@ -266,7 +263,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</ModalBody>
<ModalFooter>
<Button
variant='active'
variant='default'
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>

View File

@@ -183,7 +183,7 @@ export function MemberInvitationCard({
aria-autocomplete='none'
/>
{emailError && (
<p className='mt-1 text-[11px] text-[var(--text-error)] leading-tight'>
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
{emailError}
</p>
)}
@@ -295,7 +295,7 @@ export function MemberInvitationCard({
{/* Invitation error - inline */}
{invitationError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
{invitationError instanceof Error && invitationError.message
? invitationError.message
: String(invitationError)}

View File

@@ -104,7 +104,7 @@ export function NoOrganizationView({
<div className='flex flex-col gap-[8px]'>
{error && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>
)}
<div className='flex justify-end'>
<Button
@@ -179,7 +179,7 @@ export function NoOrganizationView({
</div>
</div>
{error && <p className='text-[11px] text-[var(--text-error)] leading-tight'>{error}</p>}
{error && <p className='text-[12px] text-[var(--text-error)] leading-tight'>{error}</p>}
<ModalFooter>
<Button

View File

@@ -33,13 +33,19 @@ export function RemoveMemberDialog({
}: RemoveMemberDialogProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
{isSelfRemoval
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
: `Are you sure you want to remove ${memberName} from the team?`}{' '}
{isSelfRemoval ? (
'Are you sure you want to leave this organization? You will lose access to all team resources.'
) : (
<>
Are you sure you want to remove{' '}
<span className='font-medium text-[var(--text-primary)]'>{memberName}</span> from
the team?
</>
)}{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
@@ -64,14 +70,14 @@ export function RemoveMemberDialog({
{error && (
<div className='mt-[8px]'>
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
{error instanceof Error && error.message ? error.message : String(error)}
</p>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant='active' onClick={onCancel}>
<Button variant='default' onClick={onCancel}>
Cancel
</Button>
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>

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