Compare commits

...

28 Commits

Author SHA1 Message Date
Siddharth Ganesan
5add92a613 Use b64 2026-01-29 18:10:47 -08:00
Siddharth Ganesan
4ab3e23cf7 Works 2026-01-29 17:35:34 -08:00
Siddharth Ganesan
aa893d56d8 Fix media 2026-01-29 17:20:38 -08:00
Siddharth Ganesan
599ffb77e6 v1 2026-01-29 17:19:29 -08:00
Siddharth Ganesan
86c3b82339 Add anvanced mode to messages 2026-01-29 13:19:48 -08:00
Siddharth Ganesan
d44c75f486 Add toggle, haven't tested 2026-01-29 13:17:27 -08:00
Siddharth Ganesan
2b026ded16 fix(copilot): hosted api key validation + credential validation (#3000)
* Fix

* Fix greptile

* Fix validation

* Fix comments

* Lint

* Fix

* remove passed in workspace id ref

* Fix comments

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-29 10:48:59 -08:00
Siddharth Ganesan
dca0758054 fix(executor): conditional deactivation for loops/parallels (#3069)
* Fix deactivation

* Remove comments
2026-01-29 10:43:30 -08:00
Waleed
ae17c90bdf chore(readme): update readme.md (#3066) 2026-01-28 23:51:34 -08:00
Waleed
1256a15266 fix(posthog): move session recording proxy to middleware for large payload support (#3065)
Next.js rewrites can strip request bodies for large payloads (1MB+),
causing 400 errors from CloudFront. PostHog session recordings require
up to 64MB per message. Moving the proxy to middleware ensures proper
body passthrough.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:49:57 -08:00
Waleed
0b2b7ed9c8 fix(oauth): use createElement for icon components to fix React hooks error (#3064) 2026-01-28 23:37:00 -08:00
Vikhyath Mondreti
0d8d9fb238 fix(type): logs workspace delivery (#3063) 2026-01-28 21:54:20 -08:00
Vikhyath Mondreti
e0f1e66f4f feat(child-workflows): nested execution snapshots (#3059)
* feat(child-workflows): nested execution snapshots

* cleanup typing

* address bugbot comments and fix tests

* do not cascade delete logs/snapshots

* fix few more inconsitencies

* fix external logs route

* add fallback color
2026-01-28 19:40:52 -08:00
Emir Karabeg
20bb7cdec6 improvement(preview): include current workflow badge in breadcrumb in workflow snapshot (#3062)
* feat(preview): add workflow context badge for nested navigation

Adds a badge next to the Back button when viewing nested workflows
to help users identify which workflow they are currently viewing.
This is especially helpful when navigating deeply into nested
workflow blocks.

Changes:
- Added workflowName field to WorkflowStackEntry interface
- Capture workflow name from metadata when drilling down
- Display workflow name badge next to Back button

Co-authored-by: emir <emir@simstudio.ai>

* added workflow name and desc to metadata for workflow preview

* added copy and search icon in code in preview editor

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: waleed <walif6@gmail.com>
2026-01-28 19:33:19 -08:00
Waleed
1469e9c66c feat(youtube): add captions, trending, and video categories tools with enhanced API coverage (#3060)
* feat(youtube): add captions, trending, and video categories tools with enhanced API coverage

* fix(youtube): remove captions tool (requires OAuth), fix tinybird defaults, encode pageToken
2026-01-28 19:08:33 -08:00
Waleed
06d7ce7667 feat(timeout): add API block timeout configuration (#3053)
* feat(timeout): add timeout subblock to the api block

* fix(timeout): honor timeout config for internal routes and fix type coercion

- Add AbortController support for internal routes (/api/*) to honor timeout
- Fix type coercion: convert string timeout from short-input to number
- Handle NaN gracefully by falling back to undefined (default timeout)

Fixes #2786
Fixes #2242

* fix: remove redundant clearTimeout in catch block

* fix: validate timeout is positive number

Negative timeout values would cause immediate request abort since
JavaScript treats negative setTimeout delays as 0.

* update docs image, update search modal performance

* removed unused keywords type

* ack comments

* cleanup

* fix: add default timeout for internal routes and validate finite timeout

- Internal routes now use same 5-minute default as external routes
- Added Number.isFinite() check to reject Infinity values

* fix: enforce max timeout and improve error message consistency

- Clamp timeout to max 600000ms (10 minutes) as documented
- External routes now report timeout value in error message

* remove unused code
2026-01-28 17:14:26 -08:00
Emir Karabeg
1bc476f10b fix(copilot): panning on workflow (#3057) 2026-01-28 16:37:12 -08:00
Vikhyath Mondreti
9e40342af8 fix(snapshot): consolidate to use hasWorkflowChanges check (#3051)
* fix(snapshot): consolidate to use hasWorkflowChanges check

* Remove debug logs

* fix normalization logic

* fix serializer for canonical modes
2026-01-28 16:29:17 -08:00
Waleed
0c0f19c717 fix(icons): update strokeWidth of action bar items to match, update run from block icon to match run workflow button (#3056)
* fix(icons): update strokeWidth of action bar items to match, update run from block icon to match run workflow button

* update docs
2026-01-28 16:24:06 -08:00
Emir Karabeg
12d529d045 fix: terminal spacing, subflow disabled in preview (#3055)
* fix: terminal spacing, subflow disabled in preview

* addressed comments
2026-01-28 15:41:46 -08:00
Vikhyath Mondreti
57f0837da7 fix(child-workflow-error-spans): pass trace-spans accurately in block logs (#3054)
* fix(child-workflow): must bypass hiddenFromDisplay config

* fix passing of spans to be in block log

* keep fallback for backwards compat

* fix error message formatting

* clean up
2026-01-28 14:54:35 -08:00
Emir Karabeg
5c02d46d55 feat(terminal): structured output (#3026)
* feat(code): collapsed JSON in terminal

* improvement(code): addressed comments

* feat(terminal): added structured output; improvement(preview): note block

* feat(terminal): log view

* improvement(terminal): ui/ux

* improvement(terminal): default sizing and collapsed width

* fix: code colors, terminal large output handling

* fix(terminal): structured search

* improvement: preivew accuracy, invite-modal admin, logs live
2026-01-28 14:40:43 -08:00
Waleed
8b2404752b feat(description): add deployment version descriptions (#3048)
* feat(description): added version description for deployments table

* feat(description): refactor to tanstack query and remove useEffect

* add wand to generate diff

* ack comments

* removed redundant logic, kept single source of truth for diff

* updated docs

* use consolidated sse parsing util, add loops & parallels check

* DRY
2026-01-28 13:52:40 -08:00
Waleed
c00f05c346 fix(tests): use UTC methods for timezone-independent schedule assertions (#3052) 2026-01-28 13:50:22 -08:00
Vikhyath Mondreti
78410eef84 improvement(inputs): sanitize trigger inputs better (#3047) 2026-01-28 12:57:20 -08:00
Siddharth Ganesan
655fe4f3b7 feat(executor): run from/until block (#3029)
* Run from block

* Fixes

* Fix

* Fix

* Minor improvements

* Fix

* Fix trace spans

* Fix loop l ogs

* Change ordering

* Run u ntil block

* Lint

* Clean up

* Fix

* Allow run from block for triggers

* Consolidation

* Fix lint

* Fix

* Fix mock payload

* Fix

* Fix trigger clear snapshot

* Fix loops and parallels

* Fix

* Cleanup

* Fix test

* Fix bugs

* Catch error

* Fix

* Fix

* I think it works??

* Fix

* Fix

* Add tests

* Fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-28 12:53:23 -08:00
Waleed
72a2f79701 improvement(search-modal): add quick navigation items and fix cmdk value uniqueness (#3050)
* improvement(search-modal): add quick navigation items and fix cmdk value uniqueness

* rerank
2026-01-28 12:39:00 -08:00
Waleed
2c2b485f81 fix(workflow): update container dimensions on keyboard movement (#3043)
* fix(workflow): update container dimensions on keyboard movement

* fix(workflow): avoid duplicate container updates during drag

Add !change.dragging check to only handle keyboard movements in
onNodesChange, since mouse drags are already handled by onNodeDrag.

* fix(workflow): persist keyboard movements to backend

Keyboard arrow key movements now call collaborativeBatchUpdatePositions
to sync position changes to the backend for persistence and real-time
collaboration.

* improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs (#3044)

* improvement(cmdk): refactor search modal to use cmdk + fix icon SVG IDs

* chore: remove unrelated workflow.tsx changes

* chore: remove comments

* chore: add devtools middleware to search modal store

* fix: allow search data re-initialization when permissions change

* fix: include keywords in search filter + show service name in tool operations

* fix: correct filterBlocks type signature

* fix: move generic to function parameter position

* fix(mcp): correct event handler type for onInput

* perf: always render command palette for instant opening

* fix: clear search input when modal reopens

* fix(helm): move rotationPolicy under privateKey for cert-manager compatibility (#3046)

* fix(helm): move rotationPolicy under privateKey for cert-manager compatibility

* docs(helm): add reclaimPolicy Retain guidance for production database storage

* fix(helm): prevent empty branding ConfigMap creation

* fix(workflow): avoid duplicate position updates on drag end

Check isInDragOperation before persisting in onNodesChange to prevent
duplicate calls. Drag-end events have dragStartPosition still set,
while keyboard movements don't, allowing proper distinction.
2026-01-28 12:31:38 -08:00
167 changed files with 33319 additions and 3437 deletions

View File

@@ -172,31 +172,6 @@ Key environment variables for self-hosted deployments. See [`.env.example`](apps
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) | | `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features | | `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
## Troubleshooting
### Ollama models not showing in dropdown (Docker)
If you're running Ollama on your host machine and Sim in Docker, change `OLLAMA_URL` from `localhost` to `host.docker.internal`:
```bash
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
```
See [Using an External Ollama Instance](#using-an-external-ollama-instance) for details.
### Database connection issues
Ensure PostgreSQL has the pgvector extension installed. When using Docker, wait for the database to be healthy before running migrations.
### Port conflicts
If ports 3000, 3002, or 5432 are in use, configure alternatives:
```bash
# Custom ports
NEXT_PUBLIC_APP_URL=http://localhost:3100 POSTGRES_PORT=5433 docker compose up -d
```
## Tech Stack ## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router) - **Framework**: [Next.js](https://nextjs.org/) (App Router)

View File

@@ -280,14 +280,24 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Click clear button in Chat panel</td> <td>Click clear button in Chat panel</td>
<td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td> <td><ActionImage src="/static/quick-reference/clear-chat.png" alt="Clear chat history" /></td>
</tr> </tr>
<tr>
<td>Run from block</td>
<td>Hover block → Click play button, or right-click → **Run from block**</td>
<td><ActionImage src="/static/quick-reference/run-from-block.png" alt="Run from block" /></td>
</tr>
<tr>
<td>Run until block</td>
<td>Right-click block → **Run until block**</td>
<td><ActionImage src="/static/quick-reference/run-until-block.png" alt="Run until block" /></td>
</tr>
<tr> <tr>
<td>View execution logs</td> <td>View execution logs</td>
<td>Open terminal panel at bottom, or `Mod+L`</td> <td>Open terminal panel at bottom, or `Mod+L`</td>
<td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td> <td><ActionImage src="/static/quick-reference/terminal.png" alt="Execution logs terminal" /></td>
</tr> </tr>
<tr> <tr>
<td>Filter logs by block or status</td> <td>Filter logs</td>
<td>Click block filter in terminal or right-click log entry → **Filter by Block** or **Filter by Status**</td> <td>Click filter icon in terminal Filter by block or status</td>
<td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td> <td><ActionImage src="/static/quick-reference/filter-block.png" alt="Filter logs by block" /></td>
</tr> </tr>
<tr> <tr>
@@ -335,6 +345,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Access previous versions in Deploy tab → **Promote to live**</td> <td>Access previous versions in Deploy tab → **Promote to live**</td>
<td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td> <td><ActionImage src="/static/quick-reference/promote-deployment.png" alt="Promote deployment to live" /></td>
</tr> </tr>
<tr>
<td>Add version description</td>
<td>Deploy tab → Click description icon → Add or generate description</td>
<td><ActionVideo src="quick-reference/deployment-description.mp4" alt="Add deployment version description" /></td>
</tr>
<tr> <tr>
<td>Copy API endpoint</td> <td>Copy API endpoint</td>
<td>Deploy tab → API → Copy API cURL</td> <td>Deploy tab → API → Copy API cURL</td>

View File

@@ -26,78 +26,41 @@ In Sim, the YouTube integration enables your agents to programmatically search a
## Usage Instructions ## Usage Instructions
Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments. Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video captions, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.
## Tools ## Tools
### `youtube_search` ### `youtube_captions`
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more. List available caption tracks (subtitles/transcripts) for a YouTube video. Returns information about each caption including language, type, and whether it is auto-generated.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Search query for YouTube videos | | `videoId` | string | Yes | YouTube video ID to get captions for |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(&lt;4 min\), "medium" \(4-20 min\), "long" \(&gt;20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\) |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_details`
Get detailed information about a specific YouTube video.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
#### Output #### Output
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `videoId` | string | YouTube video ID | | `items` | array | Array of available caption tracks for the video |
| `title` | string | Video title | | ↳ `captionId` | string | Caption track ID |
| `description` | string | Video description | | ↳ `language` | string | Language code of the caption \(e.g., |
| `channelId` | string | Channel ID | | ↳ `name` | string | Name/label of the caption track |
| `channelTitle` | string | Channel name | | ↳ `trackKind` | string | Type of caption track: |
| `publishedAt` | string | Published date and time | | ↳ `lastUpdated` | string | When the caption was last updated |
| `duration` | string | Video duration in ISO 8601 format | | ↳ `isCC` | boolean | Whether this is a closed caption track |
| `viewCount` | number | Number of views | | ↳ `isAutoSynced` | boolean | Whether the caption timing was automatically synced |
| `likeCount` | number | Number of likes | | ↳ `audioTrackType` | string | Type of audio track this caption is for |
| `commentCount` | number | Number of comments | | `totalResults` | number | Total number of caption tracks available |
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
### `youtube_channel_info` ### `youtube_channel_info`
Get detailed information about a YouTube channel. Get detailed information about a YouTube channel including statistics, branding, and content details.
#### Input #### Input
@@ -114,43 +77,20 @@ Get detailed information about a YouTube channel.
| `channelId` | string | YouTube channel ID | | `channelId` | string | YouTube channel ID |
| `title` | string | Channel name | | `title` | string | Channel name |
| `description` | string | Channel description | | `description` | string | Channel description |
| `subscriberCount` | number | Number of subscribers | | `subscriberCount` | number | Number of subscribers \(0 if hidden\) |
| `videoCount` | number | Number of videos | | `videoCount` | number | Number of public videos |
| `viewCount` | number | Total channel views | | `viewCount` | number | Total channel views |
| `publishedAt` | string | Channel creation date | | `publishedAt` | string | Channel creation date |
| `thumbnail` | string | Channel thumbnail URL | | `thumbnail` | string | Channel thumbnail/avatar URL |
| `customUrl` | string | Channel custom URL | | `customUrl` | string | Channel custom URL \(handle\) |
| `country` | string | Country the channel is associated with |
### `youtube_channel_videos` | `uploadsPlaylistId` | string | Playlist ID containing all channel uploads \(use with playlist_items\) |
| `bannerImageUrl` | string | Channel banner image URL |
Get all videos from a specific YouTube channel, with sorting options. | `hiddenSubscriberCount` | boolean | Whether the subscriber count is hidden |
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_channel_playlists` ### `youtube_channel_playlists`
Get all playlists from a specific YouTube channel. Get all public playlists from a specific YouTube channel.
#### Input #### Input
@@ -172,19 +112,80 @@ Get all playlists from a specific YouTube channel.
| ↳ `thumbnail` | string | Playlist thumbnail URL | | ↳ `thumbnail` | string | Playlist thumbnail URL |
| ↳ `itemCount` | number | Number of videos in playlist | | ↳ `itemCount` | number | Number of videos in playlist |
| ↳ `publishedAt` | string | Playlist creation date | | ↳ `publishedAt` | string | Playlist creation date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of playlists in the channel | | `totalResults` | number | Total number of playlists in the channel |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_playlist_items` ### `youtube_channel_videos`
Get videos from a YouTube playlist. Search for videos from a specific YouTube channel with sorting options. For complete channel video list, use channel_info to get uploadsPlaylistId, then use playlist_items.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `playlistId` | string | Yes | YouTube playlist ID | | `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return | | `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first, default\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_comments`
Get top-level comments from a YouTube video with author details and engagement.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `maxResults` | number | No | Maximum number of comments to return \(1-100\) |
| `order` | string | No | Order of comments: "time" \(newest first\) or "relevance" \(most relevant first\) |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of top-level comments from the video |
| ↳ `commentId` | string | Comment ID |
| ↳ `authorDisplayName` | string | Comment author display name |
| ↳ `authorChannelUrl` | string | Comment author channel URL |
| ↳ `authorProfileImageUrl` | string | Comment author profile image URL |
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
| ↳ `textOriginal` | string | Comment text \(plain text\) |
| ↳ `likeCount` | number | Number of likes on the comment |
| ↳ `publishedAt` | string | When the comment was posted |
| ↳ `updatedAt` | string | When the comment was last edited |
| ↳ `replyCount` | number | Number of replies to this comment |
| `totalResults` | number | Total number of comment threads available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_playlist_items`
Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `playlistId` | string | Yes | YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos. |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination | | `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
@@ -198,22 +199,65 @@ Get videos from a YouTube playlist.
| ↳ `description` | string | Video description | | ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL | | ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Date added to playlist | | ↳ `publishedAt` | string | Date added to playlist |
| ↳ `channelTitle` | string | Channel name | | ↳ `channelTitle` | string | Playlist owner channel name |
| ↳ `position` | number | Position in playlist | | ↳ `position` | number | Position in playlist \(0-indexed\) |
| ↳ `videoOwnerChannelId` | string | Channel ID of the video owner |
| ↳ `videoOwnerChannelTitle` | string | Channel name of the video owner |
| `totalResults` | number | Total number of items in playlist | | `totalResults` | number | Total number of items in playlist |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_comments` ### `youtube_search`
Get comments from a YouTube video. Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, live streams, and more.
#### Input #### Input
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID | | `query` | string | Yes | Search query for YouTube videos |
| `maxResults` | number | No | Maximum number of comments to return | | `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Order of comments: time or relevance | | `pageToken` | string | No | Page token for pagination \(use nextPageToken from previous response\) |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(&lt;4 min\), "medium" \(4-20 min\), "long" \(&gt;20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\). Use video_categories to list IDs. |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `eventType` | string | No | Filter by live broadcast status: "live" \(currently live\), "upcoming" \(scheduled\), "completed" \(past streams\) |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `channelId` | string | Channel ID that uploaded the video |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `liveBroadcastContent` | string | Live broadcast status: |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_trending`
Get the most popular/trending videos on YouTube. Can filter by region and video category.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get trending videos for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `videoCategoryId` | string | No | Filter by video category ID \(e.g., "10" for Music, "20" for Gaming, "17" for Sports\) |
| `maxResults` | number | No | Maximum number of trending videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination | | `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key | | `apiKey` | string | Yes | YouTube API Key |
@@ -221,17 +265,84 @@ Get comments from a YouTube video.
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ---- | ----------- | | --------- | ---- | ----------- |
| `items` | array | Array of comments from the video | | `items` | array | Array of trending videos |
| ↳ `commentId` | string | Comment ID | | ↳ `videoId` | string | YouTube video ID |
| ↳ `authorDisplayName` | string | Comment author name | | ↳ `title` | string | Video title |
| ↳ `authorChannelUrl` | string | Comment author channel URL | | ↳ `description` | string | Video description |
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) | | ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `textOriginal` | string | Comment text \(plain text\) | | ↳ `channelId` | string | Channel ID |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `viewCount` | number | Number of views |
| ↳ `likeCount` | number | Number of likes | | ↳ `likeCount` | number | Number of likes |
| ↳ `publishedAt` | string | Comment publish date | | ↳ `commentCount` | number | Number of comments |
| ↳ `updatedAt` | string | Comment last updated date | | ↳ `duration` | string | Video duration in ISO 8601 format |
| ↳ `replyCount` | number | Number of replies | | `totalResults` | number | Total number of trending videos available |
| `totalResults` | number | Total number of comments |
| `nextPageToken` | string | Token for accessing the next page of results | | `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_categories`
Get a list of video categories available on YouTube. Use this to discover valid category IDs for filtering search and trending results.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get categories for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `hl` | string | No | Language for category titles \(e.g., "en", "es", "fr"\). Defaults to English. |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of video categories available in the specified region |
| ↳ `categoryId` | string | Category ID to use in search/trending filters \(e.g., |
| ↳ `title` | string | Human-readable category name |
| ↳ `assignable` | boolean | Whether videos can be tagged with this category |
| `totalResults` | number | Total number of categories available |
### `youtube_video_details`
Get detailed information about a specific YouTube video including statistics, content details, live streaming info, and metadata.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `videoId` | string | YouTube video ID |
| `title` | string | Video title |
| `description` | string | Video description |
| `channelId` | string | Channel ID |
| `channelTitle` | string | Channel name |
| `publishedAt` | string | Published date and time |
| `duration` | string | Video duration in ISO 8601 format \(e.g., |
| `viewCount` | number | Number of views |
| `likeCount` | number | Number of likes |
| `commentCount` | number | Number of comments |
| `favoriteCount` | number | Number of times added to favorites |
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
| `categoryId` | string | YouTube video category ID |
| `definition` | string | Video definition: |
| `caption` | string | Whether captions are available: |
| `licensedContent` | boolean | Whether the video is licensed content |
| `privacyStatus` | string | Video privacy status: |
| `liveBroadcastContent` | string | Live broadcast status: |
| `defaultLanguage` | string | Default language of the video metadata |
| `defaultAudioLanguage` | string | Default audio language of the video |
| `isLiveContent` | boolean | Whether this video is or was a live stream |
| `scheduledStartTime` | string | Scheduled start time for upcoming live streams \(ISO 8601\) |
| `actualStartTime` | string | When the live stream actually started \(ISO 8601\) |
| `actualEndTime` | string | When the live stream ended \(ISO 8601\) |
| `concurrentViewers` | number | Current number of viewers \(only for active live streams\) |
| `activeLiveChatId` | string | Live chat ID for the stream \(only for active live streams\) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -14,7 +14,7 @@
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */ --panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */ --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 155px; /* TERMINAL_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
} }
.sidebar-container { .sidebar-container {

View File

@@ -56,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
deploymentVersionName: workflowDeploymentVersion.name, deploymentVersionName: workflowDeploymentVersion.name,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin( .leftJoin(
workflowDeploymentVersion, workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
@@ -65,7 +65,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
permissions, permissions,
and( and(
eq(permissions.entityType, 'workspace'), eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId), eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId) eq(permissions.userId, userId)
) )
) )
@@ -77,17 +77,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ error: 'Not found' }, { status: 404 })
} }
const workflowSummary = { const workflowSummary = log.workflowId
id: log.workflowId, ? {
name: log.workflowName, id: log.workflowId,
description: log.workflowDescription, name: log.workflowName,
color: log.workflowColor, description: log.workflowDescription,
folderId: log.workflowFolderId, color: log.workflowColor,
userId: log.workflowUserId, folderId: log.workflowFolderId,
workspaceId: log.workflowWorkspaceId, userId: log.workflowUserId,
createdAt: log.workflowCreatedAt, workspaceId: log.workflowWorkspaceId,
updatedAt: log.workflowUpdatedAt, createdAt: log.workflowCreatedAt,
} updatedAt: log.workflowUpdatedAt,
}
: null
const response = { const response = {
id: log.id, id: log.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { subscription, user, workflow, workflowExecutionLogs } from '@sim/db/schema' import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray, lt, sql } from 'drizzle-orm' import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
@@ -40,17 +40,17 @@ export async function GET(request: NextRequest) {
const freeUserIds = freeUsers.map((u) => u.userId) const freeUserIds = freeUsers.map((u) => u.userId)
const workflowsQuery = await db const workspacesQuery = await db
.select({ id: workflow.id }) .select({ id: workspace.id })
.from(workflow) .from(workspace)
.where(inArray(workflow.userId, freeUserIds)) .where(inArray(workspace.billedAccountUserId, freeUserIds))
if (workflowsQuery.length === 0) { if (workspacesQuery.length === 0) {
logger.info('No workflows found for free users') logger.info('No workspaces found for free users')
return NextResponse.json({ message: 'No workflows found for cleanup' }) return NextResponse.json({ message: 'No workspaces found for cleanup' })
} }
const workflowIds = workflowsQuery.map((w) => w.id) const workspaceIds = workspacesQuery.map((w) => w.id)
const results = { const results = {
enhancedLogs: { enhancedLogs: {
@@ -77,7 +77,7 @@ export async function GET(request: NextRequest) {
let batchesProcessed = 0 let batchesProcessed = 0
let hasMoreLogs = true let hasMoreLogs = true
logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`) logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`)
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) { while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
const oldEnhancedLogs = await db const oldEnhancedLogs = await db
@@ -99,7 +99,7 @@ export async function GET(request: NextRequest) {
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.where( .where(
and( and(
inArray(workflowExecutionLogs.workflowId, workflowIds), inArray(workflowExecutionLogs.workspaceId, workspaceIds),
lt(workflowExecutionLogs.createdAt, retentionDate) lt(workflowExecutionLogs.createdAt, retentionDate)
) )
) )
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
customKey: enhancedLogKey, customKey: enhancedLogKey,
metadata: { metadata: {
logId: String(log.id), logId: String(log.id),
workflowId: String(log.workflowId), workflowId: String(log.workflowId ?? ''),
executionId: String(log.executionId), executionId: String(log.executionId),
logType: 'enhanced', logType: 'enhanced',
archivedAt: new Date().toISOString(), archivedAt: new Date().toISOString(),

View File

@@ -6,10 +6,11 @@ import {
workflowExecutionSnapshots, workflowExecutionSnapshots,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid' import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
const logger = createLogger('LogsByExecutionIdAPI') const logger = createLogger('LogsByExecutionIdAPI')
@@ -48,14 +49,15 @@ export async function GET(
endedAt: workflowExecutionLogs.endedAt, endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs, totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost, cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
eq(permissions.entityType, 'workspace'), eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId), eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, authenticatedUserId) eq(permissions.userId, authenticatedUserId)
) )
) )
@@ -78,10 +80,42 @@ export async function GET(
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
} }
const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData']
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []
const childSnapshotIds = new Set<string>()
const collectSnapshotIds = (spans: TraceSpan[]) => {
spans.forEach((span) => {
const snapshotId = span.childWorkflowSnapshotId
if (typeof snapshotId === 'string') {
childSnapshotIds.add(snapshotId)
}
if (span.children?.length) {
collectSnapshotIds(span.children)
}
})
}
if (traceSpans.length > 0) {
collectSnapshotIds(traceSpans)
}
const childWorkflowSnapshots =
childSnapshotIds.size > 0
? await db
.select()
.from(workflowExecutionSnapshots)
.where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds)))
: []
const childSnapshotMap = childWorkflowSnapshots.reduce<Record<string, unknown>>((acc, snap) => {
acc[snap.id] = snap.stateData
return acc
}, {})
const response = { const response = {
executionId, executionId,
workflowId: workflowLog.workflowId, workflowId: workflowLog.workflowId,
workflowState: snapshot.stateData, workflowState: snapshot.stateData,
childWorkflowSnapshots: childSnapshotMap,
executionMetadata: { executionMetadata: {
trigger: workflowLog.trigger, trigger: workflowLog.trigger,
startedAt: workflowLog.startedAt.toISOString(), startedAt: workflowLog.startedAt.toISOString(),

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm' import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
totalDurationMs: workflowExecutionLogs.totalDurationMs, totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost, cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData, executionData: workflowExecutionLogs.executionData,
workflowName: workflow.name, workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
} }
const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId) const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
const rows = await db const rows = await db
.select(selectColumns) .select(selectColumns)
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(

View File

@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
workflowDeploymentVersion, workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
) )
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest) {
pausedExecutions, pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
) )
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -314,17 +314,19 @@ export async function GET(request: NextRequest) {
} catch {} } catch {}
} }
const workflowSummary = { const workflowSummary = log.workflowId
id: log.workflowId, ? {
name: log.workflowName, id: log.workflowId,
description: log.workflowDescription, name: log.workflowName,
color: log.workflowColor, description: log.workflowDescription,
folderId: log.workflowFolderId, color: log.workflowColor,
userId: log.workflowUserId, folderId: log.workflowFolderId,
workspaceId: log.workflowWorkspaceId, userId: log.workflowUserId,
createdAt: log.workflowCreatedAt, workspaceId: log.workflowWorkspaceId,
updatedAt: log.workflowUpdatedAt, createdAt: log.workflowCreatedAt,
} updatedAt: log.workflowUpdatedAt,
}
: null
return { return {
id: log.id, id: log.id,

View File

@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`, maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`,
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -103,8 +103,8 @@ export async function GET(request: NextRequest) {
const statsQuery = await db const statsQuery = await db
.select({ .select({
workflowId: workflowExecutionLogs.workflowId, workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
workflowName: workflow.name, workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
segmentIndex: segmentIndex:
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as( sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
'segment_index' 'segment_index'
@@ -120,7 +120,7 @@ export async function GET(request: NextRequest) {
), ),
}) })
.from(workflowExecutionLogs) .from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin( .innerJoin(
permissions, permissions,
and( and(
@@ -130,7 +130,11 @@ export async function GET(request: NextRequest) {
) )
) )
.where(whereCondition) .where(whereCondition)
.groupBy(workflowExecutionLogs.workflowId, workflow.name, sql`segment_index`) .groupBy(
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
sql`segment_index`
)
const workflowMap = new Map< const workflowMap = new Map<
string, string,

View File

@@ -344,7 +344,7 @@ describe('Schedule PUT API (Reactivate)', () => {
expect(nextRunAt).toBeGreaterThan(beforeCall) expect(nextRunAt).toBeGreaterThan(beforeCall)
expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000) expect(nextRunAt).toBeLessThanOrEqual(afterCall + 5 * 60 * 1000 + 1000)
// Should align with 5-minute intervals (minute divisible by 5) // Should align with 5-minute intervals (minute divisible by 5)
expect(new Date(nextRunAt).getMinutes() % 5).toBe(0) expect(new Date(nextRunAt).getUTCMinutes() % 5).toBe(0)
}) })
it('calculates nextRunAt from daily cron expression', async () => { it('calculates nextRunAt from daily cron expression', async () => {
@@ -572,7 +572,7 @@ describe('Schedule PUT API (Reactivate)', () => {
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000) expect(nextRunAt.getTime()).toBeLessThanOrEqual(beforeCall + 10 * 60 * 1000 + 1000)
// Should align with 10-minute intervals // Should align with 10-minute intervals
expect(nextRunAt.getMinutes() % 10).toBe(0) expect(nextRunAt.getUTCMinutes() % 10).toBe(0)
}) })
it('handles hourly schedules with timezone correctly', async () => { it('handles hourly schedules with timezone correctly', async () => {
@@ -598,8 +598,8 @@ describe('Schedule PUT API (Reactivate)', () => {
// Should be a future date at minute 15 // Should be a future date at minute 15
expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall) expect(nextRunAt.getTime()).toBeGreaterThan(beforeCall)
expect(nextRunAt.getMinutes()).toBe(15) expect(nextRunAt.getUTCMinutes()).toBe(15)
expect(nextRunAt.getSeconds()).toBe(0) expect(nextRunAt.getUTCSeconds()).toBe(0)
}) })
it('handles custom cron expressions with complex patterns and timezone', async () => { it('handles custom cron expressions with complex patterns and timezone', async () => {

View File

@@ -9,13 +9,24 @@ import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/
const logger = createLogger('WorkflowDeploymentVersionAPI') const logger = createLogger('WorkflowDeploymentVersionAPI')
const patchBodySchema = z.object({ const patchBodySchema = z
name: z .object({
.string() name: z
.trim() .string()
.min(1, 'Name cannot be empty') .trim()
.max(100, 'Name must be 100 characters or less'), .min(1, 'Name cannot be empty')
}) .max(100, 'Name must be 100 characters or less')
.optional(),
description: z
.string()
.trim()
.max(500, 'Description must be 500 characters or less')
.nullable()
.optional(),
})
.refine((data) => data.name !== undefined || data.description !== undefined, {
message: 'At least one of name or description must be provided',
})
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -88,33 +99,46 @@ export async function PATCH(
return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400) return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400)
} }
const { name } = validation.data const { name, description } = validation.data
const updateData: { name?: string; description?: string | null } = {}
if (name !== undefined) {
updateData.name = name
}
if (description !== undefined) {
updateData.description = description
}
const [updated] = await db const [updated] = await db
.update(workflowDeploymentVersion) .update(workflowDeploymentVersion)
.set({ name }) .set(updateData)
.where( .where(
and( and(
eq(workflowDeploymentVersion.workflowId, id), eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum) eq(workflowDeploymentVersion.version, versionNum)
) )
) )
.returning({ id: workflowDeploymentVersion.id, name: workflowDeploymentVersion.name }) .returning({
id: workflowDeploymentVersion.id,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
})
if (!updated) { if (!updated) {
return createErrorResponse('Deployment version not found', 404) return createErrorResponse('Deployment version not found', 404)
} }
logger.info( logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
`[${requestId}] Renamed deployment version ${version} for workflow ${id} to "${name}"` name: updateData.name,
) description: updateData.description,
})
return createSuccessResponse({ name: updated.name }) return createSuccessResponse({ name: updated.name, description: updated.description })
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`[${requestId}] Error renaming deployment version ${version} for workflow ${id}`, `[${requestId}] Error updating deployment version ${version} for workflow ${id}`,
error error
) )
return createErrorResponse(error.message || 'Failed to rename deployment version', 500) return createErrorResponse(error.message || 'Failed to update deployment version', 500)
} }
} }

View File

@@ -26,6 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: workflowDeploymentVersion.id, id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version, version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name, name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive, isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt, createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy, createdBy: workflowDeploymentVersion.createdBy,

View File

@@ -0,0 +1,216 @@
import { db, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
import { hasExecutionResult } from '@/executor/utils/errors'
const logger = createLogger('ExecuteFromBlockAPI')
const ExecuteFromBlockSchema = z.object({
startBlockId: z.string().min(1, 'Start block ID is required'),
sourceSnapshot: z.object({
blockStates: z.record(z.any()),
executedBlocks: z.array(z.string()),
blockLogs: z.array(z.any()),
decisions: z.object({
router: z.record(z.string()),
condition: z.record(z.string()),
}),
completedLoops: z.array(z.string()),
loopExecutions: z.record(z.any()).optional(),
parallelExecutions: z.record(z.any()).optional(),
parallelBlockMapping: z.record(z.any()).optional(),
activeExecutionPath: z.array(z.string()),
}),
input: z.any().optional(),
})
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id: workflowId } = await params
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const validation = ExecuteFromBlockSchema.safeParse(body)
if (!validation.success) {
logger.warn(`[${requestId}] Invalid request body:`, validation.error.errors)
return NextResponse.json(
{
error: 'Invalid request body',
details: validation.error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
},
{ status: 400 }
)
}
const { startBlockId, sourceSnapshot, input } = validation.data
const executionId = uuidv4()
const [workflowRecord] = await db
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
}
const workspaceId = workflowRecord.workspaceId
const workflowUserId = workflowRecord.userId
logger.info(`[${requestId}] Starting run-from-block execution`, {
workflowId,
startBlockId,
executedBlocksCount: sourceSnapshot.executedBlocks.length,
})
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
const abortController = new AbortController()
let isStreamClosed = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const { sendEvent, onBlockStart, onBlockComplete, onStream } = createSSECallbacks({
executionId,
workflowId,
controller,
isStreamClosed: () => isStreamClosed,
setStreamClosed: () => {
isStreamClosed = true
},
})
const metadata: ExecutionMetadata = {
requestId,
workflowId,
userId,
executionId,
triggerType: 'manual',
workspaceId,
workflowUserId,
useDraftState: true,
isClientSession: true,
startTime: new Date().toISOString(),
}
const snapshot = new ExecutionSnapshot(metadata, {}, input || {}, {})
try {
const startTime = new Date()
sendEvent({
type: 'execution:started',
timestamp: startTime.toISOString(),
executionId,
workflowId,
data: { startTime: startTime.toISOString() },
})
const result = await executeWorkflowCore({
snapshot,
loggingSession,
abortSignal: abortController.signal,
runFromBlock: {
startBlockId,
sourceSnapshot: sourceSnapshot as SerializableExecutionState,
},
callbacks: { onBlockStart, onBlockComplete, onStream },
})
if (result.status === 'cancelled') {
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: result.metadata?.duration || 0 },
})
} else {
sendEvent({
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: result.output,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
},
})
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Run-from-block execution failed: ${errorMessage}`)
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: executionResult?.error || errorMessage,
duration: executionResult?.metadata?.duration || 0,
},
})
} finally {
if (!isStreamClosed) {
try {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
controller.close()
} catch {}
}
}
},
cancel() {
isStreamClosed = true
abortController.abort()
markExecutionCancelled(executionId).catch(() => {})
},
})
return new NextResponse(stream, {
headers: { ...SSE_HEADERS, 'X-Execution-Id': executionId },
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error(`[${requestId}] Failed to start run-from-block execution:`, error)
return NextResponse.json(
{ error: errorMessage || 'Failed to start execution' },
{ status: 500 }
)
}
}

View File

@@ -53,6 +53,7 @@ const ExecuteWorkflowSchema = z.object({
parallels: z.record(z.any()).optional(), parallels: z.record(z.any()).optional(),
}) })
.optional(), .optional(),
stopAfterBlockId: z.string().optional(),
}) })
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -222,6 +223,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
includeFileBase64, includeFileBase64,
base64MaxBytes, base64MaxBytes,
workflowStateOverride, workflowStateOverride,
stopAfterBlockId,
} = validation.data } = validation.data
// For API key and internal JWT auth, the entire body is the input (except for our control fields) // For API key and internal JWT auth, the entire body is the input (except for our control fields)
@@ -237,6 +239,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
includeFileBase64, includeFileBase64,
base64MaxBytes, base64MaxBytes,
workflowStateOverride, workflowStateOverride,
stopAfterBlockId: _stopAfterBlockId,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
...rest ...rest
} = body } = body
@@ -434,6 +437,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
loggingSession, loggingSession,
includeFileBase64, includeFileBase64,
base64MaxBytes, base64MaxBytes,
stopAfterBlockId,
}) })
const outputWithBase64 = includeFileBase64 const outputWithBase64 = includeFileBase64
@@ -722,6 +726,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
abortSignal: abortController.signal, abortSignal: abortController.signal,
includeFileBase64, includeFileBase64,
base64MaxBytes, base64MaxBytes,
stopAfterBlockId,
}) })
if (result.status === 'paused') { if (result.status === 'paused') {

View File

@@ -133,9 +133,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = { const finalWorkflowData = {
...workflowData, ...workflowData,
state: { state: {
// Default values for expected properties
deploymentStatuses: {}, deploymentStatuses: {},
// Data from normalized tables
blocks: normalizedData.blocks, blocks: normalizedData.blocks,
edges: normalizedData.edges, edges: normalizedData.edges,
loops: normalizedData.loops, loops: normalizedData.loops,
@@ -143,8 +141,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(), lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false, isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt, deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
}, },
// Include workflow variables
variables: workflowData.variables || {}, variables: workflowData.variables || {},
} }
@@ -166,6 +167,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(), lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false, isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt, deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
}, },
variables: workflowData.variables || {}, variables: workflowData.variables || {},
} }

View File

@@ -215,6 +215,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
} }
for (const log of logs) { for (const log of logs) {
if (!log.workflowId) continue // Skip logs for deleted workflows
const idx = Math.min( const idx = Math.min(
segments - 1, segments - 1,
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs)) Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))

View File

@@ -1,5 +1,9 @@
import { memo } from 'react' import { memo } from 'react'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { StatusBar, type StatusBarSegment } from '..' import { StatusBar, type StatusBarSegment } from '..'
@@ -61,22 +65,32 @@ export function WorkflowsList({
<div> <div>
{filteredExecutions.map((workflow, idx) => { {filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId const isSelected = expandedWorkflowId === workflow.workflowId
const isDeletedWorkflow = workflow.workflowName === DELETED_WORKFLOW_LABEL
const workflowColor = isDeletedWorkflow
? DELETED_WORKFLOW_COLOR
: workflows[workflow.workflowId]?.color || '#64748b'
const canToggle = !isDeletedWorkflow
return ( return (
<div <div
key={workflow.workflowId} key={workflow.workflowId}
className={cn( className={cn(
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]', 'flex h-[44px] items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
canToggle ? 'cursor-pointer' : 'cursor-default',
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]' isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)} )}
onClick={() => onToggleWorkflow(workflow.workflowId)} onClick={() => {
if (canToggle) {
onToggleWorkflow(workflow.workflowId)
}
}}
> >
{/* Workflow name with color */} {/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'> <div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<div <div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b', backgroundColor: workflowColor,
}} }}
/> />
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'> <span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>

View File

@@ -80,6 +80,9 @@ export function ExecutionSnapshot({
}, [executionId, closeMenu]) }, [executionId, closeMenu])
const workflowState = data?.workflowState as WorkflowState | undefined const workflowState = data?.workflowState as WorkflowState | undefined
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
| Record<string, WorkflowState>
| undefined
const renderContent = () => { const renderContent = () => {
if (isLoading) { if (isLoading) {
@@ -148,6 +151,7 @@ export function ExecutionSnapshot({
key={executionId} key={executionId}
workflowState={workflowState} workflowState={workflowState}
traceSpans={traceSpans} traceSpans={traceSpans}
childWorkflowSnapshots={childWorkflowSnapshots}
className={className} className={className}
height={height} height={height}
width={width} width={width}

View File

@@ -57,40 +57,6 @@ function useSetToggle() {
) )
} }
/**
* Generates a unique key for a trace span
*/
function getSpanKey(span: TraceSpan): string {
if (span.id) {
return span.id
}
const name = span.name || 'span'
const start = span.startTime || 'unknown-start'
const end = span.endTime || 'unknown-end'
return `${name}|${start}|${end}`
}
/**
* Merges multiple arrays of trace span children, deduplicating by span key
*/
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
const merged: TraceSpan[] = []
const seen = new Set<string>()
groups.forEach((group) => {
group.forEach((child) => {
const key = getSpanKey(child)
if (seen.has(key)) {
return
}
seen.add(key)
merged.push(child)
})
})
return merged
}
/** /**
* Parses a time value to milliseconds * Parses a time value to milliseconds
*/ */
@@ -116,34 +82,16 @@ function hasErrorInTree(span: TraceSpan): boolean {
/** /**
* Normalizes and sorts trace spans recursively. * Normalizes and sorts trace spans recursively.
* Merges children from both span.children and span.output.childTraceSpans, * Deduplicates children and sorts by start time.
* deduplicates them, and sorts by start time.
*/ */
function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] { function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
return spans return spans
.map((span) => { .map((span) => {
const enrichedSpan: TraceSpan = { ...span } const enrichedSpan: TraceSpan = { ...span }
// Clean output by removing childTraceSpans after extracting // Process and deduplicate children
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') { const children = Array.isArray(span.children) ? span.children : []
enrichedSpan.output = { ...enrichedSpan.output } enrichedSpan.children = children.length > 0 ? normalizeAndSortSpans(children) : undefined
if ('childTraceSpans' in enrichedSpan.output) {
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
childTraceSpans?: TraceSpan[]
} & Record<string, unknown>
enrichedSpan.output = cleanOutput
}
}
// Merge and deduplicate children from both sources
const directChildren = Array.isArray(span.children) ? span.children : []
const outputChildren = Array.isArray(span.output?.childTraceSpans)
? (span.output!.childTraceSpans as TraceSpan[])
: []
const mergedChildren = mergeTraceSpanChildren(directChildren, outputChildren)
enrichedSpan.children =
mergedChildren.length > 0 ? normalizeAndSortSpans(mergedChildren) : undefined
return enrichedSpan return enrichedSpan
}) })
@@ -573,7 +521,19 @@ const TraceSpanNode = memo(function TraceSpanNode({
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime)) return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime]) }, [span, spanId, spanStartTime])
const hasChildren = allChildren.length > 0 // Hide empty model timing segments for agents without tool calls
const filteredChildren = useMemo(() => {
const isAgent = span.type?.toLowerCase() === 'agent'
const hasToolCalls =
(span.toolCalls?.length ?? 0) > 0 || allChildren.some((c) => c.type?.toLowerCase() === 'tool')
if (isAgent && !hasToolCalls) {
return allChildren.filter((c) => c.type?.toLowerCase() !== 'model')
}
return allChildren
}, [allChildren, span.type, span.toolCalls])
const hasChildren = filteredChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId) const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow const isToggleable = !isRootWorkflow
@@ -685,7 +645,7 @@ const TraceSpanNode = memo(function TraceSpanNode({
{/* Nested Children */} {/* Nested Children */}
{hasChildren && ( {hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'> <div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{allChildren.map((child, index) => ( {filteredChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'> <div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode <TraceSpanNode
span={child} span={child}

View File

@@ -18,6 +18,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { import {
ExecutionSnapshot, ExecutionSnapshot,
FileCards, FileCards,
@@ -25,6 +26,8 @@ import {
} from '@/app/workspace/[workspaceId]/logs/components' } from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import { import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
getDisplayStatus, getDisplayStatus,
StatusBadge, StatusBadge,
@@ -274,16 +277,13 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog]) }, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display) // Extract and clean the workflow final output (recursively remove hidden keys for cleaner display)
const workflowOutput = useMemo(() => { const workflowOutput = useMemo(() => {
const executionData = log?.executionData as const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> } | { finalOutput?: Record<string, unknown> }
| undefined | undefined
if (!executionData?.finalOutput) return null if (!executionData?.finalOutput) return null
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as { return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
childTraceSpans?: unknown
} & Record<string, unknown>
return cleanOutput
}, [log?.executionData]) }, [log?.executionData])
useEffect(() => { useEffect(() => {
@@ -388,22 +388,25 @@ export const LogDetails = memo(function LogDetails({
</div> </div>
{/* Workflow Card */} {/* Workflow Card */}
{log.workflow && ( <div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
<div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'> <div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'> Workflow
Workflow
</div>
<div className='flex min-w-0 items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
</span>
</div>
</div> </div>
)} <div className='flex min-w-0 items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{
backgroundColor:
log.workflow?.color ||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined),
}}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
</div>
</div> </div>
{/* Execution ID */} {/* Execution ID */}

View File

@@ -7,6 +7,8 @@ import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn' import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
formatDuration, formatDuration,
getDisplayStatus, getDisplayStatus,
@@ -33,6 +35,11 @@ interface LogRowProps {
const LogRow = memo( const LogRow = memo(
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) { function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt]) const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow
? DELETED_WORKFLOW_LABEL
: log.workflow?.name || 'Unknown'
const workflowColor = isDeletedWorkflow ? DELETED_WORKFLOW_COLOR : log.workflow?.color
const handleClick = useCallback(() => onClick(log), [onClick, log]) const handleClick = useCallback(() => onClick(log), [onClick, log])
@@ -78,10 +85,15 @@ const LogRow = memo(
> >
<div <div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }} style={{ backgroundColor: workflowColor }}
/> />
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'> <span
{log.workflow?.name || 'Unknown'} className={cn(
'min-w-0 truncate font-medium text-[12px]',
isDeletedWorkflow ? 'text-[var(--text-tertiary)]' : 'text-[var(--text-primary)]'
)}
>
{workflowName}
</span> </span>
</div> </div>

View File

@@ -78,7 +78,7 @@ export default function Logs() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const [isLive, setIsLive] = useState(false) const [isLive, setIsLive] = useState(true)
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const isSearchOpenRef = useRef<boolean>(false) const isSearchOpenRef = useRef<boolean>(false)

View File

@@ -27,6 +27,9 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration', 'duration',
] as const ] as const
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)'
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/** /**

View File

@@ -1,11 +1,13 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react' import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications' import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -49,6 +51,7 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry() const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution()
const addNotification = useNotificationStore((s) => s.addNotification) const addNotification = useNotificationStore((s) => s.addNotification)
@@ -97,12 +100,39 @@ export const ActionBar = memo(
) )
) )
const { activeWorkflowId } = useWorkflowRegistry()
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
const edges = useWorkflowStore((state) => state.edges)
const isStartBlock = isInputDefinitionTrigger(blockType) const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response' const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note' const isNoteBlock = blockType === 'note'
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
const incomingEdges = edges.filter((edge) => edge.target === blockId)
const isTriggerBlock = incomingEdges.length === 0
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
const isSourceSatisfied = (sourceId: string) => {
if (snapshot?.executedBlocks.includes(sourceId)) return true
// Check if source is a trigger (has no incoming edges itself)
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
return sourceIncomingEdges.length === 0
}
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
const dependenciesSatisfied =
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
const canRunFromBlock =
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
const handleRunFromBlockClick = useCallback(() => {
if (!activeWorkflowId || !canRunFromBlock) return
handleRunFromBlock(blockId, activeWorkflowId)
}, [blockId, activeWorkflowId, canRunFromBlock, handleRunFromBlock])
/** /**
* Get appropriate tooltip message based on disabled state * Get appropriate tooltip message based on disabled state
@@ -128,30 +158,35 @@ export const ActionBar = memo(
'dark:border-transparent dark:bg-[var(--surface-4)]' 'dark:border-transparent dark:bg-[var(--surface-4)]'
)} )}
> >
{!isNoteBlock && ( {!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled) { if (canRunFromBlock && !disabled) {
collaborativeBatchToggleBlockEnabled([blockId]) handleRunFromBlockClick()
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled} disabled={disabled || !canRunFromBlock}
> >
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />} <PlayOutline className={ICON_SIZE} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} {(() => {
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run upstream blocks first'
return 'Run from block'
})()}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
{isSubflowBlock && ( {!isNoteBlock && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button

View File

@@ -40,9 +40,16 @@ export interface BlockMenuProps {
onRemoveFromSubflow: () => void onRemoveFromSubflow: () => void
onOpenEditor: () => void onOpenEditor: () => void
onRename: () => void onRename: () => void
onRunFromBlock?: () => void
onRunUntilBlock?: () => void
hasClipboard?: boolean hasClipboard?: boolean
showRemoveFromSubflow?: boolean showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean
disableEdit?: boolean disableEdit?: boolean
isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean
} }
/** /**
@@ -65,9 +72,14 @@ export function BlockMenu({
onRemoveFromSubflow, onRemoveFromSubflow,
onOpenEditor, onOpenEditor,
onRename, onRename,
onRunFromBlock,
onRunUntilBlock,
hasClipboard = false, hasClipboard = false,
showRemoveFromSubflow = false, showRemoveFromSubflow = false,
canRunFromBlock = false,
disableEdit = false, disableEdit = false,
isExecuting = false,
isPositionalTrigger = false,
}: BlockMenuProps) { }: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1 const isSingleBlock = selectedBlocks.length === 1
@@ -78,10 +90,15 @@ export function BlockMenu({
(b) => (b) =>
TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type) TriggerUtils.requiresSingleInstance(b.type) || TriggerUtils.isSingleInstanceBlockType(b.type)
) )
const hasTriggerBlock = selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b)) // A block is a trigger if it's explicitly a trigger type OR has no incoming edges (positional trigger)
const hasTriggerBlock =
selectedBlocks.some((b) => TriggerUtils.isTriggerBlock(b)) || isPositionalTrigger
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note') const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow = const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel') isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const isInsideSubflow =
isSingleBlock &&
(selectedBlocks[0]?.parentType === 'loop' || selectedBlocks[0]?.parentType === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock const canRemoveFromSubflow = showRemoveFromSubflow && !hasTriggerBlock
@@ -203,6 +220,38 @@ export function BlockMenu({
</PopoverItem> </PopoverItem>
)} )}
{/* Run from/until block - only for single non-note block, not inside subflows */}
{isSingleBlock && !allNoteBlocks && !isInsideSubflow && (
<>
<PopoverDivider />
<PopoverItem
disabled={!canRunFromBlock || isExecuting}
onClick={() => {
if (canRunFromBlock && !isExecuting) {
onRunFromBlock?.()
onClose()
}
}}
>
Run from block
</PopoverItem>
{/* Hide "Run until" for triggers - they're always at the start */}
{!hasTriggerBlock && (
<PopoverItem
disabled={isExecuting}
onClick={() => {
if (!isExecuting) {
onRunUntilBlock?.()
onClose()
}
}}
>
Run until block
</PopoverItem>
)}
</>
)}
{/* Destructive action */} {/* Destructive action */}
<PopoverDivider /> <PopoverDivider />
<PopoverItem <PopoverItem

View File

@@ -0,0 +1,170 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import {
useGenerateVersionDescription,
useUpdateDeploymentVersion,
} from '@/hooks/queries/deployments'
interface VersionDescriptionModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId: string
version: number
versionName: string
currentDescription: string | null | undefined
}
export function VersionDescriptionModal({
open,
onOpenChange,
workflowId,
version,
versionName,
currentDescription,
}: VersionDescriptionModalProps) {
const initialDescriptionRef = useRef(currentDescription || '')
const [description, setDescription] = useState(initialDescriptionRef.current)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const updateMutation = useUpdateDeploymentVersion()
const generateMutation = useGenerateVersionDescription()
const hasChanges = description.trim() !== initialDescriptionRef.current.trim()
const isGenerating = generateMutation.isPending
const handleCloseAttempt = useCallback(() => {
if (updateMutation.isPending || isGenerating) {
return
}
if (hasChanges) {
setShowUnsavedChangesAlert(true)
} else {
onOpenChange(false)
}
}, [hasChanges, updateMutation.isPending, isGenerating, onOpenChange])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setDescription(initialDescriptionRef.current)
onOpenChange(false)
}, [onOpenChange])
const handleGenerateDescription = useCallback(() => {
generateMutation.mutate({
workflowId,
version,
onStreamChunk: (accumulated) => {
setDescription(accumulated)
},
})
}, [workflowId, version, generateMutation])
const handleSave = useCallback(() => {
if (!workflowId) return
updateMutation.mutate(
{
workflowId,
version,
description: description.trim() || null,
},
{
onSuccess: () => {
onOpenChange(false)
},
}
)
}, [workflowId, version, description, updateMutation, onOpenChange])
return (
<>
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>
<span>Version Description</span>
</ModalHeader>
<ModalBody className='space-y-[10px]'>
<div className='flex items-center justify-between'>
<p className='text-[12px] text-[var(--text-secondary)]'>
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</p>
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleGenerateDescription}
disabled={isGenerating || updateMutation.isPending}
>
{isGenerating ? 'Generating...' : 'Generate'}
</Button>
</div>
<Textarea
placeholder='Describe the changes in this deployment version...'
className='min-h-[120px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
disabled={isGenerating}
/>
<div className='flex items-center justify-between'>
{(updateMutation.error || generateMutation.error) && (
<p className='text-[12px] text-[var(--text-error)]'>
{updateMutation.error?.message || generateMutation.error?.message}
</p>
)}
{!updateMutation.error && !generateMutation.error && <div />}
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={handleCloseAttempt}
disabled={updateMutation.isPending || isGenerating}
>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSave}
disabled={updateMutation.isPending || isGenerating || !hasChanges}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='max-w-[400px]'>
<ModalHeader>
<span>Unsaved Changes</span>
</ModalHeader>
<ModalBody>
<p className='text-[14px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button variant='destructive' onClick={handleDiscardChanges}>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,26 +1,31 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx' import clsx from 'clsx'
import { MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react' import { FileText, MoreVertical, Pencil, RotateCcw, SendToBack } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui' import { Skeleton } from '@/components/ui'
import { formatDateTime } from '@/lib/core/utils/formatting'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments'
import { VersionDescriptionModal } from './version-description-modal'
const logger = createLogger('Versions')
/** Shared styling constants aligned with terminal component */
const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]' const HEADER_TEXT_CLASS = 'font-medium text-[var(--text-tertiary)] text-[12px]'
const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]' const ROW_TEXT_CLASS = 'font-medium text-[var(--text-primary)] text-[12px]'
const COLUMN_BASE_CLASS = 'flex-shrink-0' const COLUMN_BASE_CLASS = 'flex-shrink-0'
/** Column width configuration */
const COLUMN_WIDTHS = { const COLUMN_WIDTHS = {
VERSION: 'w-[180px]', VERSION: 'w-[180px]',
DEPLOYED_BY: 'w-[140px]', DEPLOYED_BY: 'w-[140px]',
TIMESTAMP: 'flex-1', TIMESTAMP: 'flex-1',
ACTIONS: 'w-[32px]', ACTIONS: 'w-[56px]',
} as const } as const
interface VersionsProps { interface VersionsProps {
@@ -31,34 +36,6 @@ interface VersionsProps {
onSelectVersion: (version: number | null) => void onSelectVersion: (version: number | null) => void
onPromoteToLive: (version: number) => void onPromoteToLive: (version: number) => void
onLoadDeployment: (version: number) => void onLoadDeployment: (version: number) => void
fetchVersions: () => Promise<void>
}
/**
* Formats a timestamp into a readable string.
* @param value - The date string or Date object to format
* @returns Formatted string like "8:36 PM PT on Oct 11, 2025"
*/
const formatDate = (value: string | Date): string => {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const timePart = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
})
const datePart = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return `${timePart} on ${datePart}`
} }
/** /**
@@ -73,14 +50,15 @@ export function Versions({
onSelectVersion, onSelectVersion,
onPromoteToLive, onPromoteToLive,
onLoadDeployment, onLoadDeployment,
fetchVersions,
}: VersionsProps) { }: VersionsProps) {
const [editingVersion, setEditingVersion] = useState<number | null>(null) const [editingVersion, setEditingVersion] = useState<number | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const [isRenaming, setIsRenaming] = useState(false)
const [openDropdown, setOpenDropdown] = useState<number | null>(null) const [openDropdown, setOpenDropdown] = useState<number | null>(null)
const [descriptionModalVersion, setDescriptionModalVersion] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const renameMutation = useUpdateDeploymentVersion()
useEffect(() => { useEffect(() => {
if (editingVersion !== null && inputRef.current) { if (editingVersion !== null && inputRef.current) {
inputRef.current.focus() inputRef.current.focus()
@@ -94,7 +72,8 @@ export function Versions({
setEditValue(currentName || `v${version}`) setEditValue(currentName || `v${version}`)
} }
const handleSaveRename = async (version: number) => { const handleSaveRename = (version: number) => {
if (renameMutation.isPending) return
if (!workflowId || !editValue.trim()) { if (!workflowId || !editValue.trim()) {
setEditingVersion(null) setEditingVersion(null)
return return
@@ -108,25 +87,21 @@ export function Versions({
return return
} }
setIsRenaming(true) renameMutation.mutate(
try { {
const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}`, { workflowId,
method: 'PATCH', version,
headers: { 'Content-Type': 'application/json' }, name: editValue.trim(),
body: JSON.stringify({ name: editValue.trim() }), },
}) {
onSuccess: () => {
if (res.ok) { setEditingVersion(null)
await fetchVersions() },
setEditingVersion(null) onError: () => {
} else { // Keep editing state open on error so user can retry
logger.error('Failed to rename version') },
} }
} catch (error) { )
logger.error('Error renaming version:', error)
} finally {
setIsRenaming(false)
}
} }
const handleCancelRename = () => { const handleCancelRename = () => {
@@ -149,6 +124,16 @@ export function Versions({
onLoadDeployment(version) onLoadDeployment(version)
} }
const handleOpenDescriptionModal = (version: number) => {
setOpenDropdown(null)
setDescriptionModalVersion(version)
}
const descriptionModalVersionData =
descriptionModalVersion !== null
? versions.find((v) => v.version === descriptionModalVersion)
: null
if (versionsLoading && versions.length === 0) { if (versionsLoading && versions.length === 0) {
return ( return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border)]'> <div className='overflow-hidden rounded-[4px] border border-[var(--border)]'>
@@ -179,7 +164,14 @@ export function Versions({
<div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}> <div className={clsx(COLUMN_WIDTHS.TIMESTAMP, 'min-w-0')}>
<Skeleton className='h-[12px] w-[160px]' /> <Skeleton className='h-[12px] w-[160px]' />
</div> </div>
<div className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')}> <div
className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex justify-end gap-[2px]'
)}
>
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
<Skeleton className='h-[20px] w-[20px] rounded-[4px]' /> <Skeleton className='h-[20px] w-[20px] rounded-[4px]' />
</div> </div>
</div> </div>
@@ -257,7 +249,7 @@ export function Versions({
'text-[var(--text-primary)] focus:outline-none focus:ring-0' 'text-[var(--text-primary)] focus:outline-none focus:ring-0'
)} )}
maxLength={100} maxLength={100}
disabled={isRenaming} disabled={renameMutation.isPending}
autoComplete='off' autoComplete='off'
autoCorrect='off' autoCorrect='off'
autoCapitalize='off' autoCapitalize='off'
@@ -289,14 +281,40 @@ export function Versions({
<span <span
className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)} className={clsx('block truncate text-[var(--text-tertiary)]', ROW_TEXT_CLASS)}
> >
{formatDate(v.createdAt)} {formatDateTime(new Date(v.createdAt))}
</span> </span>
</div> </div>
<div <div
className={clsx(COLUMN_WIDTHS.ACTIONS, COLUMN_BASE_CLASS, 'flex justify-end')} className={clsx(
COLUMN_WIDTHS.ACTIONS,
COLUMN_BASE_CLASS,
'flex items-center justify-end gap-[2px]'
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className={clsx(
'!p-1',
!v.description &&
'text-[var(--text-quaternary)] hover:text-[var(--text-tertiary)]'
)}
onClick={() => handleOpenDescriptionModal(v.version)}
>
<FileText className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[240px]'>
{v.description ? (
<p className='line-clamp-3 text-[12px]'>{v.description}</p>
) : (
<p className='text-[12px]'>Add description</p>
)}
</Tooltip.Content>
</Tooltip.Root>
<Popover <Popover
open={openDropdown === v.version} open={openDropdown === v.version}
onOpenChange={(open) => setOpenDropdown(open ? v.version : null)} onOpenChange={(open) => setOpenDropdown(open ? v.version : null)}
@@ -311,6 +329,10 @@ export function Versions({
<Pencil className='h-3 w-3' /> <Pencil className='h-3 w-3' />
<span>Rename</span> <span>Rename</span>
</PopoverItem> </PopoverItem>
<PopoverItem onClick={() => handleOpenDescriptionModal(v.version)}>
<FileText className='h-3 w-3' />
<span>{v.description ? 'Edit description' : 'Add description'}</span>
</PopoverItem>
{!v.isActive && ( {!v.isActive && (
<PopoverItem onClick={() => handlePromote(v.version)}> <PopoverItem onClick={() => handlePromote(v.version)}>
<RotateCcw className='h-3 w-3' /> <RotateCcw className='h-3 w-3' />
@@ -328,6 +350,20 @@ export function Versions({
) )
})} })}
</div> </div>
{workflowId && descriptionModalVersionData && (
<VersionDescriptionModal
key={descriptionModalVersionData.version}
open={descriptionModalVersion !== null}
onOpenChange={(open) => !open && setDescriptionModalVersion(null)}
workflowId={workflowId}
version={descriptionModalVersionData.version}
versionName={
descriptionModalVersionData.name || `v${descriptionModalVersionData.version}`
}
currentDescription={descriptionModalVersionData.description}
/>
)}
</div> </div>
) )
} }

View File

@@ -32,7 +32,6 @@ interface GeneralDeployProps {
versionsLoading: boolean versionsLoading: boolean
onPromoteToLive: (version: number) => Promise<void> onPromoteToLive: (version: number) => Promise<void>
onLoadDeploymentComplete: () => void onLoadDeploymentComplete: () => void
fetchVersions: () => Promise<void>
} }
type PreviewMode = 'active' | 'selected' type PreviewMode = 'active' | 'selected'
@@ -48,7 +47,6 @@ export function GeneralDeploy({
versionsLoading, versionsLoading,
onPromoteToLive, onPromoteToLive,
onLoadDeploymentComplete, onLoadDeploymentComplete,
fetchVersions,
}: GeneralDeployProps) { }: GeneralDeployProps) {
const [selectedVersion, setSelectedVersion] = useState<number | null>(null) const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
const [previewMode, setPreviewMode] = useState<PreviewMode>('active') const [previewMode, setPreviewMode] = useState<PreviewMode>('active')
@@ -229,7 +227,6 @@ export function GeneralDeploy({
onSelectVersion={handleSelectVersion} onSelectVersion={handleSelectVersion}
onPromoteToLive={handlePromoteToLive} onPromoteToLive={handlePromoteToLive}
onLoadDeployment={handleLoadDeployment} onLoadDeployment={handleLoadDeployment}
fetchVersions={fetchVersions}
/> />
</div> </div>
</div> </div>

View File

@@ -135,11 +135,9 @@ export function DeployModal({
refetch: refetchDeploymentInfo, refetch: refetchDeploymentInfo,
} = useDeploymentInfo(workflowId, { enabled: open && isDeployed }) } = useDeploymentInfo(workflowId, { enabled: open && isDeployed })
const { const { data: versionsData, isLoading: versionsLoading } = useDeploymentVersions(workflowId, {
data: versionsData, enabled: open,
isLoading: versionsLoading, })
refetch: refetchVersions,
} = useDeploymentVersions(workflowId, { enabled: open })
const { const {
isLoading: isLoadingChat, isLoading: isLoadingChat,
@@ -450,10 +448,6 @@ export function DeployModal({
deleteTrigger?.click() deleteTrigger?.click()
}, []) }, [])
const handleFetchVersions = useCallback(async () => {
await refetchVersions()
}, [refetchVersions])
const isSubmitting = deployMutation.isPending const isSubmitting = deployMutation.isPending
const isUndeploying = undeployMutation.isPending const isUndeploying = undeployMutation.isPending
@@ -512,7 +506,6 @@ export function DeployModal({
versionsLoading={versionsLoading} versionsLoading={versionsLoading}
onPromoteToLive={handlePromoteToLive} onPromoteToLive={handlePromoteToLive}
onLoadDeploymentComplete={handleCloseModal} onLoadDeploymentComplete={handleCloseModal}
fetchVersions={handleFetchVersions}
/> />
</ModalTabsContent> </ModalTabsContent>

View File

@@ -3,8 +3,9 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronDown, RepeatIcon, SplitIcon } from 'lucide-react' import { RepeatIcon, SplitIcon } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { ChevronDown } from '@/components/emcn'
import { import {
FieldItem, FieldItem,
type SchemaField, type SchemaField,
@@ -115,9 +116,8 @@ function ConnectionItem({
{hasFields && ( {hasFields && (
<ChevronDown <ChevronDown
className={clsx( className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100', 'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]', !isExpanded && '-rotate-90'
isExpanded && 'rotate-180'
)} )}
/> />
)} )}

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react' import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
@@ -203,7 +203,7 @@ export function CredentialSelector({
if (!baseProviderConfig) { if (!baseProviderConfig) {
return <ExternalLink className='h-3 w-3' /> return <ExternalLink className='h-3 w-3' />
} }
return baseProviderConfig.icon({ className: 'h-3 w-3' }) return createElement(baseProviderConfig.icon, { className: 'h-3 w-3' })
}, []) }, [])
const getProviderName = useCallback((providerName: OAuthProvider) => { const getProviderName = useCallback((providerName: OAuthProvider) => {

View File

@@ -7,13 +7,24 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react' import { ArrowLeftRight, ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn' import { useParams } from 'next/navigation'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { FileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -21,19 +32,32 @@ import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workf
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { supportsVision } from '@/providers/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('MessagesInput')
const MIN_TEXTAREA_HEIGHT_PX = 80 const MIN_TEXTAREA_HEIGHT_PX = 80
/** Workspace file record from API */
interface WorkspaceFile {
id: string
name: string
path: string
type: string
}
const MAX_TEXTAREA_HEIGHT_PX = 320 const MAX_TEXTAREA_HEIGHT_PX = 320
/** Pattern to match complete message objects in JSON */ /** Pattern to match complete message objects in JSON */
const COMPLETE_MESSAGE_PATTERN = const COMPLETE_MESSAGE_PATTERN =
/"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g /"role"\s*:\s*"(system|user|assistant|attachment)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
/** Pattern to match incomplete content at end of buffer */ /** Pattern to match incomplete content at end of buffer */
const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/ const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/
/** Pattern to match role before content */ /** Pattern to match role before content */
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*$/ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant|attachment)"[^{]*$/
/** /**
* Unescapes JSON string content * Unescapes JSON string content
@@ -41,41 +65,46 @@ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*
const unescapeContent = (str: string): string => const unescapeContent = (str: string): string =>
str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\') str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
/**
* Attachment content (files, images, documents)
*/
interface AttachmentContent {
/** Source type: how the data was provided */
sourceType: 'url' | 'base64' | 'file'
/** The URL or base64 data */
data: string
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
mimeType?: string
/** Optional filename for file uploads */
fileName?: string
/** Optional workspace file ID (used by wand to select existing files) */
fileId?: string
}
/** /**
* Interface for individual message in the messages array * Interface for individual message in the messages array
*/ */
interface Message { interface Message {
role: 'system' | 'user' | 'assistant' role: 'system' | 'user' | 'assistant' | 'attachment'
content: string content: string
attachment?: AttachmentContent
} }
/** /**
* Props for the MessagesInput component * Props for the MessagesInput component
*/ */
interface MessagesInputProps { interface MessagesInputProps {
/** Unique identifier for the block */
blockId: string blockId: string
/** Unique identifier for the sub-block */
subBlockId: string subBlockId: string
/** Configuration object for the sub-block */
config: SubBlockConfig config: SubBlockConfig
/** Whether component is in preview mode */
isPreview?: boolean isPreview?: boolean
/** Value to display in preview mode */
previewValue?: Message[] | null previewValue?: Message[] | null
/** Whether the input is disabled */
disabled?: boolean disabled?: boolean
/** Ref to expose wand control handlers to parent */
wandControlRef?: React.MutableRefObject<WandControlHandlers | null> wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
} }
/** /**
* MessagesInput component for managing LLM message history * MessagesInput component for managing LLM message history
*
* @remarks
* - Manages an array of messages with role and content
* - Each message can be edited, removed, or reordered
* - Stores data in LLM-compatible format: [{ role, content }]
*/ */
export function MessagesInput({ export function MessagesInput({
blockId, blockId,
@@ -86,10 +115,163 @@ export function MessagesInput({
disabled = false, disabled = false,
wandControlRef, wandControlRef,
}: MessagesInputProps) { }: MessagesInputProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const [messages, setMessages] = useSubBlockValue<Message[]>(blockId, subBlockId, false) const [messages, setMessages] = useSubBlockValue<Message[]>(blockId, subBlockId, false)
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }]) const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [openPopoverIndex, setOpenPopoverIndex] = useState<number | null>(null) const [openPopoverIndex, setOpenPopoverIndex] = useState<number | null>(null)
const { activeWorkflowId } = useWorkflowRegistry()
// Local attachment mode state - basic = FileUpload, advanced = URL/base64 textarea
const [attachmentMode, setAttachmentMode] = useState<'basic' | 'advanced'>('basic')
// Workspace files for wand context
const [workspaceFiles, setWorkspaceFiles] = useState<WorkspaceFile[]>([])
// Fetch workspace files for wand context
const loadWorkspaceFiles = useCallback(async () => {
if (!workspaceId || isPreview) return
try {
const response = await fetch(`/api/workspaces/${workspaceId}/files`)
const data = await response.json()
if (data.success) {
setWorkspaceFiles(data.files || [])
}
} catch (error) {
logger.error('Error loading workspace files:', error)
}
}, [workspaceId, isPreview])
// Load workspace files on mount
useEffect(() => {
void loadWorkspaceFiles()
}, [loadWorkspaceFiles])
// Build sources string for wand - available workspace files
const sourcesInfo = useMemo(() => {
if (workspaceFiles.length === 0) {
return 'No workspace files available. The user can upload files manually after generation.'
}
const filesList = workspaceFiles
.filter(
(f) =>
f.type.startsWith('image/') ||
f.type.startsWith('audio/') ||
f.type.startsWith('video/') ||
f.type === 'application/pdf'
)
.map((f) => ` - id: "${f.id}", name: "${f.name}", type: "${f.type}"`)
.join('\n')
if (!filesList) {
return 'No files in workspace. The user can upload files manually after generation.'
}
return `AVAILABLE WORKSPACE FILES (optional - you don't have to select one):\n${filesList}\n\nTo use a file, include "fileId": "<id>" in the attachment object. If not selecting a file, omit the fileId field.`
}, [workspaceFiles])
// Get indices of attachment messages for subscription
const attachmentIndices = useMemo(
() =>
localMessages
.map((msg, index) => (msg.role === 'attachment' ? index : -1))
.filter((i) => i !== -1),
[localMessages]
)
// Subscribe to model value to check vision capability
const modelSupportsVision = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return true // Default to allowing attachments
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
const modelValue = blockValues.model as string | undefined
if (!modelValue) return true // No model selected, allow attachments
return supportsVision(modelValue)
},
[activeWorkflowId, blockId]
)
)
// Determine available roles based on model capabilities
const availableRoles = useMemo(() => {
const baseRoles: Array<'system' | 'user' | 'assistant' | 'attachment'> = [
'system',
'user',
'assistant',
]
if (modelSupportsVision) {
baseRoles.push('attachment')
}
return baseRoles
}, [modelSupportsVision])
// Subscribe to file upload values for all attachment messages
const fileUploadValues = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return {}
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
const result: Record<number, { name: string; path: string; type: string; size: number }> =
{}
for (const index of attachmentIndices) {
const fileUploadKey = `${subBlockId}-attachment-${index}`
const fileValue = blockValues[fileUploadKey]
if (fileValue && typeof fileValue === 'object' && 'path' in fileValue) {
result[index] = fileValue as { name: string; path: string; type: string; size: number }
}
}
return result
},
[activeWorkflowId, blockId, subBlockId, attachmentIndices]
)
)
// Effect to sync FileUpload values to message attachment objects
useEffect(() => {
if (!activeWorkflowId || isPreview) return
let hasChanges = false
const updatedMessages = localMessages.map((msg, index) => {
if (msg.role !== 'attachment') return msg
const uploadedFile = fileUploadValues[index]
if (uploadedFile) {
const newAttachment: AttachmentContent = {
sourceType: 'file',
data: uploadedFile.path,
mimeType: uploadedFile.type,
fileName: uploadedFile.name,
}
// Only update if different
if (
msg.attachment?.data !== newAttachment.data ||
msg.attachment?.sourceType !== newAttachment.sourceType ||
msg.attachment?.mimeType !== newAttachment.mimeType ||
msg.attachment?.fileName !== newAttachment.fileName
) {
hasChanges = true
return {
...msg,
content: uploadedFile.name || msg.content,
attachment: newAttachment,
}
}
}
return msg
})
if (hasChanges) {
setLocalMessages(updatedMessages)
setMessages(updatedMessages)
}
}, [activeWorkflowId, localMessages, isPreview, setMessages, fileUploadValues])
const subBlockInput = useSubBlockInput({ const subBlockInput = useSubBlockInput({
blockId, blockId,
subBlockId, subBlockId,
@@ -98,43 +280,40 @@ export function MessagesInput({
disabled, disabled,
}) })
/**
* Gets the current messages as JSON string for wand context
*/
const getMessagesJson = useCallback((): string => { const getMessagesJson = useCallback((): string => {
if (localMessages.length === 0) return '' if (localMessages.length === 0) return ''
// Filter out empty messages for cleaner context
const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '') const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '')
if (nonEmptyMessages.length === 0) return '' if (nonEmptyMessages.length === 0) return ''
return JSON.stringify(nonEmptyMessages, null, 2) return JSON.stringify(nonEmptyMessages, null, 2)
}, [localMessages]) }, [localMessages])
/**
* Streaming buffer for accumulating JSON content
*/
const streamBufferRef = useRef<string>('') const streamBufferRef = useRef<string>('')
/**
* Parses and validates messages from JSON content
*/
const parseMessages = useCallback((content: string): Message[] | null => { const parseMessages = useCallback((content: string): Message[] | null => {
try { try {
const parsed = JSON.parse(content) const parsed = JSON.parse(content)
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const validMessages: Message[] = parsed const validMessages: Message[] = parsed
.filter( .filter(
(m): m is { role: string; content: string } => (m): m is { role: string; content: string; attachment?: AttachmentContent } =>
typeof m === 'object' && typeof m === 'object' &&
m !== null && m !== null &&
typeof m.role === 'string' && typeof m.role === 'string' &&
typeof m.content === 'string' typeof m.content === 'string'
) )
.map((m) => ({ .map((m) => {
role: (['system', 'user', 'assistant'].includes(m.role) const role = ['system', 'user', 'assistant', 'attachment'].includes(m.role)
? m.role ? m.role
: 'user') as Message['role'], : 'user'
content: m.content, const message: Message = {
})) role: role as Message['role'],
content: m.content,
}
if (m.attachment) {
message.attachment = m.attachment
}
return message
})
return validMessages.length > 0 ? validMessages : null return validMessages.length > 0 ? validMessages : null
} }
} catch { } catch {
@@ -143,26 +322,19 @@ export function MessagesInput({
return null return null
}, []) }, [])
/**
* Extracts messages from streaming JSON buffer
* Uses simple pattern matching for efficiency
*/
const extractStreamingMessages = useCallback( const extractStreamingMessages = useCallback(
(buffer: string): Message[] => { (buffer: string): Message[] => {
// Try complete JSON parse first
const complete = parseMessages(buffer) const complete = parseMessages(buffer)
if (complete) return complete if (complete) return complete
const result: Message[] = [] const result: Message[] = []
// Reset regex lastIndex for global pattern
COMPLETE_MESSAGE_PATTERN.lastIndex = 0 COMPLETE_MESSAGE_PATTERN.lastIndex = 0
let match let match
while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) { while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) {
result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) }) result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) })
} }
// Check for incomplete message at end (content still streaming)
const lastContentIdx = buffer.lastIndexOf('"content"') const lastContentIdx = buffer.lastIndexOf('"content"')
if (lastContentIdx !== -1) { if (lastContentIdx !== -1) {
const tail = buffer.slice(lastContentIdx) const tail = buffer.slice(lastContentIdx)
@@ -172,7 +344,6 @@ export function MessagesInput({
const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN) const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN)
if (roleMatch) { if (roleMatch) {
const content = unescapeContent(incomplete[1]) const content = unescapeContent(incomplete[1])
// Only add if not duplicate of last complete message
if (result.length === 0 || result[result.length - 1].content !== content) { if (result.length === 0 || result[result.length - 1].content !== content) {
result.push({ role: roleMatch[1] as Message['role'], content }) result.push({ role: roleMatch[1] as Message['role'], content })
} }
@@ -185,12 +356,10 @@ export function MessagesInput({
[parseMessages] [parseMessages]
) )
/**
* Wand hook for AI-assisted content generation
*/
const wandHook = useWand({ const wandHook = useWand({
wandConfig: config.wandConfig, wandConfig: config.wandConfig,
currentValue: getMessagesJson(), currentValue: getMessagesJson(),
sources: sourcesInfo,
onStreamStart: () => { onStreamStart: () => {
streamBufferRef.current = '' streamBufferRef.current = ''
setLocalMessages([{ role: 'system', content: '' }]) setLocalMessages([{ role: 'system', content: '' }])
@@ -205,10 +374,50 @@ export function MessagesInput({
onGeneratedContent: (content) => { onGeneratedContent: (content) => {
const validMessages = parseMessages(content) const validMessages = parseMessages(content)
if (validMessages) { if (validMessages) {
// Process attachment messages - only allow fileId to set files, sanitize other attempts
validMessages.forEach((msg, index) => {
if (msg.role === 'attachment') {
// Check if this is an existing file with valid data (preserve it)
const hasExistingFile =
msg.attachment?.sourceType === 'file' &&
msg.attachment?.data?.startsWith('/api/') &&
msg.attachment?.fileName
if (hasExistingFile) {
// Preserve existing file data as-is
return
}
// Check if wand provided a fileId to select a workspace file
if (msg.attachment?.fileId) {
const file = workspaceFiles.find((f) => f.id === msg.attachment?.fileId)
if (file) {
// Set the file value in SubBlockStore so FileUpload picks it up
const fileUploadKey = `${subBlockId}-attachment-${index}`
const uploadedFile = {
name: file.name,
path: file.path,
type: file.type,
size: 0, // Size not available from workspace files list
}
useSubBlockStore.getState().setValue(blockId, fileUploadKey, uploadedFile)
// Clear the attachment object - the FileUpload will sync the file data via useEffect
// DON'T set attachment.data here as it would appear in the ShortInput (advanced mode)
msg.attachment = undefined
return
}
}
// Sanitize: clear any attachment object that isn't a valid existing file or fileId match
// This prevents the LLM from setting arbitrary data/variable references
msg.attachment = undefined
}
})
setLocalMessages(validMessages) setLocalMessages(validMessages)
setMessages(validMessages) setMessages(validMessages)
} else { } else {
// Fallback: treat as raw system prompt
const trimmed = content.trim() const trimmed = content.trim()
if (trimmed) { if (trimmed) {
const fallback: Message[] = [{ role: 'system', content: trimmed }] const fallback: Message[] = [{ role: 'system', content: trimmed }]
@@ -219,9 +428,6 @@ export function MessagesInput({
}, },
}) })
/**
* Expose wand control handlers to parent via ref
*/
useImperativeHandle( useImperativeHandle(
wandControlRef, wandControlRef,
() => ({ () => ({
@@ -249,9 +455,6 @@ export function MessagesInput({
} }
}, [isPreview, previewValue, messages]) }, [isPreview, previewValue, messages])
/**
* Gets the current messages array
*/
const currentMessages = useMemo<Message[]>(() => { const currentMessages = useMemo<Message[]>(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) { if (isPreview && previewValue && Array.isArray(previewValue)) {
return previewValue return previewValue
@@ -269,9 +472,6 @@ export function MessagesInput({
startHeight: number startHeight: number
} | null>(null) } | null>(null)
/**
* Updates a specific message's content
*/
const updateMessageContent = useCallback( const updateMessageContent = useCallback(
(index: number, content: string) => { (index: number, content: string) => {
if (isPreview || disabled) return if (isPreview || disabled) return
@@ -287,17 +487,27 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Updates a specific message's role
*/
const updateMessageRole = useCallback( const updateMessageRole = useCallback(
(index: number, role: 'system' | 'user' | 'assistant') => { (index: number, role: 'system' | 'user' | 'assistant' | 'attachment') => {
if (isPreview || disabled) return if (isPreview || disabled) return
const updatedMessages = [...localMessages] const updatedMessages = [...localMessages]
updatedMessages[index] = { if (role === 'attachment') {
...updatedMessages[index], updatedMessages[index] = {
role, ...updatedMessages[index],
role,
content: updatedMessages[index].content || '',
attachment: updatedMessages[index].attachment || {
sourceType: 'file',
data: '',
},
}
} else {
const { attachment: _, ...rest } = updatedMessages[index]
updatedMessages[index] = {
...rest,
role,
}
} }
setLocalMessages(updatedMessages) setLocalMessages(updatedMessages)
setMessages(updatedMessages) setMessages(updatedMessages)
@@ -305,9 +515,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Adds a message after the specified index
*/
const addMessageAfter = useCallback( const addMessageAfter = useCallback(
(index: number) => { (index: number) => {
if (isPreview || disabled) return if (isPreview || disabled) return
@@ -320,9 +527,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Deletes a message at the specified index
*/
const deleteMessage = useCallback( const deleteMessage = useCallback(
(index: number) => { (index: number) => {
if (isPreview || disabled) return if (isPreview || disabled) return
@@ -335,9 +539,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Moves a message up in the list
*/
const moveMessageUp = useCallback( const moveMessageUp = useCallback(
(index: number) => { (index: number) => {
if (isPreview || disabled || index === 0) return if (isPreview || disabled || index === 0) return
@@ -352,9 +553,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Moves a message down in the list
*/
const moveMessageDown = useCallback( const moveMessageDown = useCallback(
(index: number) => { (index: number) => {
if (isPreview || disabled || index === localMessages.length - 1) return if (isPreview || disabled || index === localMessages.length - 1) return
@@ -369,18 +567,11 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled] [localMessages, setMessages, isPreview, disabled]
) )
/**
* Capitalizes the first letter of the role
*/
const formatRole = (role: string): string => { const formatRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1) return role.charAt(0).toUpperCase() + role.slice(1)
} }
/**
* Handles header click to focus the textarea
*/
const handleHeaderClick = useCallback((index: number, e: React.MouseEvent) => { const handleHeaderClick = useCallback((index: number, e: React.MouseEvent) => {
// Don't focus if clicking on interactive elements
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-radix-popper-content-wrapper]')) { if (target.closest('button') || target.closest('[data-radix-popper-content-wrapper]')) {
return return
@@ -570,50 +761,52 @@ export function MessagesInput({
className='flex cursor-pointer items-center justify-between px-[8px] pt-[6px]' className='flex cursor-pointer items-center justify-between px-[8px] pt-[6px]'
onClick={(e) => handleHeaderClick(index, e)} onClick={(e) => handleHeaderClick(index, e)}
> >
<Popover <div className='flex items-center'>
open={openPopoverIndex === index} <Popover
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)} open={openPopoverIndex === index}
> onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
<PopoverTrigger asChild> >
<button <PopoverTrigger asChild>
type='button' <button
disabled={isPreview || disabled} type='button'
className={cn( disabled={isPreview || disabled}
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]', className={cn(
(isPreview || disabled) && 'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]' (isPreview || disabled) &&
)} 'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
onClick={(e) => e.stopPropagation()} )}
aria-label='Select message role' onClick={(e) => e.stopPropagation()}
> aria-label='Select message role'
{formatRole(message.role)} >
{!isPreview && !disabled && ( {formatRole(message.role)}
<ChevronDown {!isPreview && !disabled && (
className={cn( <ChevronDown
'h-3 w-3 flex-shrink-0 transition-transform duration-100', className={cn(
openPopoverIndex === index && 'rotate-180' 'h-3 w-3 flex-shrink-0 transition-transform duration-100',
)} openPopoverIndex === index && 'rotate-180'
/> )}
)} />
</button> )}
</PopoverTrigger> </button>
<PopoverContent minWidth={140} align='start'> </PopoverTrigger>
<div className='flex flex-col gap-[2px]'> <PopoverContent minWidth={140} align='start'>
{(['system', 'user', 'assistant'] as const).map((role) => ( <div className='flex flex-col gap-[2px]'>
<PopoverItem {availableRoles.map((role) => (
key={role} <PopoverItem
active={message.role === role} key={role}
onClick={() => { active={message.role === role}
updateMessageRole(index, role) onClick={() => {
setOpenPopoverIndex(null) updateMessageRole(index, role)
}} setOpenPopoverIndex(null)
> }}
<span>{formatRole(role)}</span> >
</PopoverItem> <span>{formatRole(role)}</span>
))} </PopoverItem>
</div> ))}
</PopoverContent> </div>
</Popover> </PopoverContent>
</Popover>
</div>
{!isPreview && !disabled && ( {!isPreview && !disabled && (
<div className='flex items-center'> <div className='flex items-center'>
@@ -657,6 +850,43 @@ export function MessagesInput({
</Button> </Button>
</> </>
)} )}
{/* Mode toggle for attachment messages */}
{message.role === 'attachment' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setAttachmentMode((m) => (m === 'basic' ? 'advanced' : 'basic'))
}}
disabled={disabled}
className='-my-1 -mr-1 h-6 w-6 p-0'
aria-label={
attachmentMode === 'advanced'
? 'Switch to file upload'
: 'Switch to URL/text input'
}
>
<ArrowLeftRight
className={cn(
'h-3 w-3',
attachmentMode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{attachmentMode === 'advanced'
? 'Switch to file upload'
: 'Switch to URL/text input'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<Button <Button
variant='ghost' variant='ghost'
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
@@ -673,98 +903,152 @@ export function MessagesInput({
)} )}
</div> </div>
{/* Content Input with overlay for variable highlighting */} {/* Content Input - different for attachment vs text messages */}
<div className='relative w-full overflow-hidden'> {message.role === 'attachment' ? (
<textarea <div className='relative w-full px-[8px] py-[8px]'>
ref={(el) => { {attachmentMode === 'basic' ? (
textareaRefs.current[fieldId] = el <FileUpload
}} blockId={blockId}
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden' subBlockId={`${subBlockId}-attachment-${index}`}
placeholder='Enter message content...' acceptedTypes='image/*,audio/*,video/*,application/pdf,.doc,.docx,.txt'
value={message.content} multiple={false}
onChange={fieldHandlers.onChange} isPreview={isPreview}
onKeyDown={(e) => { disabled={disabled}
if (e.key === 'Tab' && !isPreview && !disabled) { />
e.preventDefault() ) : (
const direction = e.shiftKey ? -1 : 1 <ShortInput
const nextIndex = index + direction blockId={blockId}
subBlockId={`${subBlockId}-attachment-ref-${index}`}
if (nextIndex >= 0 && nextIndex < currentMessages.length) { placeholder='Reference file from previous block...'
const nextFieldId = `message-${nextIndex}` config={{
const nextTextarea = textareaRefs.current[nextFieldId] id: `${subBlockId}-attachment-ref-${index}`,
if (nextTextarea) { type: 'short-input',
nextTextarea.focus() }}
nextTextarea.selectionStart = nextTextarea.value.length value={
nextTextarea.selectionEnd = nextTextarea.value.length // Only show value for variable references, not file uploads
} message.attachment?.sourceType === 'file'
? ''
: message.attachment?.data || ''
} }
return onChange={(newValue: string) => {
} const updatedMessages = [...localMessages]
if (updatedMessages[index].role === 'attachment') {
fieldHandlers.onKeyDown(e) // Determine sourceType based on content
}} let sourceType: 'url' | 'base64' = 'url'
onDrop={fieldHandlers.onDrop} if (newValue.startsWith('data:') || newValue.includes(';base64,')) {
onDragOver={fieldHandlers.onDragOver} sourceType = 'base64'
onFocus={fieldHandlers.onFocus} }
onScroll={(e) => { updatedMessages[index] = {
const overlay = overlayRefs.current[fieldId] ...updatedMessages[index],
if (overlay) { content: newValue.substring(0, 50),
overlay.scrollTop = e.currentTarget.scrollTop attachment: {
overlay.scrollLeft = e.currentTarget.scrollLeft ...updatedMessages[index].attachment,
} sourceType,
}} data: newValue,
disabled={isPreview || disabled} },
/> }
<div setLocalMessages(updatedMessages)
ref={(el) => { setMessages(updatedMessages)
overlayRefs.current[fieldId] = el }
}} }}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' isPreview={isPreview}
> disabled={disabled}
{formatDisplayText(message.content, { />
accessiblePrefixes, )}
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
</div> </div>
) : (
{/* Env var dropdown for this message */} <div className='relative w-full overflow-hidden'>
<EnvVarDropdown <textarea
visible={fieldState.showEnvVars && !isPreview && !disabled} ref={(el) => {
onSelect={handleEnvSelect} textareaRefs.current[fieldId] = el
searchTerm={fieldState.searchTerm}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
workspaceId={subBlockInput.workspaceId}
maxHeight='192px'
inputRef={textareaRefObject}
/>
{/* Tag dropdown for this message */}
<TagDropdown
visible={fieldState.showTags && !isPreview && !disabled}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={textareaRefObject}
/>
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()
}} }}
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
placeholder='Enter message content...'
value={message.content}
onChange={fieldHandlers.onChange}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
const direction = e.shiftKey ? -1 : 1
const nextIndex = index + direction
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
const nextFieldId = `message-${nextIndex}`
const nextTextarea = textareaRefs.current[nextFieldId]
if (nextTextarea) {
nextTextarea.focus()
nextTextarea.selectionStart = nextTextarea.value.length
nextTextarea.selectionEnd = nextTextarea.value.length
}
}
return
}
fieldHandlers.onKeyDown(e)
}}
onDrop={fieldHandlers.onDrop}
onDragOver={fieldHandlers.onDragOver}
onFocus={fieldHandlers.onFocus}
onScroll={(e) => {
const overlay = overlayRefs.current[fieldId]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
disabled={isPreview || disabled}
/>
<div
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
> >
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' /> {formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
</div> </div>
)}
</div> {/* Env var dropdown for this message */}
<EnvVarDropdown
visible={fieldState.showEnvVars && !isPreview && !disabled}
onSelect={handleEnvSelect}
searchTerm={fieldState.searchTerm}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
workspaceId={subBlockInput.workspaceId}
maxHeight='192px'
inputRef={textareaRefObject}
/>
{/* Tag dropdown for this message */}
<TagDropdown
visible={fieldState.showTags && !isPreview && !disabled}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={textareaRefObject}
/>
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
</div>
)}
</> </>
) )
})()} })()}

View File

@@ -23,6 +23,7 @@ interface SelectorComboboxProps {
readOnly?: boolean readOnly?: boolean
onOptionChange?: (value: string) => void onOptionChange?: (value: string) => void
allowSearch?: boolean allowSearch?: boolean
missingOptionLabel?: string
} }
export function SelectorCombobox({ export function SelectorCombobox({
@@ -37,6 +38,7 @@ export function SelectorCombobox({
readOnly, readOnly,
onOptionChange, onOptionChange,
allowSearch = true, allowSearch = true,
missingOptionLabel,
}: SelectorComboboxProps) { }: SelectorComboboxProps) {
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>( const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
blockId, blockId,
@@ -60,7 +62,16 @@ export function SelectorCombobox({
detailId: activeValue, detailId: activeValue,
}) })
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined) const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : '' const hasMissingOption =
Boolean(activeValue) &&
Boolean(missingOptionLabel) &&
!isLoading &&
!optionMap.get(activeValue!)
const selectedLabel = activeValue
? hasMissingOption
? missingOptionLabel
: (optionMap.get(activeValue)?.label ?? activeValue)
: ''
const [inputValue, setInputValue] = useState(selectedLabel) const [inputValue, setInputValue] = useState(selectedLabel)
const previousActiveValue = useRef<string | undefined>(activeValue) const previousActiveValue = useRef<string | undefined>(activeValue)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { import {
@@ -22,7 +22,7 @@ const getProviderIcon = (providerName: OAuthProvider) => {
if (!baseProviderConfig) { if (!baseProviderConfig) {
return <ExternalLink className='h-3 w-3' /> return <ExternalLink className='h-3 w-3' />
} }
return baseProviderConfig.icon({ className: 'h-3 w-3' }) return createElement(baseProviderConfig.icon, { className: 'h-3 w-3' })
} }
const getProviderName = (providerName: OAuthProvider) => { const getProviderName = (providerName: OAuthProvider) => {

View File

@@ -180,20 +180,6 @@ function resolveCustomToolFromReference(
return null return null
} }
/**
* Checks if a stored custom tool uses the reference-only format.
*
* @remarks
* Reference-only format means the tool has a customToolId but no inline code/schema,
* requiring resolution from the database at runtime.
*
* @param storedTool - The stored tool to check
* @returns `true` if the tool is a reference-only custom tool, `false` otherwise
*/
function isCustomToolReference(storedTool: StoredTool): boolean {
return storedTool.type === 'custom-tool' && !!storedTool.customToolId && !storedTool.code
}
/** /**
* Generic sync wrapper that synchronizes store values with local component state. * Generic sync wrapper that synchronizes store values with local component state.
* *
@@ -1155,21 +1141,6 @@ export const ToolInput = memo(function ToolInput({
return filterBlocks(allToolBlocks) return filterBlocks(allToolBlocks)
}, [filterBlocks]) }, [filterBlocks])
const customFilter = useCallback((value: string, search: string) => {
if (!search.trim()) return 1
const normalizedValue = value.toLowerCase()
const normalizedSearch = search.toLowerCase()
if (normalizedValue === normalizedSearch) return 1
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
if (normalizedValue.includes(normalizedSearch)) return 0.6
return 0
}, [])
const hasBackfilledRef = useRef(false) const hasBackfilledRef = useRef(false)
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types' import type { SelectorContext } from '@/hooks/selectors/types'
@@ -40,6 +41,7 @@ export function WorkflowSelectorInput({
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
placeholder={subBlock.placeholder || 'Select workflow...'} placeholder={subBlock.placeholder || 'Select workflow...'}
missingOptionLabel={DELETED_WORKFLOW_LABEL}
/> />
) )
} }

View File

@@ -0,0 +1,121 @@
'use client'
import { memo } from 'react'
import clsx from 'clsx'
import { Filter } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverScrollArea,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils'
/**
* Props for the FilterPopover component
*/
export interface FilterPopoverProps {
open: boolean
onOpenChange: (open: boolean) => void
filters: TerminalFilters
toggleStatus: (status: 'error' | 'info') => void
toggleBlock: (blockId: string) => void
uniqueBlocks: BlockInfo[]
hasActiveFilters: boolean
}
/**
* Filter popover component used in terminal header and output panel
*/
export const FilterPopover = memo(function FilterPopover({
open,
onOpenChange,
filters,
toggleStatus,
toggleBlock,
uniqueBlocks,
hasActiveFilters,
}: FilterPopoverProps) {
return (
<Popover open={open} onOpenChange={onOpenChange} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={(e) => e.stopPropagation()}
aria-label='Filters'
>
<Filter
className={clsx('h-3 w-3', hasActiveFilters && 'text-[var(--brand-secondary)]')}
/>
</Button>
</PopoverTrigger>
<PopoverContent
side='top'
align='end'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
minWidth={160}
maxWidth={220}
maxHeight={300}
>
<PopoverSection>Status</PopoverSection>
<PopoverItem
active={filters.statuses.has('error')}
showCheck={filters.statuses.has('error')}
onClick={() => toggleStatus('error')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--text-error)' }}
/>
<span className='flex-1'>Error</span>
</PopoverItem>
<PopoverItem
active={filters.statuses.has('info')}
showCheck={filters.statuses.has('info')}
onClick={() => toggleStatus('info')}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
/>
<span className='flex-1'>Info</span>
</PopoverItem>
{uniqueBlocks.length > 0 && (
<>
<PopoverDivider className='my-[4px]' />
<PopoverSection className='!mt-0'>Blocks</PopoverSection>
<PopoverScrollArea className='max-h-[100px]'>
{uniqueBlocks.map((block) => {
const BlockIcon = getBlockIcon(block.blockType)
const isSelected = filters.blockIds.has(block.blockId)
return (
<PopoverItem
key={block.blockId}
active={isSelected}
showCheck={isSelected}
onClick={() => toggleBlock(block.blockId)}
>
{BlockIcon && <BlockIcon className='h-3 w-3' />}
<span className='flex-1'>{block.blockName}</span>
</PopoverItem>
)
})}
</PopoverScrollArea>
</>
)}
</PopoverContent>
</Popover>
)
})

View File

@@ -0,0 +1 @@
export { FilterPopover, type FilterPopoverProps } from './filter-popover'

View File

@@ -1,2 +1,5 @@
export { LogRowContextMenu } from './log-row-context-menu' export { FilterPopover, type FilterPopoverProps } from './filter-popover'
export { OutputContextMenu } from './output-context-menu' export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'
export { OutputPanel, type OutputPanelProps } from './output-panel'
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -0,0 +1 @@
export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu'

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { RefObject } from 'react' import { memo, type RefObject } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -8,20 +8,13 @@ import {
PopoverDivider, PopoverDivider,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import type {
ContextMenuPosition,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal' import type { ConsoleEntry } from '@/stores/terminal'
interface ContextMenuPosition { export interface LogRowContextMenuProps {
x: number
y: number
}
interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
interface LogRowContextMenuProps {
isOpen: boolean isOpen: boolean
position: ContextMenuPosition position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null> menuRef: RefObject<HTMLDivElement | null>
@@ -30,19 +23,16 @@ interface LogRowContextMenuProps {
filters: TerminalFilters filters: TerminalFilters
onFilterByBlock: (blockId: string) => void onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onCopyRunId: (runId: string) => void onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void onClearConsole: () => void
onFixInCopilot: (entry: ConsoleEntry) => void onFixInCopilot: (entry: ConsoleEntry) => void
hasActiveFilters: boolean
} }
/** /**
* Context menu for terminal log rows (left side). * Context menu for terminal log rows (left side).
* Displays filtering options based on the selected row's properties. * Displays filtering options based on the selected row's properties.
*/ */
export function LogRowContextMenu({ export const LogRowContextMenu = memo(function LogRowContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -51,19 +41,15 @@ export function LogRowContextMenu({
filters, filters,
onFilterByBlock, onFilterByBlock,
onFilterByStatus, onFilterByStatus,
onFilterByRunId,
onCopyRunId, onCopyRunId,
onClearFilters,
onClearConsole, onClearConsole,
onFixInCopilot, onFixInCopilot,
hasActiveFilters,
}: LogRowContextMenuProps) { }: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null const hasRunId = entry?.executionId != null
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
const entryStatus = entry?.success ? 'info' : 'error' const entryStatus = entry?.success ? 'info' : 'error'
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
return ( return (
<Popover <Popover
@@ -134,34 +120,11 @@ export function LogRowContextMenu({
> >
Filter by Status Filter by Status
</PopoverItem> </PopoverItem>
{hasRunId && (
<PopoverItem
showCheck={isRunIdFiltered}
onClick={() => {
onFilterByRunId(entry.executionId!)
onClose()
}}
>
Filter by Run ID
</PopoverItem>
)}
</> </>
)} )}
{/* Clear filters */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
)}
{/* Destructive action */} {/* Destructive action */}
{(entry || hasActiveFilters) && <PopoverDivider />} {entry && <PopoverDivider />}
<PopoverItem <PopoverItem
onClick={() => { onClick={() => {
onClearConsole() onClearConsole()
@@ -173,4 +136,4 @@ export function LogRowContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} })

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { RefObject } from 'react' import { memo, type RefObject } from 'react'
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -8,13 +8,9 @@ import {
PopoverDivider, PopoverDivider,
PopoverItem, PopoverItem,
} from '@/components/emcn' } from '@/components/emcn'
import type { ContextMenuPosition } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
interface ContextMenuPosition { export interface OutputContextMenuProps {
x: number
y: number
}
interface OutputContextMenuProps {
isOpen: boolean isOpen: boolean
position: ContextMenuPosition position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null> menuRef: RefObject<HTMLDivElement | null>
@@ -22,6 +18,8 @@ interface OutputContextMenuProps {
onCopySelection: () => void onCopySelection: () => void
onCopyAll: () => void onCopyAll: () => void
onSearch: () => void onSearch: () => void
structuredView: boolean
onToggleStructuredView: () => void
wrapText: boolean wrapText: boolean
onToggleWrap: () => void onToggleWrap: () => void
openOnRun: boolean openOnRun: boolean
@@ -34,7 +32,7 @@ interface OutputContextMenuProps {
* Context menu for terminal output panel (right side). * Context menu for terminal output panel (right side).
* Displays copy, search, and display options for the code viewer. * Displays copy, search, and display options for the code viewer.
*/ */
export function OutputContextMenu({ export const OutputContextMenu = memo(function OutputContextMenu({
isOpen, isOpen,
position, position,
menuRef, menuRef,
@@ -42,6 +40,8 @@ export function OutputContextMenu({
onCopySelection, onCopySelection,
onCopyAll, onCopyAll,
onSearch, onSearch,
structuredView,
onToggleStructuredView,
wrapText, wrapText,
onToggleWrap, onToggleWrap,
openOnRun, openOnRun,
@@ -96,6 +96,9 @@ export function OutputContextMenu({
{/* Display settings - toggles don't close menu */} {/* Display settings - toggles don't close menu */}
<PopoverDivider /> <PopoverDivider />
<PopoverItem showCheck={structuredView} onClick={onToggleStructuredView}>
Structured View
</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}> <PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text Wrap Text
</PopoverItem> </PopoverItem>
@@ -116,4 +119,4 @@ export function OutputContextMenu({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
} })

View File

@@ -0,0 +1,913 @@
'use client'
import type React from 'react'
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object'
type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red'
interface NodeEntry {
key: string
value: unknown
path: string
}
/**
* Search context for structured output tree.
*/
interface SearchContextValue {
query: string
pathToMatchIndices: Map<string, number[]>
}
const SearchContext = createContext<SearchContextValue | null>(null)
/**
* Configuration for virtualized rendering.
*/
const CONFIG = {
ROW_HEIGHT: 22,
INDENT_PER_LEVEL: 12,
BASE_PADDING: 20,
MAX_SEARCH_DEPTH: 100,
OVERSCAN_COUNT: 10,
VIRTUALIZATION_THRESHOLD: 200,
} as const
const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
string: 'green',
number: 'blue',
boolean: 'orange',
array: 'purple',
null: 'gray',
undefined: 'gray',
object: 'gray',
} as const
/**
* Styling constants matching the original non-virtualized implementation.
*/
const STYLES = {
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
chevron:
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
keyName:
'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]',
badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
summary: 'text-[12px] text-[var(--text-tertiary)]',
indent:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
value: 'min-w-0 py-[2px] text-[13px] text-[var(--text-primary)]',
emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]',
matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40',
currentMatchHighlight: 'bg-orange-400',
} as const
const EMPTY_MATCH_INDICES: number[] = []
function getTypeLabel(value: unknown): ValueType {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (Array.isArray(value)) return 'array'
return typeof value as ValueType
}
function formatPrimitive(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
return String(value)
}
function isPrimitive(value: unknown): value is null | undefined | string | number | boolean {
return value === null || value === undefined || typeof value !== 'object'
}
function isEmpty(value: unknown): boolean {
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object' && value !== null) return Object.keys(value).length === 0
return false
}
function extractErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
if (data instanceof Error) return data.message
if (typeof data === 'object' && data !== null && 'message' in data) {
return String((data as { message: unknown }).message)
}
return JSON.stringify(data, null, 2)
}
function buildEntries(value: unknown, basePath: string): NodeEntry[] {
if (Array.isArray(value)) {
return value.map((item, i) => ({ key: String(i), value: item, path: `${basePath}[${i}]` }))
}
return Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
key: k,
value: v,
path: `${basePath}.${k}`,
}))
}
function getCollapsedSummary(value: unknown): string | null {
if (Array.isArray(value)) {
const len = value.length
return `${len} item${len !== 1 ? 's' : ''}`
}
if (typeof value === 'object' && value !== null) {
const count = Object.keys(value).length
return `${count} key${count !== 1 ? 's' : ''}`
}
return null
}
function computeInitialPaths(data: unknown, isError: boolean): Set<string> {
if (isError) return new Set(['root.error'])
if (!data || typeof data !== 'object') return new Set()
const entries = Array.isArray(data)
? data.map((_, i) => `root[${i}]`)
: Object.keys(data).map((k) => `root.${k}`)
return new Set(entries)
}
function getAncestorPaths(path: string): string[] {
const ancestors: string[] = []
let current = path
while (current.includes('.') || current.includes('[')) {
const splitPoint = Math.max(current.lastIndexOf('.'), current.lastIndexOf('['))
if (splitPoint <= 0) break
current = current.slice(0, splitPoint)
if (current !== 'root') ancestors.push(current)
}
return ancestors
}
function findTextMatches(text: string, query: string): Array<[number, number]> {
if (!query) return []
const matches: Array<[number, number]> = []
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
let pos = 0
while (pos < lowerText.length) {
const idx = lowerText.indexOf(lowerQuery, pos)
if (idx === -1) break
matches.push([idx, idx + query.length])
pos = idx + 1
}
return matches
}
function addPrimitiveMatches(value: unknown, path: string, query: string, matches: string[]): void {
const text = formatPrimitive(value)
const count = findTextMatches(text, query).length
for (let i = 0; i < count; i++) {
matches.push(path)
}
}
function collectAllMatchPaths(data: unknown, query: string, basePath: string, depth = 0): string[] {
if (!query || depth > CONFIG.MAX_SEARCH_DEPTH) return []
const matches: string[] = []
if (isPrimitive(data)) {
addPrimitiveMatches(data, `${basePath}.value`, query, matches)
return matches
}
for (const entry of buildEntries(data, basePath)) {
if (isPrimitive(entry.value)) {
addPrimitiveMatches(entry.value, entry.path, query, matches)
} else {
matches.push(...collectAllMatchPaths(entry.value, query, entry.path, depth + 1))
}
}
return matches
}
function buildPathToIndicesMap(matchPaths: string[]): Map<string, number[]> {
const map = new Map<string, number[]>()
matchPaths.forEach((path, globalIndex) => {
const existing = map.get(path)
if (existing) {
existing.push(globalIndex)
} else {
map.set(path, [globalIndex])
}
})
return map
}
/**
* Renders text with search highlights using segments.
*/
function renderHighlightedSegments(
text: string,
query: string,
matchIndices: number[],
currentMatchIndex: number,
path: string
): React.ReactNode {
if (!query || matchIndices.length === 0) return text
const textMatches = findTextMatches(text, query)
if (textMatches.length === 0) return text
const segments: React.ReactNode[] = []
let lastEnd = 0
textMatches.forEach(([start, end], i) => {
const globalIndex = matchIndices[i]
const isCurrent = globalIndex === currentMatchIndex
if (start > lastEnd) {
segments.push(<span key={`t-${path}-${start}`}>{text.slice(lastEnd, start)}</span>)
}
segments.push(
<mark
key={`m-${path}-${start}`}
data-search-match
data-match-index={globalIndex}
className={cn(
'rounded-sm',
isCurrent ? STYLES.currentMatchHighlight : STYLES.matchHighlight
)}
>
{text.slice(start, end)}
</mark>
)
lastEnd = end
})
if (lastEnd < text.length) {
segments.push(<span key={`t-${path}-${lastEnd}`}>{text.slice(lastEnd)}</span>)
}
return <>{segments}</>
}
interface HighlightedTextProps {
text: string
matchIndices: number[]
path: string
currentMatchIndex: number
}
/**
* Renders text with search highlights for non-virtualized mode.
* Accepts currentMatchIndex as prop to ensure re-render when it changes.
*/
const HighlightedText = memo(function HighlightedText({
text,
matchIndices,
path,
currentMatchIndex,
}: HighlightedTextProps) {
const searchContext = useContext(SearchContext)
if (!searchContext || matchIndices.length === 0) return <>{text}</>
return (
<>
{renderHighlightedSegments(text, searchContext.query, matchIndices, currentMatchIndex, path)}
</>
)
})
interface StructuredNodeProps {
name: string
value: unknown
path: string
expandedPaths: Set<string>
onToggle: (path: string) => void
wrapText: boolean
currentMatchIndex: number
isError?: boolean
}
/**
* Recursive node component for non-virtualized rendering.
* Preserves exact original styling with border-left tree lines.
*/
const StructuredNode = memo(function StructuredNode({
name,
value,
path,
expandedPaths,
onToggle,
wrapText,
currentMatchIndex,
isError = false,
}: StructuredNodeProps) {
const searchContext = useContext(SearchContext)
const type = getTypeLabel(value)
const isPrimitiveValue = isPrimitive(value)
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
const isExpanded = expandedPaths.has(path)
const handleToggle = useCallback(() => onToggle(path), [onToggle, path])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleToggle()
}
},
[handleToggle]
)
const childEntries = useMemo(
() => (isPrimitiveValue || isEmptyValue ? [] : buildEntries(value, path)),
[value, isPrimitiveValue, isEmptyValue, path]
)
const collapsedSummary = useMemo(
() => (isPrimitiveValue ? null : getCollapsedSummary(value)),
[value, isPrimitiveValue]
)
const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type]
const valueText = isPrimitiveValue ? formatPrimitive(value) : ''
const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES
return (
<div className='flex min-w-0 flex-col'>
<div
className={STYLES.row}
onClick={handleToggle}
onKeyDown={handleKeyDown}
role='button'
tabIndex={0}
aria-expanded={isExpanded}
>
<span className={cn(STYLES.keyName, isError && 'text-[var(--text-error)]')}>{name}</span>
<Badge variant={badgeVariant} className={STYLES.badge}>
{type}
</Badge>
{!isExpanded && collapsedSummary && (
<span className={STYLES.summary}>{collapsedSummary}</span>
)}
<ChevronDown className={cn(STYLES.chevron, !isExpanded && '-rotate-90')} />
</div>
{isExpanded && (
<div className={STYLES.indent}>
{isPrimitiveValue ? (
<div
className={cn(
STYLES.value,
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
)}
>
<HighlightedText
text={valueText}
matchIndices={matchIndices}
path={path}
currentMatchIndex={currentMatchIndex}
/>
</div>
) : isEmptyValue ? (
<div className={STYLES.emptyValue}>{Array.isArray(value) ? '[]' : '{}'}</div>
) : (
childEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={onToggle}
wrapText={wrapText}
currentMatchIndex={currentMatchIndex}
/>
))
)}
</div>
)}
</div>
)
})
/**
* Flattened row for virtualization.
*/
interface FlatRow {
path: string
key: string
value: unknown
depth: number
type: 'header' | 'value' | 'empty'
valueType: ValueType
isExpanded: boolean
isError: boolean
collapsedSummary: string | null
displayText: string
matchIndices: number[]
}
/**
* Flattens the tree into rows for virtualization.
*/
function flattenTree(
data: unknown,
expandedPaths: Set<string>,
pathToMatchIndices: Map<string, number[]>,
isError: boolean
): FlatRow[] {
const rows: FlatRow[] = []
if (isError) {
const errorText = extractErrorMessage(data)
const isExpanded = expandedPaths.has('root.error')
rows.push({
path: 'root.error',
key: 'error',
value: errorText,
depth: 0,
type: 'header',
valueType: 'string',
isExpanded,
isError: true,
collapsedSummary: null,
displayText: '',
matchIndices: [],
})
if (isExpanded) {
rows.push({
path: 'root.error.value',
key: '',
value: errorText,
depth: 1,
type: 'value',
valueType: 'string',
isExpanded: false,
isError: true,
collapsedSummary: null,
displayText: errorText,
matchIndices: pathToMatchIndices.get('root.error') ?? [],
})
}
return rows
}
function processNode(key: string, value: unknown, path: string, depth: number): void {
const valueType = getTypeLabel(value)
const isPrimitiveValue = isPrimitive(value)
const isEmptyValue = !isPrimitiveValue && isEmpty(value)
const isExpanded = expandedPaths.has(path)
const collapsedSummary = isPrimitiveValue ? null : getCollapsedSummary(value)
rows.push({
path,
key,
value,
depth,
type: 'header',
valueType,
isExpanded,
isError: false,
collapsedSummary,
displayText: '',
matchIndices: [],
})
if (isExpanded) {
if (isPrimitiveValue) {
rows.push({
path: `${path}.value`,
key: '',
value,
depth: depth + 1,
type: 'value',
valueType,
isExpanded: false,
isError: false,
collapsedSummary: null,
displayText: formatPrimitive(value),
matchIndices: pathToMatchIndices.get(path) ?? [],
})
} else if (isEmptyValue) {
rows.push({
path: `${path}.empty`,
key: '',
value,
depth: depth + 1,
type: 'empty',
valueType,
isExpanded: false,
isError: false,
collapsedSummary: null,
displayText: Array.isArray(value) ? '[]' : '{}',
matchIndices: [],
})
} else {
for (const entry of buildEntries(value, path)) {
processNode(entry.key, entry.value, entry.path, depth + 1)
}
}
}
}
if (isPrimitive(data)) {
processNode('value', data, 'root.value', 0)
} else if (data && typeof data === 'object') {
for (const entry of buildEntries(data, 'root')) {
processNode(entry.key, entry.value, entry.path, 0)
}
}
return rows
}
/**
* Counts total visible rows for determining virtualization threshold.
*/
function countVisibleRows(data: unknown, expandedPaths: Set<string>, isError: boolean): number {
if (isError) return expandedPaths.has('root.error') ? 2 : 1
let count = 0
function countNode(value: unknown, path: string): void {
count++
if (!expandedPaths.has(path)) return
if (isPrimitive(value) || isEmpty(value)) {
count++
} else {
for (const entry of buildEntries(value, path)) {
countNode(entry.value, entry.path)
}
}
}
if (isPrimitive(data)) {
countNode(data, 'root.value')
} else if (data && typeof data === 'object') {
for (const entry of buildEntries(data, 'root')) {
countNode(entry.value, entry.path)
}
}
return count
}
interface VirtualizedRowProps {
rows: FlatRow[]
onToggle: (path: string) => void
wrapText: boolean
searchQuery: string
currentMatchIndex: number
}
/**
* Virtualized row component for large data sets.
*/
function VirtualizedRow({ index, style, ...props }: RowComponentProps<VirtualizedRowProps>) {
const { rows, onToggle, wrapText, searchQuery, currentMatchIndex } = props
const row = rows[index]
const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL
if (row.type === 'header') {
const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType]
return (
<div style={{ ...style, paddingLeft }} data-row-index={index}>
<div
className={STYLES.row}
onClick={() => onToggle(row.path)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle(row.path)
}
}}
role='button'
tabIndex={0}
aria-expanded={row.isExpanded}
>
<span className={cn(STYLES.keyName, row.isError && 'text-[var(--text-error)]')}>
{row.key}
</span>
<Badge variant={badgeVariant} className={STYLES.badge}>
{row.valueType}
</Badge>
{!row.isExpanded && row.collapsedSummary && (
<span className={STYLES.summary}>{row.collapsedSummary}</span>
)}
<ChevronDown className={cn(STYLES.chevron, !row.isExpanded && '-rotate-90')} />
</div>
</div>
)
}
if (row.type === 'empty') {
return (
<div style={{ ...style, paddingLeft }} data-row-index={index}>
<div className={STYLES.emptyValue}>{row.displayText}</div>
</div>
)
}
return (
<div style={{ ...style, paddingLeft }} data-row-index={index}>
<div
className={cn(
STYLES.value,
row.isError && 'text-[var(--text-error)]',
wrapText ? '[word-break:break-word]' : 'whitespace-nowrap'
)}
>
{renderHighlightedSegments(
row.displayText,
searchQuery,
row.matchIndices,
currentMatchIndex,
row.path
)}
</div>
</div>
)
}
export interface StructuredOutputProps {
data: unknown
wrapText?: boolean
isError?: boolean
isRunning?: boolean
className?: string
searchQuery?: string
currentMatchIndex?: number
onMatchCountChange?: (count: number) => void
contentRef?: React.RefObject<HTMLDivElement | null>
}
/**
* Renders structured data as nested collapsible blocks.
* Uses virtualization for large data sets (>200 visible rows) while
* preserving exact original styling for smaller data sets.
*/
export const StructuredOutput = memo(function StructuredOutput({
data,
wrapText = true,
isError = false,
isRunning = false,
className,
searchQuery,
currentMatchIndex = 0,
onMatchCountChange,
contentRef,
}: StructuredOutputProps) {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() =>
computeInitialPaths(data, isError)
)
const prevDataRef = useRef(data)
const prevIsErrorRef = useRef(isError)
const internalRef = useRef<HTMLDivElement>(null)
const listRef = useListRef(null)
const [containerHeight, setContainerHeight] = useState(400)
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
;(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (contentRef) {
;(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
}
},
[contentRef]
)
// Measure container height
useEffect(() => {
const container = internalRef.current?.parentElement
if (!container) return
const updateHeight = () => setContainerHeight(container.clientHeight)
updateHeight()
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(container)
return () => resizeObserver.disconnect()
}, [])
// Reset expanded paths when data changes
useEffect(() => {
if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) {
prevDataRef.current = data
prevIsErrorRef.current = isError
setExpandedPaths(computeInitialPaths(data, isError))
}
}, [data, isError])
const allMatchPaths = useMemo(() => {
if (!searchQuery) return []
if (isError) {
const errorText = extractErrorMessage(data)
const count = findTextMatches(errorText, searchQuery).length
return Array(count).fill('root.error') as string[]
}
return collectAllMatchPaths(data, searchQuery, 'root')
}, [data, searchQuery, isError])
useEffect(() => {
onMatchCountChange?.(allMatchPaths.length)
}, [allMatchPaths.length, onMatchCountChange])
const pathToMatchIndices = useMemo(() => buildPathToIndicesMap(allMatchPaths), [allMatchPaths])
// Auto-expand to current match
useEffect(() => {
if (
allMatchPaths.length === 0 ||
currentMatchIndex < 0 ||
currentMatchIndex >= allMatchPaths.length
) {
return
}
const currentPath = allMatchPaths[currentMatchIndex]
const pathsToExpand = [currentPath, ...getAncestorPaths(currentPath)]
setExpandedPaths((prev) => {
if (pathsToExpand.every((p) => prev.has(p))) return prev
const next = new Set(prev)
pathsToExpand.forEach((p) => next.add(p))
return next
})
}, [currentMatchIndex, allMatchPaths])
const handleToggle = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}, [])
const rootEntries = useMemo<NodeEntry[]>(() => {
if (isPrimitive(data)) return [{ key: 'value', value: data, path: 'root.value' }]
return buildEntries(data, 'root')
}, [data])
const searchContextValue = useMemo<SearchContextValue | null>(() => {
if (!searchQuery) return null
return { query: searchQuery, pathToMatchIndices }
}, [searchQuery, pathToMatchIndices])
const visibleRowCount = useMemo(
() => countVisibleRows(data, expandedPaths, isError),
[data, expandedPaths, isError]
)
const useVirtualization = visibleRowCount > CONFIG.VIRTUALIZATION_THRESHOLD
const flatRows = useMemo(() => {
if (!useVirtualization) return []
return flattenTree(data, expandedPaths, pathToMatchIndices, isError)
}, [data, expandedPaths, pathToMatchIndices, isError, useVirtualization])
// Scroll to match (virtualized)
useEffect(() => {
if (!useVirtualization || allMatchPaths.length === 0 || !listRef.current) return
const currentPath = allMatchPaths[currentMatchIndex]
const targetPath = currentPath.endsWith('.value') ? currentPath : `${currentPath}.value`
const rowIndex = flatRows.findIndex((r) => r.path === targetPath || r.path === currentPath)
if (rowIndex !== -1) {
listRef.current.scrollToRow({ index: rowIndex, align: 'center' })
}
}, [currentMatchIndex, allMatchPaths, flatRows, listRef, useVirtualization])
// Scroll to match (non-virtualized)
useEffect(() => {
if (useVirtualization || allMatchPaths.length === 0) return
const rafId = requestAnimationFrame(() => {
const match = internalRef.current?.querySelector(
`[data-match-index="${currentMatchIndex}"]`
) as HTMLElement | null
match?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
return () => cancelAnimationFrame(rafId)
}, [currentMatchIndex, allMatchPaths.length, expandedPaths, useVirtualization])
const containerClass = cn('flex flex-col pl-[20px]', wrapText && 'overflow-x-hidden', className)
const virtualizedContainerClass = cn('relative', wrapText && 'overflow-x-hidden', className)
const listClass = wrapText ? 'overflow-x-hidden' : 'overflow-x-auto'
// Running state
if (isRunning && data === undefined) {
return (
<div ref={setContainerRef} className={containerClass}>
<div className={STYLES.row}>
<span className={STYLES.keyName}>running</span>
<Badge variant='green' className={STYLES.badge}>
Running
</Badge>
</div>
</div>
)
}
// Empty state
if (rootEntries.length === 0 && !isError) {
return (
<div ref={setContainerRef} className={containerClass}>
<span className={STYLES.emptyValue}>null</span>
</div>
)
}
// Virtualized rendering
if (useVirtualization) {
return (
<div
ref={setContainerRef}
className={virtualizedContainerClass}
style={{ height: containerHeight }}
>
<List
listRef={listRef}
defaultHeight={containerHeight}
rowCount={flatRows.length}
rowHeight={CONFIG.ROW_HEIGHT}
rowComponent={VirtualizedRow}
rowProps={{
rows: flatRows,
onToggle: handleToggle,
wrapText,
searchQuery: searchQuery ?? '',
currentMatchIndex,
}}
overscanCount={CONFIG.OVERSCAN_COUNT}
className={listClass}
/>
</div>
)
}
// Non-virtualized rendering (preserves exact original styling)
if (isError) {
return (
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
<StructuredNode
name='error'
value={extractErrorMessage(data)}
path='root.error'
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
currentMatchIndex={currentMatchIndex}
isError
/>
</div>
</SearchContext.Provider>
)
}
return (
<SearchContext.Provider value={searchContextValue}>
<div ref={setContainerRef} className={containerClass}>
{rootEntries.map((entry) => (
<StructuredNode
key={entry.path}
name={entry.key}
value={entry.value}
path={entry.path}
expandedPaths={expandedPaths}
onToggle={handleToggle}
wrapText={wrapText}
currentMatchIndex={currentMatchIndex}
/>
))}
</div>
</SearchContext.Provider>
)
})

View File

@@ -0,0 +1,4 @@
export { OutputContextMenu, type OutputContextMenuProps } from './components/output-context-menu'
export { StructuredOutput, type StructuredOutputProps } from './components/structured-output'
export type { OutputPanelProps } from './output-panel'
export { OutputPanel } from './output-panel'

View File

@@ -0,0 +1,643 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
ArrowDown,
ArrowDownToLine,
ArrowUp,
Check,
Clipboard,
Database,
MoreHorizontal,
Palette,
Pause,
Search,
Trash2,
X,
} from 'lucide-react'
import Link from 'next/link'
import {
Button,
Code,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { FilterPopover } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover'
import { OutputContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/output-context-menu'
import { StructuredOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output'
import { ToggleButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/toggle-button'
import type {
BlockInfo,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { ConsoleEntry } from '@/stores/terminal'
import { useTerminalStore } from '@/stores/terminal'
interface OutputCodeContentProps {
code: string
language: 'javascript' | 'json'
wrapText: boolean
searchQuery: string | undefined
currentMatchIndex: number
onMatchCountChange: (count: number) => void
contentRef: React.RefObject<HTMLDivElement | null>
}
const OutputCodeContent = React.memo(function OutputCodeContent({
code,
language,
wrapText,
searchQuery,
currentMatchIndex,
onMatchCountChange,
contentRef,
}: OutputCodeContentProps) {
return (
<Code.Viewer
code={code}
showGutter
language={language}
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
paddingLeft={8}
gutterStyle={{ backgroundColor: 'transparent' }}
wrapText={wrapText}
searchQuery={searchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={onMatchCountChange}
contentRef={contentRef}
virtualized
showCollapseColumn={language === 'json'}
/>
)
})
/**
* Props for the OutputPanel component
* Store-backed settings (wrapText, openOnRun, structuredView, outputPanelWidth)
* are accessed directly from useTerminalStore to reduce prop drilling.
*/
export interface OutputPanelProps {
selectedEntry: ConsoleEntry
handleOutputPanelResizeMouseDown: (e: React.MouseEvent) => void
handleHeaderClick: () => void
isExpanded: boolean
expandToLastHeight: () => void
showInput: boolean
setShowInput: (show: boolean) => void
hasInputData: boolean
isPlaygroundEnabled: boolean
shouldShowTrainingButton: boolean
isTraining: boolean
handleTrainingClick: (e: React.MouseEvent) => void
showCopySuccess: boolean
handleCopy: () => void
filteredEntries: ConsoleEntry[]
handleExportConsole: (e: React.MouseEvent) => void
hasActiveFilters: boolean
handleClearConsole: (e: React.MouseEvent) => void
shouldShowCodeDisplay: boolean
outputDataStringified: string
outputData: unknown
handleClearConsoleFromMenu: () => void
filters: TerminalFilters
toggleBlock: (blockId: string) => void
toggleStatus: (status: 'error' | 'info') => void
uniqueBlocks: BlockInfo[]
}
/**
* Output panel component that manages its own search state.
* Accesses store-backed settings directly to reduce prop drilling.
*/
export const OutputPanel = React.memo(function OutputPanel({
selectedEntry,
handleOutputPanelResizeMouseDown,
handleHeaderClick,
isExpanded,
expandToLastHeight,
showInput,
setShowInput,
hasInputData,
isPlaygroundEnabled,
shouldShowTrainingButton,
isTraining,
handleTrainingClick,
showCopySuccess,
handleCopy,
filteredEntries,
handleExportConsole,
hasActiveFilters,
handleClearConsole,
shouldShowCodeDisplay,
outputDataStringified,
outputData,
handleClearConsoleFromMenu,
filters,
toggleBlock,
toggleStatus,
uniqueBlocks,
}: OutputPanelProps) {
// Access store-backed settings directly to reduce prop drilling
const outputPanelWidth = useTerminalStore((state) => state.outputPanelWidth)
const wrapText = useTerminalStore((state) => state.wrapText)
const setWrapText = useTerminalStore((state) => state.setWrapText)
const openOnRun = useTerminalStore((state) => state.openOnRun)
const setOpenOnRun = useTerminalStore((state) => state.setOpenOnRun)
const structuredView = useTerminalStore((state) => state.structuredView)
const setStructuredView = useTerminalStore((state) => state.setStructuredView)
const outputContentRef = useRef<HTMLDivElement>(null)
const [filtersOpen, setFiltersOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
const {
isSearchActive: isOutputSearchActive,
searchQuery: outputSearchQuery,
setSearchQuery: setOutputSearchQuery,
matchCount,
currentMatchIndex,
activateSearch: activateOutputSearch,
closeSearch: closeOutputSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef: outputSearchInputRef,
} = useCodeViewerFeatures({
contentRef: outputContentRef,
externalWrapText: wrapText,
onWrapTextChange: setWrapText,
})
// Context menu state for output panel
const [hasSelection, setHasSelection] = useState(false)
const [storedSelectionText, setStoredSelectionText] = useState('')
const {
isOpen: isOutputMenuOpen,
position: outputMenuPosition,
menuRef: outputMenuRef,
handleContextMenu: handleOutputContextMenu,
closeMenu: closeOutputMenu,
} = useContextMenu()
const handleOutputPanelContextMenu = useCallback(
(e: React.MouseEvent) => {
const selection = window.getSelection()
const selectionText = selection?.toString() || ''
setStoredSelectionText(selectionText)
setHasSelection(selectionText.length > 0)
handleOutputContextMenu(e)
},
[handleOutputContextMenu]
)
const handleCopySelection = useCallback(() => {
if (storedSelectionText) {
navigator.clipboard.writeText(storedSelectionText)
}
}, [storedSelectionText])
// Memoized callbacks to avoid inline arrow functions
const handleToggleStructuredView = useCallback(() => {
setStructuredView(!structuredView)
}, [structuredView, setStructuredView])
const handleToggleWrapText = useCallback(() => {
setWrapText(!wrapText)
}, [wrapText, setWrapText])
const handleToggleOpenOnRun = useCallback(() => {
setOpenOnRun(!openOnRun)
}, [openOnRun, setOpenOnRun])
const handleCopyClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleCopy()
},
[handleCopy]
)
const handleSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
activateOutputSearch()
},
[activateOutputSearch]
)
const handleCloseSearchClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
closeOutputSearch()
},
[closeOutputSearch]
)
const handleOutputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
if (showInput) setShowInput(false)
},
[isExpanded, expandToLastHeight, showInput, setShowInput]
)
const handleInputButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!isExpanded) {
expandToLastHeight()
}
setShowInput(true)
},
[isExpanded, expandToLastHeight, setShowInput]
)
const handleToggleButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
handleHeaderClick()
},
[handleHeaderClick]
)
/**
* Track text selection state for context menu.
* Skip updates when the context menu is open to prevent the selection
* state from changing mid-click (which would disable the copy button).
*/
useEffect(() => {
const handleSelectionChange = () => {
if (isOutputMenuOpen) return
const selection = window.getSelection()
setHasSelection(Boolean(selection && selection.toString().length > 0))
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => document.removeEventListener('selectionchange', handleSelectionChange)
}, [isOutputMenuOpen])
// Memoize the search query for structured output to avoid re-renders
const structuredSearchQuery = useMemo(
() => (isOutputSearchActive ? outputSearchQuery : undefined),
[isOutputSearchActive, outputSearchQuery]
)
return (
<>
<div
className='absolute top-0 right-0 bottom-0 flex flex-col border-[var(--border)] border-l bg-[var(--surface-1)]'
style={{ width: `${outputPanelWidth}px` }}
>
{/* Horizontal Resize Handle */}
<div
className='-ml-[4px] absolute top-0 bottom-0 left-0 z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleOutputPanelResizeMouseDown}
role='separator'
aria-label='Resize output panel'
aria-orientation='vertical'
/>
{/* Header */}
<div
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center justify-between bg-[var(--surface-1)] pr-[16px] pl-[10px]'
onClick={handleHeaderClick}
>
<div className='flex items-center'>
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleOutputButtonClick}
aria-label='Show output'
>
Output
</Button>
{hasInputData && (
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={handleInputButtonClick}
aria-label='Show input'
>
Input
</Button>
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* Unified filter popover */}
{filteredEntries.length > 0 && (
<FilterPopover
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
toggleStatus={toggleStatus}
toggleBlock={toggleBlock}
uniqueBlocks={uniqueBlocks}
hasActiveFilters={hasActiveFilters}
/>
)}
{isOutputSearchActive ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCloseSearchClick}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Close search</span>
</Tooltip.Content>
</Tooltip.Root>
) : (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleSearchClick}
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Search</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{isPlaygroundEnabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Link href='/playground'>
<Button
variant='ghost'
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-[12px] w-[12px]' />
</Button>
</Link>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Component Playground</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{shouldShowTrainingButton && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleTrainingClick}
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
className={clsx(
'!p-1.5 -m-1.5',
isTraining && 'text-orange-600 dark:text-orange-400'
)}
>
{isTraining ? (
<Pause className='h-[12px] w-[12px]' />
) : (
<Database className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleCopyClick}
aria-label='Copy output'
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-[12px] w-[12px]' />
) : (
<Clipboard className='h-[12px] w-[12px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>{showCopySuccess ? 'Copied' : 'Copy output'}</span>
</Tooltip.Content>
</Tooltip.Root>
{filteredEntries.length > 0 && (
<>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleExportConsole}
aria-label='Download console CSV'
className='!p-1.5 -m-1.5'
>
<ArrowDownToLine className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Download CSV</span>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleClearConsole}
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘D'>Clear console</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</>
)}
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
<PopoverTrigger asChild>
<Button
variant='ghost'
onClick={(e) => e.stopPropagation()}
aria-label='Terminal options'
className='!p-1.5 -m-1.5'
>
<MoreHorizontal className='h-3.5 w-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
collisionPadding={0}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '140px', maxWidth: '160px' }}
className='gap-[2px]'
>
<PopoverItem
active={structuredView}
showCheck={structuredView}
onClick={handleToggleStructuredView}
>
<span>Structured view</span>
</PopoverItem>
<PopoverItem active={wrapText} showCheck={wrapText} onClick={handleToggleWrapText}>
<span>Wrap text</span>
</PopoverItem>
<PopoverItem
active={openOnRun}
showCheck={openOnRun}
onClick={handleToggleOpenOnRun}
>
<span>Open on run</span>
</PopoverItem>
</PopoverContent>
</Popover>
<ToggleButton isExpanded={isExpanded} onClick={handleToggleButtonClick} />
</div>
</div>
{/* Search Overlay */}
{isOutputSearchActive && (
<div
className='absolute top-[30px] right-[8px] z-30 flex h-[34px] items-center gap-[6px] rounded-b-[4px] border border-[var(--border)] border-t-0 bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
data-toolbar-root
data-search-active='true'
>
<Input
ref={outputSearchInputRef}
type='text'
value={outputSearchQuery}
onChange={(e) => setOutputSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={clsx(
'w-[58px] font-medium text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : 'No results'}
</span>
<Button
variant='ghost'
onClick={goToPreviousMatch}
aria-label='Previous match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={goToNextMatch}
aria-label='Next match'
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
onClick={closeOutputSearch}
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Content */}
<div
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
onContextMenu={handleOutputPanelContextMenu}
>
{shouldShowCodeDisplay ? (
<OutputCodeContent
code={selectedEntry.input.code}
language={(selectedEntry.input.language as 'javascript' | 'json') || 'javascript'}
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : structuredView ? (
<StructuredOutput
data={outputData}
wrapText={wrapText}
isError={!showInput && Boolean(selectedEntry.error)}
isRunning={!showInput && Boolean(selectedEntry.isRunning)}
className='min-h-full'
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
) : (
<OutputCodeContent
code={outputDataStringified}
language='json'
wrapText={wrapText}
searchQuery={structuredSearchQuery}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
contentRef={outputContentRef}
/>
)}
</div>
</div>
{/* Output Panel Context Menu */}
<OutputContextMenu
isOpen={isOutputMenuOpen}
position={outputMenuPosition}
menuRef={outputMenuRef}
onClose={closeOutputMenu}
onCopySelection={handleCopySelection}
onCopyAll={handleCopy}
onSearch={activateOutputSearch}
structuredView={structuredView}
onToggleStructuredView={handleToggleStructuredView}
wrapText={wrapText}
onToggleWrap={handleToggleWrapText}
openOnRun={openOnRun}
onToggleOpenOnRun={handleToggleOpenOnRun}
onClearConsole={handleClearConsoleFromMenu}
hasSelection={hasSelection}
/>
</>
)
})

View File

@@ -0,0 +1 @@
export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display'

View File

@@ -0,0 +1,43 @@
'use client'
import { memo } from 'react'
import { Badge } from '@/components/emcn'
import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
/**
* Running badge component - displays a consistent "Running" indicator
*/
export const RunningBadge = memo(function RunningBadge() {
return (
<Badge variant='green' className={BADGE_STYLE}>
Running
</Badge>
)
})
/**
* Props for StatusDisplay component
*/
export interface StatusDisplayProps {
isRunning: boolean
isCanceled: boolean
formattedDuration: string
}
/**
* Reusable status display for terminal rows.
* Shows Running badge, 'canceled' text, or formatted duration.
*/
export const StatusDisplay = memo(function StatusDisplay({
isRunning,
isCanceled,
formattedDuration,
}: StatusDisplayProps) {
if (isRunning) {
return <RunningBadge />
}
if (isCanceled) {
return <>canceled</>
}
return <>{formattedDuration}</>
})

View File

@@ -0,0 +1 @@
export { ToggleButton, type ToggleButtonProps } from './toggle-button'

View File

@@ -0,0 +1,33 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import clsx from 'clsx'
import { ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
export interface ToggleButtonProps {
isExpanded: boolean
onClick: (e: React.MouseEvent) => void
}
/**
* Toggle button component for terminal expand/collapse
*/
export const ToggleButton = memo(function ToggleButton({ isExpanded, onClick }: ToggleButtonProps) {
return (
<Button
variant='ghost'
className='!p-1.5 -m-1.5'
onClick={onClick}
aria-label='Toggle terminal'
>
<ChevronDown
className={clsx(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-100',
!isExpanded && 'rotate-180'
)}
/>
</Button>
)
})

View File

@@ -1,3 +1,4 @@
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
export { useOutputPanelResize } from './use-output-panel-resize' export { useOutputPanelResize } from './use-output-panel-resize'
export { useTerminalFilters } from './use-terminal-filters' export { useTerminalFilters } from './use-terminal-filters'
export { useTerminalResize } from './use-terminal-resize' export { useTerminalResize } from './use-terminal-resize'

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { OUTPUT_PANEL_WIDTH } from '@/stores/constants' import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import { useTerminalStore } from '@/stores/terminal' import { useTerminalStore } from '@/stores/terminal'
const BLOCK_COLUMN_WIDTH = 240
export function useOutputPanelResize() { export function useOutputPanelResize() {
const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth) const setOutputPanelWidth = useTerminalStore((state) => state.setOutputPanelWidth)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
@@ -25,7 +23,7 @@ export function useOutputPanelResize() {
const newWidth = window.innerWidth - e.clientX - panelWidth const newWidth = window.innerWidth - e.clientX - panelWidth
const terminalWidth = window.innerWidth - sidebarWidth - panelWidth const terminalWidth = window.innerWidth - sidebarWidth - panelWidth
const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH const maxWidth = terminalWidth - TERMINAL_BLOCK_COLUMN_WIDTH
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth)) const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, Math.min(newWidth, maxWidth))
setOutputPanelWidth(clampedWidth) setOutputPanelWidth(clampedWidth)

View File

@@ -1,26 +1,10 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import type {
SortConfig,
TerminalFilters,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
import type { ConsoleEntry } from '@/stores/terminal' import type { ConsoleEntry } from '@/stores/terminal'
/**
* Sort configuration
*/
export type SortField = 'timestamp'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
/** /**
* Custom hook to manage terminal filters and sorting. * Custom hook to manage terminal filters and sorting.
* Provides filter state, sort state, and filtering/sorting logic for console entries. * Provides filter state, sort state, and filtering/sorting logic for console entries.
@@ -31,7 +15,6 @@ export function useTerminalFilters() {
const [filters, setFilters] = useState<TerminalFilters>({ const [filters, setFilters] = useState<TerminalFilters>({
blockIds: new Set(), blockIds: new Set(),
statuses: new Set(), statuses: new Set(),
runIds: new Set(),
}) })
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
@@ -69,21 +52,6 @@ export function useTerminalFilters() {
}) })
}, []) }, [])
/**
* Toggles a run ID filter
*/
const toggleRunId = useCallback((runId: string) => {
setFilters((prev) => {
const newRunIds = new Set(prev.runIds)
if (newRunIds.has(runId)) {
newRunIds.delete(runId)
} else {
newRunIds.add(runId)
}
return { ...prev, runIds: newRunIds }
})
}, [])
/** /**
* Toggles sort direction between ascending and descending * Toggles sort direction between ascending and descending
*/ */
@@ -101,7 +69,6 @@ export function useTerminalFilters() {
setFilters({ setFilters({
blockIds: new Set(), blockIds: new Set(),
statuses: new Set(), statuses: new Set(),
runIds: new Set(),
}) })
}, []) }, [])
@@ -109,7 +76,7 @@ export function useTerminalFilters() {
* Checks if any filters are active * Checks if any filters are active
*/ */
const hasActiveFilters = useMemo(() => { const hasActiveFilters = useMemo(() => {
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0 return filters.blockIds.size > 0 || filters.statuses.size > 0
}, [filters]) }, [filters])
/** /**
@@ -134,14 +101,6 @@ export function useTerminalFilters() {
if (!hasStatus) return false if (!hasStatus) return false
} }
// Run ID filter
if (
filters.runIds.size > 0 &&
(!entry.executionId || !filters.runIds.has(entry.executionId))
) {
return false
}
return true return true
}) })
} }
@@ -164,7 +123,6 @@ export function useTerminalFilters() {
sortConfig, sortConfig,
toggleBlock, toggleBlock,
toggleStatus, toggleStatus,
toggleRunId,
toggleSort, toggleSort,
clearFilters, clearFilters,
hasActiveFilters, hasActiveFilters,

View File

@@ -0,0 +1,64 @@
/**
* Terminal filter configuration state
*/
export interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
}
/**
* Context menu position for positioning floating menus
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Sort field options for terminal entries
*/
export type SortField = 'timestamp'
/**
* Sort direction options
*/
export type SortDirection = 'asc' | 'desc'
/**
* Sort configuration for terminal entries
*/
export interface SortConfig {
field: SortField
direction: SortDirection
}
/**
* Status type for console entries
*/
export type EntryStatus = 'error' | 'info'
/**
* Block information for filters
*/
export interface BlockInfo {
blockId: string
blockName: string
blockType: string
}
/**
* Common row styling classes for terminal components
*/
export const ROW_STYLES = {
base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]',
selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]',
hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
nested:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
iconButton: '!p-1.5 -m-1.5',
} as const
/**
* Common badge styling for status badges
*/
export const BADGE_STYLE = 'rounded-[4px] px-[4px] py-[0px] text-[11px]'

View File

@@ -0,0 +1,452 @@
import type React from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { getBlock } from '@/blocks'
import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
import type { ConsoleEntry } from '@/stores/terminal'
/**
* Subflow colors matching the subflow tool configs
*/
const SUBFLOW_COLORS = {
loop: '#2FB3FF',
parallel: '#FEE12B',
} as const
/**
* Retrieves the icon component for a given block type
*/
export function getBlockIcon(
blockType: string
): React.ComponentType<{ className?: string }> | null {
const blockConfig = getBlock(blockType)
if (blockConfig?.icon) {
return blockConfig.icon
}
if (blockType === 'loop') {
return RepeatIcon
}
if (blockType === 'parallel') {
return SplitIcon
}
return null
}
/**
* Gets the background color for a block type
*/
export function getBlockColor(blockType: string): string {
const blockConfig = getBlock(blockType)
if (blockConfig?.bgColor) {
return blockConfig.bgColor
}
// Use proper subflow colors matching the toolbar configs
if (blockType === 'loop') {
return SUBFLOW_COLORS.loop
}
if (blockType === 'parallel') {
return SUBFLOW_COLORS.parallel
}
return '#6b7280'
}
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Determines if a keyboard event originated from a text-editable element
*/
export function isEventFromEditableElement(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null
if (!target) return false
const isEditable = (el: HTMLElement | null): boolean => {
if (!el) return false
if (el instanceof HTMLInputElement) return true
if (el instanceof HTMLTextAreaElement) return true
if ((el as HTMLElement).isContentEditable) return true
const role = el.getAttribute('role')
if (role === 'textbox' || role === 'combobox') return true
return false
}
let el: HTMLElement | null = target
while (el) {
if (isEditable(el)) return true
el = el.parentElement
}
return false
}
/**
* Checks if a block type is a subflow (loop or parallel)
*/
export function isSubflowBlockType(blockType: string): boolean {
const lower = blockType?.toLowerCase() || ''
return lower === 'loop' || lower === 'parallel'
}
/**
* Node type for the tree structure
*/
export type EntryNodeType = 'block' | 'subflow' | 'iteration'
/**
* Entry node for tree structure - represents a block, subflow, or iteration
*/
export interface EntryNode {
/** The console entry (for blocks) or synthetic entry (for subflows/iterations) */
entry: ConsoleEntry
/** Child nodes */
children: EntryNode[]
/** Node type */
nodeType: EntryNodeType
/** Iteration info for iteration nodes */
iterationInfo?: {
current: number
total?: number
}
}
/**
* Execution group interface for grouping entries by execution
*/
export interface ExecutionGroup {
executionId: string
startTime: string
endTime: string
startTimeMs: number
endTimeMs: number
duration: number
status: 'success' | 'error'
/** Flat list of entries (legacy, kept for filters) */
entries: ConsoleEntry[]
/** Tree structure of entry nodes for nested display */
entryTree: EntryNode[]
}
/**
* Iteration group for grouping blocks within the same iteration
*/
interface IterationGroup {
iterationType: string
iterationCurrent: number
iterationTotal?: number
blocks: ConsoleEntry[]
startTimeMs: number
}
/**
* Builds a tree structure from flat entries.
* Groups iteration entries by (iterationType, iterationCurrent), showing all blocks
* that executed within each iteration.
* Sorts by start time to ensure chronological order.
*/
function buildEntryTree(entries: ConsoleEntry[]): EntryNode[] {
// Separate regular blocks from iteration entries
const regularBlocks: ConsoleEntry[] = []
const iterationEntries: ConsoleEntry[] = []
for (const entry of entries) {
if (entry.iterationType && entry.iterationCurrent !== undefined) {
iterationEntries.push(entry)
} else {
regularBlocks.push(entry)
}
}
// Group iteration entries by (iterationType, iterationCurrent)
const iterationGroupsMap = new Map<string, IterationGroup>()
for (const entry of iterationEntries) {
const key = `${entry.iterationType}-${entry.iterationCurrent}`
let group = iterationGroupsMap.get(key)
const entryStartMs = new Date(entry.startedAt || entry.timestamp).getTime()
if (!group) {
group = {
iterationType: entry.iterationType!,
iterationCurrent: entry.iterationCurrent!,
iterationTotal: entry.iterationTotal,
blocks: [],
startTimeMs: entryStartMs,
}
iterationGroupsMap.set(key, group)
} else {
// Update start time to earliest
if (entryStartMs < group.startTimeMs) {
group.startTimeMs = entryStartMs
}
// Update total if available
if (entry.iterationTotal !== undefined) {
group.iterationTotal = entry.iterationTotal
}
}
group.blocks.push(entry)
}
// Sort blocks within each iteration by start time ascending (oldest first, top-down)
for (const group of iterationGroupsMap.values()) {
group.blocks.sort((a, b) => {
const aStart = new Date(a.startedAt || a.timestamp).getTime()
const bStart = new Date(b.startedAt || b.timestamp).getTime()
return aStart - bStart
})
}
// Group iterations by iterationType to create subflow parents
const subflowGroups = new Map<string, IterationGroup[]>()
for (const group of iterationGroupsMap.values()) {
const type = group.iterationType
let groups = subflowGroups.get(type)
if (!groups) {
groups = []
subflowGroups.set(type, groups)
}
groups.push(group)
}
// Sort iterations within each subflow by iteration number
for (const groups of subflowGroups.values()) {
groups.sort((a, b) => a.iterationCurrent - b.iterationCurrent)
}
// Build subflow nodes with iteration children
const subflowNodes: EntryNode[] = []
for (const [iterationType, iterationGroups] of subflowGroups.entries()) {
// Calculate subflow timing from all its iterations
const firstIteration = iterationGroups[0]
const allBlocks = iterationGroups.flatMap((g) => g.blocks)
const subflowStartMs = Math.min(
...allBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
)
const subflowEndMs = Math.max(
...allBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const totalDuration = allBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
// Create synthetic subflow parent entry
const syntheticSubflow: ConsoleEntry = {
id: `subflow-${iterationType}-${firstIteration.blocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(subflowStartMs).toISOString(),
workflowId: firstIteration.blocks[0]?.workflowId || '',
blockId: `${iterationType}-container`,
blockName: iterationType.charAt(0).toUpperCase() + iterationType.slice(1),
blockType: iterationType,
executionId: firstIteration.blocks[0]?.executionId,
startedAt: new Date(subflowStartMs).toISOString(),
endedAt: new Date(subflowEndMs).toISOString(),
durationMs: totalDuration,
success: !allBlocks.some((b) => b.error),
}
// Build iteration child nodes
const iterationNodes: EntryNode[] = iterationGroups.map((iterGroup) => {
// Create synthetic iteration entry
const iterBlocks = iterGroup.blocks
const iterStartMs = Math.min(
...iterBlocks.map((b) => new Date(b.startedAt || b.timestamp).getTime())
)
const iterEndMs = Math.max(
...iterBlocks.map((b) => new Date(b.endedAt || b.timestamp).getTime())
)
const iterDuration = iterBlocks.reduce((sum, b) => sum + (b.durationMs || 0), 0)
const syntheticIteration: ConsoleEntry = {
id: `iteration-${iterationType}-${iterGroup.iterationCurrent}-${iterBlocks[0]?.executionId || 'unknown'}`,
timestamp: new Date(iterStartMs).toISOString(),
workflowId: iterBlocks[0]?.workflowId || '',
blockId: `iteration-${iterGroup.iterationCurrent}`,
blockName: `Iteration ${iterGroup.iterationCurrent}${iterGroup.iterationTotal !== undefined ? ` / ${iterGroup.iterationTotal}` : ''}`,
blockType: iterationType,
executionId: iterBlocks[0]?.executionId,
startedAt: new Date(iterStartMs).toISOString(),
endedAt: new Date(iterEndMs).toISOString(),
durationMs: iterDuration,
success: !iterBlocks.some((b) => b.error),
iterationCurrent: iterGroup.iterationCurrent,
iterationTotal: iterGroup.iterationTotal,
iterationType: iterationType as 'loop' | 'parallel',
}
// Block nodes within this iteration
const blockNodes: EntryNode[] = iterBlocks.map((block) => ({
entry: block,
children: [],
nodeType: 'block' as const,
}))
return {
entry: syntheticIteration,
children: blockNodes,
nodeType: 'iteration' as const,
iterationInfo: {
current: iterGroup.iterationCurrent,
total: iterGroup.iterationTotal,
},
}
})
subflowNodes.push({
entry: syntheticSubflow,
children: iterationNodes,
nodeType: 'subflow' as const,
})
}
// Build nodes for regular blocks
const regularNodes: EntryNode[] = regularBlocks.map((entry) => ({
entry,
children: [],
nodeType: 'block' as const,
}))
// Combine all nodes and sort by start time ascending (oldest first, top-down)
const allNodes = [...subflowNodes, ...regularNodes]
allNodes.sort((a, b) => {
const aStart = new Date(a.entry.startedAt || a.entry.timestamp).getTime()
const bStart = new Date(b.entry.startedAt || b.entry.timestamp).getTime()
return aStart - bStart
})
return allNodes
}
/**
* Groups console entries by execution ID and builds a tree structure.
* Pre-computes timestamps for efficient sorting.
*/
export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup[] {
const groups = new Map<
string,
{ meta: Omit<ExecutionGroup, 'entryTree'>; entries: ConsoleEntry[] }
>()
for (const entry of entries) {
const execId = entry.executionId || entry.id
const entryStartTime = entry.startedAt || entry.timestamp
const entryEndTime = entry.endedAt || entry.timestamp
const entryStartMs = new Date(entryStartTime).getTime()
const entryEndMs = new Date(entryEndTime).getTime()
let group = groups.get(execId)
if (!group) {
group = {
meta: {
executionId: execId,
startTime: entryStartTime,
endTime: entryEndTime,
startTimeMs: entryStartMs,
endTimeMs: entryEndMs,
duration: 0,
status: 'success',
entries: [],
},
entries: [],
}
groups.set(execId, group)
} else {
// Update timing bounds
if (entryStartMs < group.meta.startTimeMs) {
group.meta.startTime = entryStartTime
group.meta.startTimeMs = entryStartMs
}
if (entryEndMs > group.meta.endTimeMs) {
group.meta.endTime = entryEndTime
group.meta.endTimeMs = entryEndMs
}
}
// Check for errors
if (entry.error) {
group.meta.status = 'error'
}
group.entries.push(entry)
}
// Build tree structure for each group
const result: ExecutionGroup[] = []
for (const group of groups.values()) {
group.meta.duration = group.meta.endTimeMs - group.meta.startTimeMs
group.meta.entries = group.entries
result.push({
...group.meta,
entryTree: buildEntryTree(group.entries),
})
}
// Sort by start time descending (newest first)
result.sort((a, b) => b.startTimeMs - a.startTimeMs)
return result
}
/**
* Flattens entry tree into display order for keyboard navigation
*/
export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] {
const result: ConsoleEntry[] = []
for (const node of nodes) {
result.push(node.entry)
if (node.children.length > 0) {
result.push(...flattenEntryTree(node.children))
}
}
return result
}
/**
* Block entry with parent tracking for navigation
*/
export interface NavigableBlockEntry {
entry: ConsoleEntry
executionId: string
/** IDs of parent nodes (subflows, iterations) that contain this block */
parentNodeIds: string[]
}
/**
* Flattens entry tree to only include actual block entries (not subflows/iterations).
* Also tracks parent node IDs for auto-expanding when navigating.
*/
export function flattenBlockEntriesOnly(
nodes: EntryNode[],
executionId: string,
parentIds: string[] = []
): NavigableBlockEntry[] {
const result: NavigableBlockEntry[] = []
for (const node of nodes) {
if (node.nodeType === 'block') {
result.push({
entry: node.entry,
executionId,
parentNodeIds: parentIds,
})
}
if (node.children.length > 0) {
const newParentIds = node.nodeType !== 'block' ? [...parentIds, node.entry.id] : parentIds
result.push(...flattenBlockEntriesOnly(node.children, executionId, newParentIds))
}
}
return result
}
/**
* Terminal height configuration constants
*/
export const TERMINAL_CONFIG = {
NEAR_MIN_THRESHOLD: 40,
BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH,
HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
} as const

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { readSSEStream } from '@/lib/core/utils/sse'
import type { GenerationType } from '@/blocks/types' import type { GenerationType } from '@/blocks/types'
import { subscriptionKeys } from '@/hooks/queries/subscription' import { subscriptionKeys } from '@/hooks/queries/subscription'
@@ -62,6 +63,8 @@ export interface WandConfig {
interface UseWandProps { interface UseWandProps {
wandConfig?: WandConfig wandConfig?: WandConfig
currentValue?: string currentValue?: string
/** Additional context about available sources/references for the prompt */
sources?: string
onGeneratedContent: (content: string) => void onGeneratedContent: (content: string) => void
onStreamChunk?: (chunk: string) => void onStreamChunk?: (chunk: string) => void
onStreamStart?: () => void onStreamStart?: () => void
@@ -71,6 +74,7 @@ interface UseWandProps {
export function useWand({ export function useWand({
wandConfig, wandConfig,
currentValue, currentValue,
sources,
onGeneratedContent, onGeneratedContent,
onStreamChunk, onStreamChunk,
onStreamStart, onStreamStart,
@@ -153,6 +157,12 @@ export function useWand({
if (systemPrompt.includes('{context}')) { if (systemPrompt.includes('{context}')) {
systemPrompt = systemPrompt.replace('{context}', contextInfo) systemPrompt = systemPrompt.replace('{context}', contextInfo)
} }
if (systemPrompt.includes('{sources}')) {
systemPrompt = systemPrompt.replace(
'{sources}',
sources || 'No upstream sources available'
)
}
const userMessage = prompt const userMessage = prompt
@@ -184,52 +194,10 @@ export function useWand({
throw new Error('Response body is null') throw new Error('Response body is null')
} }
const reader = response.body.getReader() const accumulatedContent = await readSSEStream(response.body, {
const decoder = new TextDecoder() onChunk: onStreamChunk,
let accumulatedContent = '' signal: abortControllerRef.current?.signal,
})
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const lineData = line.substring(6)
if (lineData === '[DONE]') {
continue
}
try {
const data = JSON.parse(lineData)
if (data.error) {
throw new Error(data.error)
}
if (data.chunk) {
accumulatedContent += data.chunk
if (onStreamChunk) {
onStreamChunk(data.chunk)
}
}
if (data.done) {
break
}
} catch (parseError) {
logger.debug('Failed to parse SSE line', { line, parseError })
}
}
}
}
} finally {
reader.releaseLock()
}
if (accumulatedContent) { if (accumulatedContent) {
onGeneratedContent(accumulatedContent) onGeneratedContent(accumulatedContent)

View File

@@ -15,13 +15,16 @@ import {
TriggerUtils, TriggerUtils,
} from '@/lib/workflows/triggers/triggers' } from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { getBlock } from '@/blocks'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors' import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block' import { coerceValue } from '@/executor/utils/start-block'
import { subscriptionKeys } from '@/hooks/queries/subscription' import { subscriptionKeys } from '@/hooks/queries/subscription'
import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer' import { WorkflowValidationError } from '@/serializer'
import { useExecutionStore } from '@/stores/execution' import { useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel' import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment' import { useEnvironmentStore } from '@/stores/settings/environment'
import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal' import { type ConsoleEntry, useTerminalConsoleStore } from '@/stores/terminal'
@@ -81,7 +84,8 @@ export function useWorkflowExecution() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow() const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry() const { activeWorkflowId, workflows } = useWorkflowRegistry()
const { toggleConsole, addConsole } = useTerminalConsoleStore() const { toggleConsole, addConsole, updateConsole, cancelRunningEntries } =
useTerminalConsoleStore()
const { getAllVariables } = useEnvironmentStore() const { getAllVariables } = useEnvironmentStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore() const { getVariablesByWorkflowId, variables } = useVariablesStore()
const { const {
@@ -98,11 +102,15 @@ export function useWorkflowExecution() {
setActiveBlocks, setActiveBlocks,
setBlockRunStatus, setBlockRunStatus,
setEdgeRunStatus, setEdgeRunStatus,
setLastExecutionSnapshot,
getLastExecutionSnapshot,
clearLastExecutionSnapshot,
} = useExecutionStore() } = useExecutionStore()
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null) const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
const executionStream = useExecutionStream() const executionStream = useExecutionStream()
const currentChatExecutionIdRef = useRef<string | null>(null) const currentChatExecutionIdRef = useRef<string | null>(null)
const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
const addNotification = useNotificationStore((state) => state.addNotification)
/** /**
* Validates debug state before performing debug operations * Validates debug state before performing debug operations
@@ -668,7 +676,8 @@ export function useWorkflowExecution() {
onStream?: (se: StreamingExecution) => Promise<void>, onStream?: (se: StreamingExecution) => Promise<void>,
executionId?: string, executionId?: string,
onBlockComplete?: (blockId: string, output: any) => Promise<void>, onBlockComplete?: (blockId: string, output: any) => Promise<void>,
overrideTriggerType?: 'chat' | 'manual' | 'api' overrideTriggerType?: 'chat' | 'manual' | 'api',
stopAfterBlockId?: string
): Promise<ExecutionResult | StreamingExecution> => { ): Promise<ExecutionResult | StreamingExecution> => {
// Use diff workflow for execution when available, regardless of canvas view state // Use diff workflow for execution when available, regardless of canvas view state
const executionWorkflowState = null as { const executionWorkflowState = null as {
@@ -867,6 +876,8 @@ export function useWorkflowExecution() {
if (activeWorkflowId) { if (activeWorkflowId) {
logger.info('Using server-side executor') logger.info('Using server-side executor')
const executionId = uuidv4()
let executionResult: ExecutionResult = { let executionResult: ExecutionResult = {
success: false, success: false,
output: {}, output: {},
@@ -876,6 +887,8 @@ export function useWorkflowExecution() {
const activeBlocksSet = new Set<string>() const activeBlocksSet = new Set<string>()
const streamedContent = new Map<string, string>() const streamedContent = new Map<string, string>()
const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
// Execute the workflow // Execute the workflow
try { try {
@@ -887,6 +900,7 @@ export function useWorkflowExecution() {
triggerType: overrideTriggerType || 'manual', triggerType: overrideTriggerType || 'manual',
useDraftState: true, useDraftState: true,
isClientSession: true, isClientSession: true,
stopAfterBlockId,
workflowStateOverride: executionWorkflowState workflowStateOverride: executionWorkflowState
? { ? {
blocks: executionWorkflowState.blocks, blocks: executionWorkflowState.blocks,
@@ -910,24 +924,49 @@ export function useWorkflowExecution() {
incomingEdges.forEach((edge) => { incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success') setEdgeRunStatus(edge.id, 'success')
}) })
// Add entry to terminal immediately with isRunning=true
const startedAt = new Date().toISOString()
addConsole({
input: {},
output: undefined,
success: undefined,
durationMs: undefined,
startedAt,
endedAt: undefined,
workflowId: activeWorkflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
isRunning: true,
// Pass through iteration context for subflow grouping
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
}, },
onBlockCompleted: (data) => { onBlockCompleted: (data) => {
logger.info('onBlockCompleted received:', { data }) logger.info('onBlockCompleted received:', { data })
activeBlocksSet.delete(data.blockId) activeBlocksSet.delete(data.blockId)
// Create a new Set to trigger React re-render
setActiveBlocks(new Set(activeBlocksSet)) setActiveBlocks(new Set(activeBlocksSet))
// Track successful block execution in run path
setBlockRunStatus(data.blockId, 'success') setBlockRunStatus(data.blockId, 'success')
// Edges already tracked in onBlockStarted, no need to track again executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel'
if (isContainerBlock) return
const startedAt = new Date(Date.now() - data.durationMs).toISOString() const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString() const endedAt = new Date().toISOString()
// Accumulate block log for the execution result
accumulatedBlockLogs.push({ accumulatedBlockLogs.push({
blockId: data.blockId, blockId: data.blockId,
blockName: data.blockName || 'Unknown Block', blockName: data.blockName || 'Unknown Block',
@@ -940,24 +979,23 @@ export function useWorkflowExecution() {
endedAt, endedAt,
}) })
// Add to console // Update existing console entry (created in onBlockStarted) with completion data
addConsole({ updateConsole(
input: data.input || {}, data.blockId,
output: data.output, {
success: true, input: data.input || {},
durationMs: data.durationMs, replaceOutput: data.output,
startedAt, success: true,
endedAt, durationMs: data.durationMs,
workflowId: activeWorkflowId, endedAt,
blockId: data.blockId, isRunning: false,
executionId: executionId || uuidv4(), // Pass through iteration context for subflow grouping
blockName: data.blockName || 'Unknown Block', iterationCurrent: data.iterationCurrent,
blockType: data.blockType || 'unknown', iterationTotal: data.iterationTotal,
// Pass through iteration context for console pills iterationType: data.iterationType,
iterationCurrent: data.iterationCurrent, },
iterationTotal: data.iterationTotal, executionId
iterationType: data.iterationType, )
})
// Call onBlockComplete callback if provided // Call onBlockComplete callback if provided
if (onBlockComplete) { if (onBlockComplete) {
@@ -992,25 +1030,24 @@ export function useWorkflowExecution() {
endedAt, endedAt,
}) })
// Add error to console // Update existing console entry (created in onBlockStarted) with error data
addConsole({ updateConsole(
input: data.input || {}, data.blockId,
output: {}, {
success: false, input: data.input || {},
error: data.error, replaceOutput: {},
durationMs: data.durationMs, success: false,
startedAt, error: data.error,
endedAt, durationMs: data.durationMs,
workflowId: activeWorkflowId, endedAt,
blockId: data.blockId, isRunning: false,
executionId: executionId || uuidv4(), // Pass through iteration context for subflow grouping
blockName: data.blockName, iterationCurrent: data.iterationCurrent,
blockType: data.blockType, iterationTotal: data.iterationTotal,
// Pass through iteration context for console pills iterationType: data.iterationType,
iterationCurrent: data.iterationCurrent, },
iterationTotal: data.iterationTotal, executionId
iterationType: data.iterationType, )
})
}, },
onStreamChunk: (data) => { onStreamChunk: (data) => {
@@ -1056,6 +1093,53 @@ export function useWorkflowExecution() {
}, },
logs: accumulatedBlockLogs, logs: accumulatedBlockLogs,
} }
// Add trigger block to executed blocks so downstream blocks can use run-from-block
if (data.success && startBlockId) {
executedBlockIds.add(startBlockId)
}
if (data.success && activeWorkflowId) {
if (stopAfterBlockId) {
const existingSnapshot = getLastExecutionSnapshot(activeWorkflowId)
const mergedBlockStates = {
...(existingSnapshot?.blockStates || {}),
...Object.fromEntries(accumulatedBlockStates),
}
const mergedExecutedBlocks = new Set([
...(existingSnapshot?.executedBlocks || []),
...executedBlockIds,
])
const snapshot: SerializableExecutionState = {
blockStates: mergedBlockStates,
executedBlocks: Array.from(mergedExecutedBlocks),
blockLogs: [...(existingSnapshot?.blockLogs || []), ...accumulatedBlockLogs],
decisions: existingSnapshot?.decisions || { router: {}, condition: {} },
completedLoops: existingSnapshot?.completedLoops || [],
activeExecutionPath: Array.from(mergedExecutedBlocks),
}
setLastExecutionSnapshot(activeWorkflowId, snapshot)
logger.info('Merged execution snapshot after run-until-block', {
workflowId: activeWorkflowId,
newBlocksExecuted: executedBlockIds.size,
totalExecutedBlocks: mergedExecutedBlocks.size,
})
} else {
const snapshot: SerializableExecutionState = {
blockStates: Object.fromEntries(accumulatedBlockStates),
executedBlocks: Array.from(executedBlockIds),
blockLogs: accumulatedBlockLogs,
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: Array.from(executedBlockIds),
}
setLastExecutionSnapshot(activeWorkflowId, snapshot)
logger.info('Stored execution snapshot for run-from-block', {
workflowId: activeWorkflowId,
executedBlocksCount: executedBlockIds.size,
})
}
}
}, },
onExecutionError: (data) => { onExecutionError: (data) => {
@@ -1089,7 +1173,7 @@ export function useWorkflowExecution() {
endedAt: new Date().toISOString(), endedAt: new Date().toISOString(),
workflowId: activeWorkflowId, workflowId: activeWorkflowId,
blockId: 'validation', blockId: 'validation',
executionId: executionId || uuidv4(), executionId,
blockName: 'Workflow Validation', blockName: 'Workflow Validation',
blockType: 'validation', blockType: 'validation',
}) })
@@ -1358,6 +1442,11 @@ export function useWorkflowExecution() {
// Mark current chat execution as superseded so its cleanup won't affect new executions // Mark current chat execution as superseded so its cleanup won't affect new executions
currentChatExecutionIdRef.current = null currentChatExecutionIdRef.current = null
// Mark all running entries as canceled in the terminal
if (activeWorkflowId) {
cancelRunningEntries(activeWorkflowId)
}
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx // Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
setIsExecuting(false) setIsExecuting(false)
setIsDebugging(false) setIsDebugging(false)
@@ -1374,8 +1463,334 @@ export function useWorkflowExecution() {
setIsExecuting, setIsExecuting,
setIsDebugging, setIsDebugging,
setActiveBlocks, setActiveBlocks,
activeWorkflowId,
cancelRunningEntries,
]) ])
/**
* Handles running workflow from a specific block using cached outputs
*/
const handleRunFromBlock = useCallback(
async (blockId: string, workflowId: string) => {
const snapshot = getLastExecutionSnapshot(workflowId)
const workflowEdges = useWorkflowStore.getState().edges
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
const isTriggerBlock = incomingEdges.length === 0
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
const isSourceSatisfied = (sourceId: string) => {
if (snapshot?.executedBlocks.includes(sourceId)) return true
// Check if source is a trigger (has no incoming edges itself)
const sourceIncomingEdges = workflowEdges.filter((edge) => edge.target === sourceId)
return sourceIncomingEdges.length === 0
}
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
if (!snapshot && !isTriggerBlock) {
logger.error('No execution snapshot available for run-from-block', { workflowId, blockId })
return
}
const dependenciesSatisfied =
isTriggerBlock || incomingEdges.every((edge) => isSourceSatisfied(edge.source))
if (!dependenciesSatisfied) {
logger.error('Upstream dependencies not satisfied for run-from-block', {
workflowId,
blockId,
})
return
}
// For trigger blocks, always use empty snapshot to prevent stale data from different
// execution paths from being resolved. For non-trigger blocks, use the existing snapshot.
const emptySnapshot: SerializableExecutionState = {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: { router: {}, condition: {} },
completedLoops: [],
activeExecutionPath: [],
}
const effectiveSnapshot: SerializableExecutionState = isTriggerBlock
? emptySnapshot
: snapshot || emptySnapshot
// Extract mock payload for trigger blocks
let workflowInput: any
if (isTriggerBlock) {
const workflowBlocks = useWorkflowStore.getState().blocks
const mergedStates = mergeSubblockState(workflowBlocks, workflowId)
const candidates = resolveStartCandidates(mergedStates, { execution: 'manual' })
const candidate = candidates.find((c) => c.blockId === blockId)
if (candidate) {
if (triggerNeedsMockPayload(candidate)) {
workflowInput = extractTriggerMockPayload(candidate)
} else if (
candidate.path === StartBlockPath.SPLIT_API ||
candidate.path === StartBlockPath.SPLIT_INPUT ||
candidate.path === StartBlockPath.UNIFIED
) {
const inputFormatValue = candidate.block.subBlocks?.inputFormat?.value
if (Array.isArray(inputFormatValue)) {
const testInput: Record<string, any> = {}
inputFormatValue.forEach((field: any) => {
if (field && typeof field === 'object' && field.name && field.value !== undefined) {
testInput[field.name] = coerceValue(field.type, field.value)
}
})
if (Object.keys(testInput).length > 0) {
workflowInput = testInput
}
}
}
} else {
// Fallback: block is trigger by position but not classified as start candidate
const block = mergedStates[blockId]
if (block) {
const blockConfig = getBlock(block.type)
const hasTriggers = blockConfig?.triggers?.available?.length
if (hasTriggers || block.triggerMode) {
workflowInput = extractTriggerMockPayload({
blockId,
block,
path: StartBlockPath.EXTERNAL_TRIGGER,
})
}
}
}
}
setIsExecuting(true)
const executionId = uuidv4()
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
const activeBlocksSet = new Set<string>()
try {
await executionStream.executeFromBlock({
workflowId,
startBlockId: blockId,
sourceSnapshot: effectiveSnapshot,
input: workflowInput,
callbacks: {
onBlockStarted: (data) => {
activeBlocksSet.add(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId)
incomingEdges.forEach((edge) => {
setEdgeRunStatus(edge.id, 'success')
})
},
onBlockCompleted: (data) => {
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'success')
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
executed: true,
executionTime: data.durationMs,
})
const isContainerBlock = data.blockType === 'loop' || data.blockType === 'parallel'
if (isContainerBlock) return
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
})
addConsole({
input: data.input || {},
output: data.output,
success: true,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
},
onBlockError: (data) => {
activeBlocksSet.delete(data.blockId)
setActiveBlocks(new Set(activeBlocksSet))
setBlockRunStatus(data.blockId, 'error')
const startedAt = new Date(Date.now() - data.durationMs).toISOString()
const endedAt = new Date().toISOString()
accumulatedBlockLogs.push({
blockId: data.blockId,
blockName: data.blockName || 'Unknown Block',
blockType: data.blockType || 'unknown',
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
})
addConsole({
input: data.input || {},
output: {},
success: false,
error: data.error,
durationMs: data.durationMs,
startedAt,
endedAt,
workflowId,
blockId: data.blockId,
executionId,
blockName: data.blockName,
blockType: data.blockType,
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
})
},
onExecutionCompleted: (data) => {
if (data.success) {
// Add the start block (trigger) to executed blocks
executedBlockIds.add(blockId)
const mergedBlockStates: Record<string, BlockState> = {
...effectiveSnapshot.blockStates,
}
for (const [bId, state] of accumulatedBlockStates) {
mergedBlockStates[bId] = state
}
const mergedExecutedBlocks = new Set([
...effectiveSnapshot.executedBlocks,
...executedBlockIds,
])
const updatedSnapshot: SerializableExecutionState = {
...effectiveSnapshot,
blockStates: mergedBlockStates,
executedBlocks: Array.from(mergedExecutedBlocks),
blockLogs: [...effectiveSnapshot.blockLogs, ...accumulatedBlockLogs],
activeExecutionPath: Array.from(mergedExecutedBlocks),
}
setLastExecutionSnapshot(workflowId, updatedSnapshot)
}
},
onExecutionError: (data) => {
const isWorkflowModified =
data.error?.includes('Block not found in workflow') ||
data.error?.includes('Upstream dependency not executed')
if (isWorkflowModified) {
clearLastExecutionSnapshot(workflowId)
addNotification({
level: 'error',
message:
'Workflow was modified. Run the workflow again to enable running from block.',
workflowId,
})
} else {
addNotification({
level: 'error',
message: data.error || 'Run from block failed',
workflowId,
})
}
},
},
})
} catch (error) {
if ((error as Error).name !== 'AbortError') {
logger.error('Run-from-block failed:', error)
}
} finally {
setIsExecuting(false)
setActiveBlocks(new Set())
}
},
[
getLastExecutionSnapshot,
setLastExecutionSnapshot,
clearLastExecutionSnapshot,
setIsExecuting,
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
addNotification,
addConsole,
executionStream,
]
)
/**
* Handles running workflow until a specific block (stops after that block completes)
*/
const handleRunUntilBlock = useCallback(
async (blockId: string, workflowId: string) => {
if (!workflowId || workflowId !== activeWorkflowId) {
logger.error('Invalid workflow ID for run-until-block', { workflowId, activeWorkflowId })
return
}
logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId })
setExecutionResult(null)
setIsExecuting(true)
const executionId = uuidv4()
try {
const result = await executeWorkflow(
undefined,
undefined,
executionId,
undefined,
'manual',
blockId
)
if (result && 'success' in result) {
setExecutionResult(result)
}
} catch (error) {
const errorResult = handleExecutionError(error, { executionId })
return errorResult
} finally {
setIsExecuting(false)
setIsDebugging(false)
setActiveBlocks(new Set())
}
},
[activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks]
)
return { return {
isExecuting, isExecuting,
isDebugging, isDebugging,
@@ -1386,5 +1801,7 @@ export function useWorkflowExecution() {
handleResumeDebug, handleResumeDebug,
handleCancelDebug, handleCancelDebug,
handleCancelExecution, handleCancelExecution,
handleRunFromBlock,
handleRunUntilBlock,
} }
} }

View File

@@ -47,6 +47,7 @@ import {
useCurrentWorkflow, useCurrentWorkflow,
useNodeUtilities, useNodeUtilities,
useShiftSelectionLock, useShiftSelectionLock,
useWorkflowExecution,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { import {
calculateContainerDimensions, calculateContainerDimensions,
@@ -302,6 +303,8 @@ const WorkflowContent = React.memo(() => {
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal) const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
const { handleRunFromBlock, handleRunUntilBlock } = useWorkflowExecution()
const snapToGridSize = useSnapToGridSize() const snapToGridSize = useSnapToGridSize()
const snapToGrid = snapToGridSize > 0 const snapToGrid = snapToGridSize > 0
@@ -733,13 +736,16 @@ const WorkflowContent = React.memo(() => {
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
) )
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore( const { activeBlockIds, pendingBlocks, isDebugging, isExecuting, getLastExecutionSnapshot } =
useShallow((state) => ({ useExecutionStore(
activeBlockIds: state.activeBlockIds, useShallow((state) => ({
pendingBlocks: state.pendingBlocks, activeBlockIds: state.activeBlockIds,
isDebugging: state.isDebugging, pendingBlocks: state.pendingBlocks,
})) isDebugging: state.isDebugging,
) isExecuting: state.isExecuting,
getLastExecutionSnapshot: state.getLastExecutionSnapshot,
}))
)
const [dragStartParentId, setDragStartParentId] = useState<string | null>(null) const [dragStartParentId, setDragStartParentId] = useState<string | null>(null)
@@ -1102,6 +1108,50 @@ const WorkflowContent = React.memo(() => {
} }
}, [contextMenuBlocks]) }, [contextMenuBlocks])
const handleContextRunFromBlock = useCallback(() => {
if (contextMenuBlocks.length !== 1) return
const blockId = contextMenuBlocks[0].id
handleRunFromBlock(blockId, workflowIdParam)
}, [contextMenuBlocks, workflowIdParam, handleRunFromBlock])
const handleContextRunUntilBlock = useCallback(() => {
if (contextMenuBlocks.length !== 1) return
const blockId = contextMenuBlocks[0].id
handleRunUntilBlock(blockId, workflowIdParam)
}, [contextMenuBlocks, workflowIdParam, handleRunUntilBlock])
const runFromBlockState = useMemo(() => {
if (contextMenuBlocks.length !== 1) {
return { canRun: false, reason: undefined }
}
const block = contextMenuBlocks[0]
const snapshot = getLastExecutionSnapshot(workflowIdParam)
const incomingEdges = edges.filter((edge) => edge.target === block.id)
const isTriggerBlock = incomingEdges.length === 0
// Check if each source block is either executed OR is a trigger block (triggers don't need prior execution)
const isSourceSatisfied = (sourceId: string) => {
if (snapshot?.executedBlocks.includes(sourceId)) return true
// Check if source is a trigger (has no incoming edges itself)
const sourceIncomingEdges = edges.filter((edge) => edge.target === sourceId)
return sourceIncomingEdges.length === 0
}
// Non-trigger blocks need a snapshot to exist (so upstream outputs are available)
const dependenciesSatisfied =
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
const isNoteBlock = block.type === 'note'
const isInsideSubflow =
block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
if (isInsideSubflow) return { canRun: false, reason: 'Cannot run from inside subflow' }
if (!dependenciesSatisfied) return { canRun: false, reason: 'Run upstream blocks first' }
if (isNoteBlock) return { canRun: false, reason: undefined }
if (isExecuting) return { canRun: false, reason: undefined }
return { canRun: true, reason: undefined }
}, [contextMenuBlocks, edges, workflowIdParam, getLastExecutionSnapshot, isExecuting])
const handleContextAddBlock = useCallback(() => { const handleContextAddBlock = useCallback(() => {
useSearchModalStore.getState().open() useSearchModalStore.getState().open()
}, []) }, [])
@@ -1750,37 +1800,32 @@ const WorkflowContent = React.memo(() => {
) )
}, [screenToFlowPosition, handleToolbarDrop]) }, [screenToFlowPosition, handleToolbarDrop])
/** /** Tracks blocks to pan to after diff updates. */
* Focus canvas on changed blocks when diff appears.
*/
const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null) const pendingZoomBlockIdsRef = useRef<Set<string> | null>(null)
const prevDiffReadyRef = useRef(false) const seenDiffBlocksRef = useRef<Set<string>>(new Set())
// Phase 1: When diff becomes ready, record which blocks we want to zoom to /** Queues newly changed blocks for viewport panning. */
// Phase 2 effect is located after displayNodes is defined (search for "Phase 2")
useEffect(() => { useEffect(() => {
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) { if (!isDiffReady || !diffAnalysis) {
// Diff just became ready - record blocks to zoom to
const changedBlockIds = [
...(diffAnalysis.new_blocks || []),
...(diffAnalysis.edited_blocks || []),
]
if (changedBlockIds.length > 0) {
pendingZoomBlockIdsRef.current = new Set(changedBlockIds)
} else {
// No specific blocks to focus on, fit all after a frame
pendingZoomBlockIdsRef.current = null
requestAnimationFrame(() => {
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else if (!isDiffReady && prevDiffReadyRef.current) {
// Diff was cleared (accepted/rejected) - cancel any pending zoom
pendingZoomBlockIdsRef.current = null pendingZoomBlockIdsRef.current = null
seenDiffBlocksRef.current.clear()
return
} }
prevDiffReadyRef.current = isDiffReady
}, [isDiffReady, diffAnalysis, fitViewToBounds]) const newBlocks = new Set<string>()
const allBlocks = [...(diffAnalysis.new_blocks || []), ...(diffAnalysis.edited_blocks || [])]
for (const id of allBlocks) {
if (!seenDiffBlocksRef.current.has(id)) {
newBlocks.add(id)
}
seenDiffBlocksRef.current.add(id)
}
if (newBlocks.size > 0) {
pendingZoomBlockIdsRef.current = newBlocks
}
}, [isDiffReady, diffAnalysis])
/** Displays trigger warning notifications. */ /** Displays trigger warning notifications. */
useEffect(() => { useEffect(() => {
@@ -2188,18 +2233,12 @@ const WorkflowContent = React.memo(() => {
}) })
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) }, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready /** Pans viewport to pending blocks once they have valid dimensions. */
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => { useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) { if (!pendingBlockIds || pendingBlockIds.size === 0) return
return
}
// Find the nodes we're waiting for
const pendingNodes = displayNodes.filter((node) => pendingBlockIds.has(node.id)) const pendingNodes = displayNodes.filter((node) => pendingBlockIds.has(node.id))
// Check if all expected nodes are present with valid dimensions
const allNodesReady = const allNodesReady =
pendingNodes.length === pendingBlockIds.size && pendingNodes.length === pendingBlockIds.size &&
pendingNodes.every( pendingNodes.every(
@@ -2211,16 +2250,20 @@ const WorkflowContent = React.memo(() => {
) )
if (allNodesReady) { if (allNodesReady) {
logger.info('Diff ready - focusing on changed blocks', { logger.info('Focusing on changed blocks', {
changedBlockIds: Array.from(pendingBlockIds), changedBlockIds: Array.from(pendingBlockIds),
foundNodes: pendingNodes.length, foundNodes: pendingNodes.length,
}) })
// Clear pending state before zooming to prevent re-triggers
pendingZoomBlockIdsRef.current = null pendingZoomBlockIdsRef.current = null
// Use requestAnimationFrame to ensure React has finished rendering
const nodesWithAbsolutePositions = pendingNodes.map((node) => ({
...node,
position: getNodeAbsolutePosition(node.id),
}))
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitViewToBounds({ fitViewToBounds({
nodes: pendingNodes, nodes: nodesWithAbsolutePositions,
duration: 600, duration: 600,
padding: 0.1, padding: 0.1,
minZoom: 0.5, minZoom: 0.5,
@@ -2228,7 +2271,7 @@ const WorkflowContent = React.memo(() => {
}) })
}) })
} }
}, [displayNodes, fitViewToBounds]) }, [displayNodes, fitViewToBounds, getNodeAbsolutePosition])
/** Handles ActionBar remove-from-subflow events. */ /** Handles ActionBar remove-from-subflow events. */
useEffect(() => { useEffect(() => {
@@ -2302,33 +2345,12 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
selectedIdsRef.current = null
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
if (!hasSelectionChange) return updated
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
return resolved
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
syncPanelWithSelection(selectedIds)
}
},
[blocks]
)
/** /**
* Updates container dimensions in displayNodes during drag. * Updates container dimensions in displayNodes during drag or keyboard movement.
* This allows live resizing of containers as their children are dragged.
*/ */
const updateContainerDimensionsDuringDrag = useCallback( const updateContainerDimensionsDuringMove = useCallback(
(draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => { (movedNodeId: string, movedNodePosition: { x: number; y: number }) => {
const parentId = blocks[draggedNodeId]?.data?.parentId const parentId = blocks[movedNodeId]?.data?.parentId
if (!parentId) return if (!parentId) return
setDisplayNodes((currentNodes) => { setDisplayNodes((currentNodes) => {
@@ -2336,7 +2358,7 @@ const WorkflowContent = React.memo(() => {
if (childNodes.length === 0) return currentNodes if (childNodes.length === 0) return currentNodes
const childPositions = childNodes.map((node) => { const childPositions = childNodes.map((node) => {
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position const nodePosition = node.id === movedNodeId ? movedNodePosition : node.position
const { width, height } = getBlockDimensions(node.id) const { width, height } = getBlockDimensions(node.id)
return { x: nodePosition.x, y: nodePosition.y, width, height } return { x: nodePosition.x, y: nodePosition.y, width, height }
}) })
@@ -2367,6 +2389,55 @@ const WorkflowContent = React.memo(() => {
[blocks, getBlockDimensions] [blocks, getBlockDimensions]
) )
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
selectedIdsRef.current = null
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
if (!hasSelectionChange) return updated
const resolved = resolveParentChildSelectionConflicts(updated, blocks)
selectedIdsRef.current = resolved.filter((node) => node.selected).map((node) => node.id)
return resolved
})
const selectedIds = selectedIdsRef.current as string[] | null
if (selectedIds !== null) {
syncPanelWithSelection(selectedIds)
}
// Handle position changes (e.g., from keyboard arrow key movement)
// Update container dimensions when child nodes are moved and persist to backend
// Only persist if not in a drag operation (drag-end is handled by onNodeDragStop)
const isInDragOperation =
getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0
const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> = []
for (const change of changes) {
if (
change.type === 'position' &&
!change.dragging &&
'position' in change &&
change.position
) {
updateContainerDimensionsDuringMove(change.id, change.position)
if (!isInDragOperation) {
keyboardPositionUpdates.push({ id: change.id, position: change.position })
}
}
}
// Persist keyboard movements to backend for collaboration sync
if (keyboardPositionUpdates.length > 0) {
collaborativeBatchUpdatePositions(keyboardPositionUpdates)
}
},
[
blocks,
updateContainerDimensionsDuringMove,
collaborativeBatchUpdatePositions,
getDragStartPosition,
]
)
/** /**
* Effect to resize loops when nodes change (add/remove/position change). * Effect to resize loops when nodes change (add/remove/position change).
* Runs on structural changes only - not during drag (position-only changes). * Runs on structural changes only - not during drag (position-only changes).
@@ -2611,7 +2682,7 @@ const WorkflowContent = React.memo(() => {
// If the node is inside a container, update container dimensions during drag // If the node is inside a container, update container dimensions during drag
if (currentParentId) { if (currentParentId) {
updateContainerDimensionsDuringDrag(node.id, node.position) updateContainerDimensionsDuringMove(node.id, node.position)
} }
// Check if this is a starter block - starter blocks should never be in containers // Check if this is a starter block - starter blocks should never be in containers
@@ -2728,7 +2799,7 @@ const WorkflowContent = React.memo(() => {
blocks, blocks,
getNodeAbsolutePosition, getNodeAbsolutePosition,
getNodeDepth, getNodeDepth,
updateContainerDimensionsDuringDrag, updateContainerDimensionsDuringMove,
highlightContainerNode, highlightContainerNode,
] ]
) )
@@ -3418,11 +3489,19 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow} onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor} onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename} onRename={handleContextRename}
onRunFromBlock={handleContextRunFromBlock}
onRunUntilBlock={handleContextRunUntilBlock}
hasClipboard={hasClipboard()} hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some( showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)} )}
canRunFromBlock={runFromBlockState.canRun}
disableEdit={!effectivePermissions.canEdit} disableEdit={!effectivePermissions.canEdit}
isExecuting={isExecuting}
isPositionalTrigger={
contextMenuBlocks.length === 1 &&
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
}
/> />
<CanvasMenu <CanvasMenu

View File

@@ -4,11 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check,
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
ChevronUp, ChevronUp,
Clipboard,
ExternalLink, ExternalLink,
Maximize2, Maximize2,
RepeatIcon, RepeatIcon,
Search,
SplitIcon, SplitIcon,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -34,6 +37,7 @@ import {
isSubBlockFeatureEnabled, isSubBlockFeatureEnabled,
isSubBlockVisibleForMode, isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility' } from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu' import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow' import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
@@ -690,6 +694,7 @@ interface ExecutionData {
output?: unknown output?: unknown
status?: string status?: string
durationMs?: number durationMs?: number
childWorkflowSnapshotId?: string
} }
interface WorkflowVariable { interface WorkflowVariable {
@@ -714,6 +719,8 @@ interface PreviewEditorProps {
parallels?: Record<string, Parallel> parallels?: Record<string, Parallel>
/** When true, shows "Not Executed" badge if no executionData is provided */ /** When true, shows "Not Executed" badge if no executionData is provided */
isExecutionMode?: boolean isExecutionMode?: boolean
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Optional close handler - if not provided, no close button is shown */ /** Optional close handler - if not provided, no close button is shown */
onClose?: () => void onClose?: () => void
/** Callback to drill down into a nested workflow block */ /** Callback to drill down into a nested workflow block */
@@ -739,6 +746,7 @@ function PreviewEditorContent({
loops, loops,
parallels, parallels,
isExecutionMode = false, isExecutionMode = false,
childWorkflowSnapshots,
onClose, onClose,
onDrillDown, onDrillDown,
}: PreviewEditorProps) { }: PreviewEditorProps) {
@@ -768,17 +776,35 @@ function PreviewEditorContent({
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState( const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined childWorkflowId ?? undefined
) )
const childWorkflowSnapshotId = executionData?.childWorkflowSnapshotId
const childWorkflowSnapshotState = childWorkflowSnapshotId
? childWorkflowSnapshots?.[childWorkflowSnapshotId]
: undefined
const resolvedChildWorkflowState = isExecutionMode
? childWorkflowSnapshotState
: childWorkflowState
const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow
const isMissingChildWorkflow =
Boolean(childWorkflowId) && !resolvedIsLoadingChildWorkflow && !resolvedChildWorkflowState
/** Drills down into the child workflow or opens it in a new tab */ /** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => { const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId || !childWorkflowState) return if (!childWorkflowId) return
if (isExecutionMode && onDrillDown) { if (isExecutionMode && onDrillDown) {
onDrillDown(block.id, childWorkflowState) if (!childWorkflowSnapshotState) return
onDrillDown(block.id, childWorkflowSnapshotState)
} else if (workspaceId) { } else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer') window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
} }
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId]) }, [
childWorkflowId,
childWorkflowSnapshotState,
isExecutionMode,
onDrillDown,
block.id,
workspaceId,
])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null) const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -813,6 +839,13 @@ function PreviewEditorContent({
} = useContextMenu() } = useContextMenu()
const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false }) const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false })
const [copiedSection, setCopiedSection] = useState<'input' | 'output' | null>(null)
const handleCopySection = useCallback((content: string, section: 'input' | 'output') => {
navigator.clipboard.writeText(content)
setCopiedSection(section)
setTimeout(() => setCopiedSection(null), 1500)
}, [])
const openContextMenu = useCallback( const openContextMenu = useCallback(
(e: React.MouseEvent, content: string, copyOnly: boolean) => { (e: React.MouseEvent, content: string, copyOnly: boolean) => {
@@ -862,9 +895,6 @@ function PreviewEditorContent({
} }
}, [contextMenuData.content]) }, [contextMenuData.content])
/**
* Handles mouse down event on the resize handle to initiate resizing
*/
const handleConnectionsResizeMouseDown = useCallback( const handleConnectionsResizeMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
setIsResizing(true) setIsResizing(true)
@@ -874,18 +904,12 @@ function PreviewEditorContent({
[connectionsHeight] [connectionsHeight]
) )
/**
* Toggle connections collapsed state
*/
const toggleConnectionsCollapsed = useCallback(() => { const toggleConnectionsCollapsed = useCallback(() => {
setConnectionsHeight((prev) => setConnectionsHeight((prev) =>
prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT
) )
}, []) }, [])
/**
* Sets up resize event listeners during resize operations
*/
useEffect(() => { useEffect(() => {
if (!isResizing) return if (!isResizing) return
@@ -1141,15 +1165,17 @@ function PreviewEditorContent({
<div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'> <div className='relative flex h-full w-80 flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--surface-1)]'>
{/* Header - styled like editor */} {/* Header - styled like editor */}
<div className='mx-[-1px] flex flex-shrink-0 items-center gap-[8px] rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-[12px] py-[6px]'> <div className='mx-[-1px] flex flex-shrink-0 items-center gap-[8px] rounded-b-[4px] border-[var(--border)] border-x border-b bg-[var(--surface-4)] px-[12px] py-[6px]'>
<div {block.type !== 'note' && (
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]' <div
style={{ backgroundColor: blockConfig.bgColor }} className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
> style={{ backgroundColor: blockConfig.bgColor }}
<IconComponent >
icon={blockConfig.icon} <IconComponent
className='h-[12px] w-[12px] text-[var(--white)]' icon={blockConfig.icon}
/> className='h-[12px] w-[12px] text-[var(--white)]'
</div> />
</div>
)}
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'> <span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{block.name || blockConfig.name} {block.name || blockConfig.name}
</span> </span>
@@ -1203,7 +1229,11 @@ function PreviewEditorContent({
} }
emptyMessage='No input data' emptyMessage='No input data'
> >
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}> <div
onContextMenu={handleExecutionContextMenu}
ref={contentRef}
className='relative'
>
<Code.Viewer <Code.Viewer
code={formatValueAsJson(executionData.input)} code={formatValueAsJson(executionData.input)}
language='json' language='json'
@@ -1213,6 +1243,49 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
/> />
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.input), 'input')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'input' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'input' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -1229,7 +1302,7 @@ function PreviewEditorContent({
emptyMessage='No output data' emptyMessage='No output data'
isError={executionData.status === 'error'} isError={executionData.status === 'error'}
> >
<div onContextMenu={handleExecutionContextMenu}> <div onContextMenu={handleExecutionContextMenu} className='relative'>
<Code.Viewer <Code.Viewer
code={formatValueAsJson(executionData.output)} code={formatValueAsJson(executionData.output)}
language='json' language='json'
@@ -1242,6 +1315,49 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex} currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange} onMatchCountChange={handleMatchCountChange}
/> />
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.output), 'output')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'output' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'output' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -1254,7 +1370,7 @@ function PreviewEditorContent({
Workflow Preview Workflow Preview
</div> </div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'> <div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? ( {resolvedIsLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div <div
className='h-[18px] w-[18px] animate-spin rounded-full' className='h-[18px] w-[18px] animate-spin rounded-full'
@@ -1267,11 +1383,11 @@ function PreviewEditorContent({
}} }}
/> />
</div> </div>
) : childWorkflowState ? ( ) : resolvedChildWorkflowState ? (
<> <>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'> <div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow <PreviewWorkflow
workflowState={childWorkflowState} workflowState={resolvedChildWorkflowState}
height={160} height={160}
width='100%' width='100%'
isPannable={true} isPannable={true}
@@ -1303,7 +1419,9 @@ function PreviewEditorContent({
) : ( ) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'> <div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'> <span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview {isMissingChildWorkflow
? DELETED_WORKFLOW_LABEL
: 'Unable to load preview'}
</span> </span>
</div> </div>
)} )}

View File

@@ -9,6 +9,7 @@ import {
isSubBlockFeatureEnabled, isSubBlockFeatureEnabled,
isSubBlockVisibleForMode, isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility' } from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
@@ -112,7 +113,7 @@ function resolveWorkflowName(
if (!rawValue || typeof rawValue !== 'string') return null if (!rawValue || typeof rawValue !== 'string') return null
const workflowMap = useWorkflowRegistry.getState().workflows const workflowMap = useWorkflowRegistry.getState().workflows
return workflowMap[rawValue]?.name ?? null return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
} }
/** /**
@@ -411,8 +412,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
const IconComponent = blockConfig.icon const IconComponent = blockConfig.icon
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
const isNoteBlock = type === 'note'
const shouldShowDefaultHandles = !isStarterOrTrigger const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock
const hasSubBlocks = visibleSubBlocks.length > 0 const hasSubBlocks = visibleSubBlocks.length > 0
const hasContentBelowHeader = const hasContentBelowHeader =
type === 'condition' type === 'condition'
@@ -574,8 +576,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</> </>
)} )}
{/* Source and error handles for non-condition/router blocks */} {/* Source and error handles for non-condition/router/note blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && ( {type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
<> <>
<Handle <Handle
type='source' type='source'

View File

@@ -3,6 +3,8 @@
import { memo } from 'react' import { memo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react' import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow' import { Handle, type NodeProps, Position } from 'reactflow'
import { Badge } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
/** Execution status for subflows in preview mode */ /** Execution status for subflows in preview mode */
@@ -13,6 +15,8 @@ interface WorkflowPreviewSubflowData {
width?: number width?: number
height?: number height?: number
kind: 'loop' | 'parallel' kind: 'loop' | 'parallel'
/** Whether this subflow is enabled */
enabled?: boolean
/** Whether this subflow is selected in preview mode */ /** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean isPreviewSelected?: boolean
/** Execution status for highlighting the subflow container */ /** Execution status for highlighting the subflow container */
@@ -27,7 +31,15 @@ interface WorkflowPreviewSubflowData {
* or interactive features. * or interactive features.
*/ */
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) { function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data const {
name,
width = 500,
height = 300,
kind,
enabled = true,
isPreviewSelected = false,
executionStatus,
} = data
const isLoop = kind === 'loop' const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon const BlockIcon = isLoop ? RepeatIcon : SplitIcon
@@ -84,14 +96,21 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
<div className='flex min-w-0 flex-1 items-center gap-[10px]'> <div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div <div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]' className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }} style={{ backgroundColor: enabled ? blockIconBg : 'var(--surface-4)' }}
> >
<BlockIcon className='h-[16px] w-[16px] text-white' /> <BlockIcon className='h-[16px] w-[16px] text-white' />
</div> </div>
<span className='font-medium text-[16px]' title={blockName}> <span
className={cn(
'truncate font-medium text-[16px]',
!enabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName} {blockName}
</span> </span>
</div> </div>
{!enabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div> </div>
{/* Content area - matches workflow structure */} {/* Content area - matches workflow structure */}

View File

@@ -23,11 +23,7 @@ import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/type
const logger = createLogger('PreviewWorkflow') const logger = createLogger('PreviewWorkflow')
/** /** Gets block dimensions, using stored values or defaults. */
* Gets block dimensions for preview purposes.
* For containers, uses stored dimensions or defaults.
* For regular blocks, uses stored height or estimates based on type.
*/
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') { if (block.type === 'loop' || block.type === 'parallel') {
return { return {
@@ -50,10 +46,7 @@ function getPreviewBlockDimensions(block: BlockState): { width: number; height:
return estimateBlockDimensions(block.type) return estimateBlockDimensions(block.type)
} }
/** /** Calculates container dimensions from child block positions. */
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
*/
function calculateContainerDimensions( function calculateContainerDimensions(
containerId: string, containerId: string,
blocks: Record<string, BlockState> blocks: Record<string, BlockState>
@@ -91,12 +84,7 @@ function calculateContainerDimensions(
return { width, height } return { width, height }
} }
/** /** Finds the leftmost block ID, excluding subflow containers. */
* Finds the leftmost block ID from a workflow state.
* Excludes subflow containers (loop/parallel) from consideration.
* @param workflowState - The workflow state to search
* @returns The ID of the leftmost block, or null if no blocks exist
*/
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null { export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null if (!workflowState?.blocks) return null
@@ -118,7 +106,7 @@ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefin
/** Execution status for edges/nodes in the preview */ /** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed' type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Calculates absolute position for blocks, handling nested subflows */ /** Calculates absolute position, handling nested subflows. */
function calculateAbsolutePosition( function calculateAbsolutePosition(
block: BlockState, block: BlockState,
blocks: Record<string, BlockState> blocks: Record<string, BlockState>
@@ -164,10 +152,7 @@ interface PreviewWorkflowProps {
lightweight?: boolean lightweight?: boolean
} }
/** /** Preview node types using minimal, hook-free components. */
* Preview node types using minimal components without hooks or store subscriptions.
* This prevents interaction issues while allowing canvas panning and node clicking.
*/
const previewNodeTypes: NodeTypes = { const previewNodeTypes: NodeTypes = {
workflowBlock: PreviewBlock, workflowBlock: PreviewBlock,
noteBlock: PreviewBlock, noteBlock: PreviewBlock,
@@ -185,11 +170,7 @@ interface FitViewOnChangeProps {
containerRef: React.RefObject<HTMLDivElement | null> containerRef: React.RefObject<HTMLDivElement | null>
} }
/** /** Calls fitView on node changes or container resize. */
* Helper component that calls fitView when the set of nodes changes or when the container resizes.
* Only triggers on actual node additions/removals, not on selection changes.
* Must be rendered inside ReactFlowProvider.
*/
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) { function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null) const lastNodeIdsRef = useRef<string | null>(null)
@@ -229,16 +210,7 @@ function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeP
return null return null
} }
/** /** Readonly workflow visualization with execution status highlighting. */
* Readonly workflow component for visualizing workflow state.
* Renders blocks, subflows, and edges with execution status highlighting.
*
* @remarks
* - Supports panning and node click interactions
* - Shows execution path via green edges for successful paths
* - Error edges display red by default, green when error path was taken
* - Fits view automatically when nodes change or container resizes
*/
export function PreviewWorkflow({ export function PreviewWorkflow({
workflowState, workflowState,
className, className,
@@ -300,49 +272,58 @@ export function PreviewWorkflow({
return map return map
}, [workflowState.blocks, isValidWorkflowState]) }, [workflowState.blocks, isValidWorkflowState])
/** Derives subflow execution status from child blocks */ /** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */
const blockExecutionMap = useMemo(() => {
if (!executedBlocks) return new Map<string, { status: string }>()
const map = new Map<string, { status: string }>()
for (const [key, value] of Object.entries(executedBlocks)) {
// Extract base ID (remove iteration suffix like ₍0₎)
const baseId = key.includes('₍') ? key.split('₍')[0] : key
// Keep first match or error status (error takes precedence)
const existing = map.get(baseId)
if (!existing || value.status === 'error') {
map.set(baseId, value)
}
}
return map
}, [executedBlocks])
/** Derives subflow status from children. Error takes precedence. */
const getSubflowExecutionStatus = useMemo(() => { const getSubflowExecutionStatus = useMemo(() => {
return (subflowId: string): ExecutionStatus | undefined => { return (subflowId: string): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined
const childIds = subflowChildrenMap.get(subflowId) const childIds = subflowChildrenMap.get(subflowId)
if (!childIds?.length) return undefined if (!childIds?.length) return undefined
const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean) const executedChildren = childIds
if (childStatuses.length === 0) return undefined .map((id) => blockExecutionMap.get(id))
.filter((status): status is { status: string } => Boolean(status))
if (childStatuses.some((s) => s.status === 'error')) return 'error' if (executedChildren.length === 0) return undefined
if (childStatuses.some((s) => s.status === 'success')) return 'success' if (executedChildren.some((s) => s.status === 'error')) return 'error'
return 'not-executed' return 'success'
} }
}, [executedBlocks, subflowChildrenMap]) }, [subflowChildrenMap, blockExecutionMap])
/** Gets execution status for any block, deriving subflow status from children */ /** Gets block status. Subflows derive status from children. */
const getBlockExecutionStatus = useMemo(() => { const getBlockExecutionStatus = useMemo(() => {
return (blockId: string): { status: string; executed: boolean } | undefined => { return (blockId: string): { status: string; executed: boolean } | undefined => {
if (!executedBlocks) return undefined const directStatus = blockExecutionMap.get(blockId)
const directStatus = executedBlocks[blockId]
if (directStatus) { if (directStatus) {
return { status: directStatus.status, executed: true } return { status: directStatus.status, executed: true }
} }
const block = workflowState.blocks?.[blockId] const block = workflowState.blocks?.[blockId]
if (block && (block.type === 'loop' || block.type === 'parallel')) { if (block?.type === 'loop' || block?.type === 'parallel') {
const subflowStatus = getSubflowExecutionStatus(blockId) const subflowStatus = getSubflowExecutionStatus(blockId)
if (subflowStatus) { if (subflowStatus) {
return { status: subflowStatus, executed: true } return { status: subflowStatus, executed: true }
} }
const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
return { status: 'not-executed', executed: true }
}
} }
return undefined return undefined
} }
}, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus]) }, [workflowState.blocks, getSubflowExecutionStatus, blockExecutionMap])
const edgesStructure = useMemo(() => { const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' } if (!isValidWorkflowState) return { count: 0, ids: '' }
@@ -380,6 +361,7 @@ export function PreviewWorkflow({
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
kind: block.type as 'loop' | 'parallel', kind: block.type as 'loop' | 'parallel',
enabled: block.enabled ?? true,
isPreviewSelected: isSelected, isPreviewSelected: isSelected,
executionStatus: subflowExecutionStatus, executionStatus: subflowExecutionStatus,
lightweight, lightweight,
@@ -406,9 +388,11 @@ export function PreviewWorkflow({
} }
} }
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({ nodeArray.push({
id: blockId, id: blockId,
type: 'workflowBlock', type: nodeType,
position: absolutePosition, position: absolutePosition,
draggable: false, draggable: false,
zIndex: block.data?.parentId ? 10 : undefined, zIndex: block.data?.parentId ? 10 : undefined,
@@ -442,48 +426,29 @@ export function PreviewWorkflow({
const edges: Edge[] = useMemo(() => { const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return [] if (!isValidWorkflowState) return []
/** /** Edge is green if target executed and source condition met by edge type. */
* Determines edge execution status for visualization.
* Error edges turn green when taken (source errored, target executed).
* Normal edges turn green when both source succeeded and target executed.
*/
const getEdgeExecutionStatus = (edge: { const getEdgeExecutionStatus = (edge: {
source: string source: string
target: string target: string
sourceHandle?: string | null sourceHandle?: string | null
}): ExecutionStatus | undefined => { }): ExecutionStatus | undefined => {
if (!executedBlocks) return undefined if (blockExecutionMap.size === 0) return undefined
const targetStatus = getBlockExecutionStatus(edge.target)
if (!targetStatus?.executed) return 'not-executed'
const sourceStatus = getBlockExecutionStatus(edge.source) const sourceStatus = getBlockExecutionStatus(edge.source)
const targetStatus = getBlockExecutionStatus(edge.target) const { sourceHandle } = edge
const isErrorEdge = edge.sourceHandle === 'error'
if (isErrorEdge) { if (sourceHandle === 'error') {
return sourceStatus?.status === 'error' && targetStatus?.executed return sourceStatus?.status === 'error' ? 'success' : 'not-executed'
? 'success'
: 'not-executed'
} }
const isSubflowStartEdge = if (sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source') {
edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
if (isSubflowStartEdge) {
const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
const incomingSucceeded = incomingEdge
? executedBlocks[incomingEdge.source]?.status === 'success'
: false
return incomingSucceeded ? 'success' : 'not-executed'
}
const targetBlock = workflowState.blocks?.[edge.target]
const targetIsSubflow =
targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
return 'success' return 'success'
} }
return 'not-executed' return sourceStatus?.status === 'success' ? 'success' : 'not-executed'
} }
return (workflowState.edges || []).map((edge) => { return (workflowState.edges || []).map((edge) => {
@@ -505,9 +470,8 @@ export function PreviewWorkflow({
}, [ }, [
edgesStructure, edgesStructure,
workflowState.edges, workflowState.edges,
workflowState.blocks,
isValidWorkflowState, isValidWorkflowState,
executedBlocks, blockExecutionMap,
getBlockExecutionStatus, getBlockExecutionStatus,
]) ])

View File

@@ -19,6 +19,8 @@ interface TraceSpan {
status?: string status?: string
duration?: number duration?: number
children?: TraceSpan[] children?: TraceSpan[]
childWorkflowSnapshotId?: string
childWorkflowId?: string
} }
interface BlockExecutionData { interface BlockExecutionData {
@@ -28,6 +30,7 @@ interface BlockExecutionData {
durationMs: number durationMs: number
/** Child trace spans for nested workflow blocks */ /** Child trace spans for nested workflow blocks */
children?: TraceSpan[] children?: TraceSpan[]
childWorkflowSnapshotId?: string
} }
/** Represents a level in the workflow navigation stack */ /** Represents a level in the workflow navigation stack */
@@ -35,12 +38,13 @@ interface WorkflowStackEntry {
workflowState: WorkflowState workflowState: WorkflowState
traceSpans: TraceSpan[] traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData> blockExecutions: Record<string, BlockExecutionData>
workflowName: string
} }
/** /**
* Extracts child trace spans from a workflow block's execution data. * Extracts child trace spans from a workflow block's execution data.
* Checks both the `children` property (where trace span processing moves them) * Checks `children` property (where trace-spans processing puts them),
* and the legacy `output.childTraceSpans` for compatibility. * with fallback to `output.childTraceSpans` for old stored logs.
*/ */
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] { function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return [] if (!blockExecution) return []
@@ -49,6 +53,7 @@ function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined):
return blockExecution.children return blockExecution.children
} }
// Backward compat: old stored logs may have childTraceSpans in output
if (blockExecution.output && typeof blockExecution.output === 'object') { if (blockExecution.output && typeof blockExecution.output === 'object') {
const output = blockExecution.output as Record<string, unknown> const output = blockExecution.output as Record<string, unknown>
if (Array.isArray(output.childTraceSpans)) { if (Array.isArray(output.childTraceSpans)) {
@@ -88,6 +93,7 @@ export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockEx
status: span.status || 'unknown', status: span.status || 'unknown',
durationMs: span.duration || 0, durationMs: span.duration || 0,
children: span.children, children: span.children,
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
} }
} }
} }
@@ -102,6 +108,8 @@ interface PreviewProps {
traceSpans?: TraceSpan[] traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */ /** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData> blockExecutions?: Record<string, BlockExecutionData>
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Additional CSS class names */ /** Additional CSS class names */
className?: string className?: string
/** Height of the component */ /** Height of the component */
@@ -134,6 +142,7 @@ export function Preview({
workflowState: rootWorkflowState, workflowState: rootWorkflowState,
traceSpans: rootTraceSpans, traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions, blockExecutions: providedBlockExecutions,
childWorkflowSnapshots,
className, className,
height = '100%', height = '100%',
width = '100%', width = '100%',
@@ -143,7 +152,6 @@ export function Preview({
initialSelectedBlockId, initialSelectedBlockId,
autoSelectLeftmost = true, autoSelectLeftmost = true,
}: PreviewProps) { }: PreviewProps) {
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => { const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) { if (autoSelectLeftmost) {
@@ -152,17 +160,14 @@ export function Preview({
return null return null
}) })
/** Stack for nested workflow navigation. Empty means we're at the root level. */
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([]) const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
/** Block executions for the root level */
const rootBlockExecutions = useMemo(() => { const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {} if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans) return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans]) }, [providedBlockExecutions, rootTraceSpans])
/** Current block executions - either from stack or root */
const blockExecutions = useMemo(() => { const blockExecutions = useMemo(() => {
if (workflowStack.length > 0) { if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].blockExecutions return workflowStack[workflowStack.length - 1].blockExecutions
@@ -170,7 +175,6 @@ export function Preview({
return rootBlockExecutions return rootBlockExecutions
}, [workflowStack, rootBlockExecutions]) }, [workflowStack, rootBlockExecutions])
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => { const workflowState = useMemo(() => {
if (workflowStack.length > 0) { if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState return workflowStack[workflowStack.length - 1].workflowState
@@ -178,41 +182,39 @@ export function Preview({
return rootWorkflowState return rootWorkflowState
}, [workflowStack, rootWorkflowState]) }, [workflowStack, rootWorkflowState])
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => { const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0 return Object.keys(blockExecutions).length > 0
}, [blockExecutions]) }, [blockExecutions])
/** Handler to drill down into a nested workflow block */
const handleDrillDown = useCallback( const handleDrillDown = useCallback(
(blockId: string, childWorkflowState: WorkflowState) => { (blockId: string, childWorkflowState: WorkflowState) => {
const blockExecution = blockExecutions[blockId] const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution) const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans) const childBlockExecutions = buildBlockExecutions(childTraceSpans)
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
setWorkflowStack((prev) => [ setWorkflowStack((prev) => [
...prev, ...prev,
{ {
workflowState: childWorkflowState, workflowState: childWorkflowState,
traceSpans: childTraceSpans, traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions, blockExecutions: childBlockExecutions,
workflowName,
}, },
]) ])
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState) const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId) setPinnedBlockId(leftmostId)
}, },
[blockExecutions] [blockExecutions]
) )
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1)) setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null) setPinnedBlockId(null)
}, []) }, [])
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => { const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId) setPinnedBlockId(blockId)
}, []) }, [])
@@ -231,6 +233,8 @@ export function Preview({
const isNested = workflowStack.length > 0 const isNested = workflowStack.length > 0
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
return ( return (
<div <div
style={{ height, width }} style={{ height, width }}
@@ -241,20 +245,27 @@ export function Preview({
)} )}
> >
{isNested && ( {isNested && (
<div className='absolute top-[12px] left-[12px] z-20'> <div className='absolute top-[12px] left-[12px] z-20 flex items-center gap-[6px]'>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <Button
variant='ghost' variant='ghost'
onClick={handleGoBack} onClick={handleGoBack}
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]' className='flex h-[28px] items-center gap-[5px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] text-[var(--text-secondary)] shadow-sm hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)]'
> >
<ArrowLeft className='h-[13px] w-[13px]' /> <ArrowLeft className='h-[12px] w-[12px]' />
<span className='font-medium text-[13px]'>Back</span> <span className='font-medium text-[12px]'>Back</span>
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content> <Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{currentWorkflowName && (
<div className='flex h-[28px] max-w-[200px] items-center rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] shadow-sm'>
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{currentWorkflowName}
</span>
</div>
)}
</div> </div>
)} )}
@@ -283,6 +294,7 @@ export function Preview({
loops={workflowState.loops} loops={workflowState.loops}
parallels={workflowState.parallels} parallels={workflowState.parallels}
isExecutionMode={isExecutionMode} isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={handleEditorClose} onClose={handleEditorClose}
onDrillDown={handleDrillDown} onDrillDown={handleDrillDown}
/> />

View File

@@ -2,19 +2,38 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Command } from 'cmdk' import { Command } from 'cmdk'
import { BookOpen, Layout, ScrollText } from 'lucide-react' import { Database, HelpCircle, Layout, Settings } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useBrandConfig } from '@/lib/branding/branding' import { Library } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSearchModalStore } from '@/stores/modals/search/store' import { useSearchModalStore } from '@/stores/modals/search/store'
import type { import type {
SearchBlockItem, SearchBlockItem,
SearchDocItem, SearchDocItem,
SearchToolOperationItem, SearchToolOperationItem,
} from '@/stores/modals/search/types' } from '@/stores/modals/search/types'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
function customFilter(value: string, search: string): number {
const searchLower = search.toLowerCase()
const valueLower = value.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.9
if (valueLower.includes(searchLower)) return 0.7
const searchWords = searchLower.split(/\s+/).filter(Boolean)
if (searchWords.length > 1) {
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
if (allWordsMatch) return 0.5
}
return 0
}
interface SearchModalProps { interface SearchModalProps {
open: boolean open: boolean
@@ -43,8 +62,10 @@ interface PageItem {
id: string id: string
name: string name: string
icon: React.ComponentType<{ className?: string }> icon: React.ComponentType<{ className?: string }>
href: string href?: string
onClick?: () => void
shortcut?: string shortcut?: string
hidden?: boolean
} }
export function SearchModal({ export function SearchModal({
@@ -57,10 +78,10 @@ export function SearchModal({
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
const brand = useBrandConfig()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [search, setSearch] = useState('')
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
@@ -70,42 +91,71 @@ export function SearchModal({
(state) => state.data (state) => state.data
) )
const openHelpModal = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-help-modal'))
}, [])
const pages = useMemo( const pages = useMemo(
(): PageItem[] => [ (): PageItem[] =>
{ [
id: 'logs', {
name: 'Logs', id: 'logs',
icon: ScrollText, name: 'Logs',
href: `/workspace/${workspaceId}/logs`, icon: Library,
shortcut: '⌘⇧L', href: `/workspace/${workspaceId}/logs`,
}, shortcut: '⌘⇧L',
{ },
id: 'templates', {
name: 'Templates', id: 'templates',
icon: Layout, name: 'Templates',
href: `/workspace/${workspaceId}/templates`, icon: Layout,
}, href: `/workspace/${workspaceId}/templates`,
{ hidden: permissionConfig.hideTemplates,
id: 'docs', },
name: 'Docs', {
icon: BookOpen, id: 'knowledge-base',
href: brand.documentationUrl || 'https://docs.sim.ai/', name: 'Knowledge Base',
}, icon: Database,
], href: `/workspace/${workspaceId}/knowledge`,
[workspaceId, brand.documentationUrl] hidden: permissionConfig.hideKnowledgeBaseTab,
},
{
id: 'help',
name: 'Help',
icon: HelpCircle,
onClick: openHelpModal,
},
{
id: 'settings',
name: 'Settings',
icon: Settings,
onClick: openSettingsModal,
},
].filter((page) => !page.hidden),
[
workspaceId,
openHelpModal,
openSettingsModal,
permissionConfig.hideTemplates,
permissionConfig.hideKnowledgeBaseTab,
]
) )
useEffect(() => { useEffect(() => {
if (open) { if (open && inputRef.current) {
setSearch('') const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
requestAnimationFrame(() => { window.HTMLInputElement.prototype,
inputRef.current?.focus() 'value'
}) )?.set
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, '')
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
}
inputRef.current.focus()
} }
}, [open]) }, [open])
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(() => {
setSearch(value)
requestAnimationFrame(() => { requestAnimationFrame(() => {
const list = document.querySelector('[cmdk-list]') const list = document.querySelector('[cmdk-list]')
if (list) { if (list) {
@@ -179,10 +229,14 @@ export function SearchModal({
const handlePageSelect = useCallback( const handlePageSelect = useCallback(
(page: PageItem) => { (page: PageItem) => {
if (page.href.startsWith('http')) { if (page.onClick) {
window.open(page.href, '_blank', 'noopener,noreferrer') page.onClick()
} else { } else if (page.href) {
router.push(page.href) if (page.href.startsWith('http')) {
window.open(page.href, '_blank', 'noopener,noreferrer')
} else {
router.push(page.href)
}
} }
onOpenChange(false) onOpenChange(false)
}, },
@@ -203,28 +257,6 @@ export function SearchModal({
const showToolOperations = isOnWorkflowPage && toolOperations.length > 0 const showToolOperations = isOnWorkflowPage && toolOperations.length > 0
const showDocs = isOnWorkflowPage && docs.length > 0 const showDocs = isOnWorkflowPage && docs.length > 0
const customFilter = useCallback((value: string, search: string, keywords?: string[]) => {
const searchLower = search.toLowerCase()
const valueLower = value.toLowerCase()
if (valueLower === searchLower) return 1
if (valueLower.startsWith(searchLower)) return 0.8
if (valueLower.includes(searchLower)) return 0.6
const searchWords = searchLower.split(/\s+/).filter(Boolean)
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
if (allWordsMatch && searchWords.length > 0) return 0.4
if (keywords?.length) {
const keywordsLower = keywords.join(' ').toLowerCase()
if (keywordsLower.includes(searchLower)) return 0.3
const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word))
if (keywordWordsMatch && searchWords.length > 0) return 0.2
}
return 0
}, [])
if (!mounted) return null if (!mounted) return null
return createPortal( return createPortal(
@@ -253,7 +285,6 @@ export function SearchModal({
<Command label='Search' filter={customFilter}> <Command label='Search' filter={customFilter}>
<Command.Input <Command.Input
ref={inputRef} ref={inputRef}
value={search}
autoFocus autoFocus
onValueChange={handleSearchChange} onValueChange={handleSearchChange}
placeholder='Search anything...' placeholder='Search anything...'
@@ -269,8 +300,7 @@ export function SearchModal({
{blocks.map((block) => ( {blocks.map((block) => (
<CommandItem <CommandItem
key={block.id} key={block.id}
value={block.name} value={`${block.name} block-${block.id}`}
keywords={[block.description]}
onSelect={() => handleBlockSelect(block, 'block')} onSelect={() => handleBlockSelect(block, 'block')}
icon={block.icon} icon={block.icon}
bgColor={block.bgColor} bgColor={block.bgColor}
@@ -287,8 +317,7 @@ export function SearchModal({
{tools.map((tool) => ( {tools.map((tool) => (
<CommandItem <CommandItem
key={tool.id} key={tool.id}
value={tool.name} value={`${tool.name} tool-${tool.id}`}
keywords={[tool.description]}
onSelect={() => handleBlockSelect(tool, 'tool')} onSelect={() => handleBlockSelect(tool, 'tool')}
icon={tool.icon} icon={tool.icon}
bgColor={tool.bgColor} bgColor={tool.bgColor}
@@ -305,8 +334,7 @@ export function SearchModal({
{triggers.map((trigger) => ( {triggers.map((trigger) => (
<CommandItem <CommandItem
key={trigger.id} key={trigger.id}
value={trigger.name} value={`${trigger.name} trigger-${trigger.id}`}
keywords={[trigger.description]}
onSelect={() => handleBlockSelect(trigger, 'trigger')} onSelect={() => handleBlockSelect(trigger, 'trigger')}
icon={trigger.icon} icon={trigger.icon}
bgColor={trigger.bgColor} bgColor={trigger.bgColor}
@@ -323,7 +351,7 @@ export function SearchModal({
{workflows.map((workflow) => ( {workflows.map((workflow) => (
<Command.Item <Command.Item
key={workflow.id} key={workflow.id}
value={workflow.name} value={`${workflow.name} workflow-${workflow.id}`}
onSelect={() => handleWorkflowSelect(workflow)} onSelect={() => handleWorkflowSelect(workflow)}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
> >
@@ -345,8 +373,7 @@ export function SearchModal({
{toolOperations.map((op) => ( {toolOperations.map((op) => (
<CommandItem <CommandItem
key={op.id} key={op.id}
value={op.searchValue} value={`${op.searchValue} operation-${op.id}`}
keywords={op.keywords}
onSelect={() => handleToolOperationSelect(op)} onSelect={() => handleToolOperationSelect(op)}
icon={op.icon} icon={op.icon}
bgColor={op.bgColor} bgColor={op.bgColor}
@@ -363,7 +390,7 @@ export function SearchModal({
{workspaces.map((workspace) => ( {workspaces.map((workspace) => (
<Command.Item <Command.Item
key={workspace.id} key={workspace.id}
value={workspace.name} value={`${workspace.name} workspace-${workspace.id}`}
onSelect={() => handleWorkspaceSelect(workspace)} onSelect={() => handleWorkspaceSelect(workspace)}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
> >
@@ -381,7 +408,7 @@ export function SearchModal({
{docs.map((doc) => ( {docs.map((doc) => (
<CommandItem <CommandItem
key={doc.id} key={doc.id}
value={`${doc.name} docs documentation`} value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => handleDocSelect(doc)} onSelect={() => handleDocSelect(doc)}
icon={doc.icon} icon={doc.icon}
bgColor='#6B7280' bgColor='#6B7280'
@@ -400,7 +427,7 @@ export function SearchModal({
return ( return (
<Command.Item <Command.Item
key={page.id} key={page.id}
value={page.name} value={`${page.name} page-${page.id}`}
onSelect={() => handlePageSelect(page)} onSelect={() => handlePageSelect(page)}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
> >
@@ -433,7 +460,6 @@ const groupHeadingClassName =
interface CommandItemProps { interface CommandItemProps {
value: string value: string
keywords?: string[]
onSelect: () => void onSelect: () => void
icon: React.ComponentType<{ className?: string }> icon: React.ComponentType<{ className?: string }>
bgColor: string bgColor: string
@@ -443,7 +469,6 @@ interface CommandItemProps {
function CommandItem({ function CommandItem({
value, value,
keywords,
onSelect, onSelect,
icon: Icon, icon: Icon,
bgColor, bgColor,
@@ -453,7 +478,6 @@ function CommandItem({
return ( return (
<Command.Item <Command.Item
value={value} value={value}
keywords={keywords}
onSelect={onSelect} onSelect={onSelect}
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
> >

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { createElement, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react' import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
@@ -339,9 +339,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
> >
<div className='flex items-center gap-[12px]'> <div className='flex items-center gap-[12px]'>
<div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'> <div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
{typeof service.icon === 'function' {createElement(service.icon, { className: 'h-4 w-4' })}
? service.icon({ className: 'h-4 w-4' })
: service.icon}
</div> </div>
<div className='flex flex-col justify-center gap-[1px]'> <div className='flex flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{service.name}</span> <span className='font-medium text-[14px]'>{service.name}</span>

View File

@@ -164,7 +164,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
...prev, ...prev,
{ {
email: normalized, email: normalized,
permissionType: 'read', permissionType: 'admin',
}, },
]) ])
} }

View File

@@ -66,7 +66,10 @@ function generateSignature(secret: string, timestamp: number, body: string): str
async function buildPayload( async function buildPayload(
log: WorkflowExecutionLog, log: WorkflowExecutionLog,
subscription: typeof workspaceNotificationSubscription.$inferSelect subscription: typeof workspaceNotificationSubscription.$inferSelect
): Promise<NotificationPayload> { ): Promise<NotificationPayload | null> {
// Skip notifications for deleted workflows
if (!log.workflowId) return null
const workflowData = await db const workflowData = await db
.select({ name: workflowTable.name, userId: workflowTable.userId }) .select({ name: workflowTable.name, userId: workflowTable.userId })
.from(workflowTable) .from(workflowTable)
@@ -526,6 +529,13 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
const attempts = claimed[0].attempts const attempts = claimed[0].attempts
const payload = await buildPayload(log, subscription) const payload = await buildPayload(log, subscription)
// Skip delivery for deleted workflows
if (!payload) {
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was deleted')
logger.info(`Skipping delivery ${deliveryId} - workflow was deleted`)
return
}
let result: { success: boolean; status?: number; error?: string } let result: { success: boolean; status?: number; error?: string }
switch (notificationType) { switch (notificationType) {

View File

@@ -85,7 +85,9 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
id: 'messages', id: 'messages',
title: 'Messages', title: 'Messages',
type: 'messages-input', type: 'messages-input',
canonicalParamId: 'messages',
placeholder: 'Enter messages...', placeholder: 'Enter messages...',
mode: 'basic',
wandConfig: { wandConfig: {
enabled: true, enabled: true,
maintainHistory: true, maintainHistory: true,
@@ -93,10 +95,12 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
Current messages: {context} Current messages: {context}
{sources}
RULES: RULES:
1. Generate ONLY a valid JSON array - no markdown, no explanations 1. Generate ONLY a valid JSON array - no markdown, no explanations
2. Each message object must have "role" (system/user/assistant) and "content" (string) 2. Each message object must have "role" and "content" properties
3. You can generate any number of messages as needed 3. Valid roles are: "system", "user", "assistant", "attachment"
4. Content can be as long as necessary - don't truncate 4. Content can be as long as necessary - don't truncate
5. If editing existing messages, preserve structure unless asked to change it 5. If editing existing messages, preserve structure unless asked to change it
6. For new agents, create DETAILED, PROFESSIONAL system prompts that include: 6. For new agents, create DETAILED, PROFESSIONAL system prompts that include:
@@ -106,6 +110,16 @@ RULES:
- Critical thinking or quality guidelines - Critical thinking or quality guidelines
- How to handle edge cases and uncertainty - How to handle edge cases and uncertainty
ATTACHMENTS:
- Use role "attachment" to include images, audio, video, or documents in a multimodal conversation
- IMPORTANT: If an attachment message in the current context has an "attachment" object with file data, ALWAYS preserve that entire "attachment" object exactly as-is
- When creating NEW attachment messages, you can either:
1. Just set role to "attachment" with descriptive content - user will upload the file manually
2. Select a file from the available workspace files by including "fileId" in the attachment object (optional)
- You do NOT have to select a file - it's completely optional
- Example without file: {"role": "attachment", "content": "Analyze this image for text and objects"}
- Example with file selection: {"role": "attachment", "content": "Analyze this image", "attachment": {"fileId": "abc123"}}
EXAMPLES: EXAMPLES:
Research agent: Research agent:
@@ -114,14 +128,23 @@ Research agent:
Code reviewer: Code reviewer:
[{"role": "system", "content": "You are a Senior Code Reviewer with expertise in software architecture, security, and best practices. Your role is to provide thorough, constructive code reviews that improve code quality and help developers grow.\\n\\n## Review Methodology\\n\\n1. **Security First**: Check for vulnerabilities including injection attacks, authentication flaws, data exposure, and insecure dependencies.\\n\\n2. **Code Quality**: Evaluate readability, maintainability, adherence to DRY/SOLID principles, and appropriate abstraction levels.\\n\\n3. **Performance**: Identify potential bottlenecks, unnecessary computations, memory leaks, and optimization opportunities.\\n\\n4. **Testing**: Assess test coverage, edge case handling, and testability of the code structure.\\n\\n## Output Format\\n\\n### Summary\\nBrief overview of the code's purpose and overall assessment.\\n\\n### Critical Issues\\nSecurity vulnerabilities or bugs that must be fixed before merging.\\n\\n### Improvements\\nSuggested enhancements with clear explanations of why and how.\\n\\n### Positive Aspects\\nHighlight well-written code to reinforce good practices.\\n\\nBe specific with line references. Provide code examples for suggested changes. Balance critique with encouragement."}, {"role": "user", "content": "<start.input>"}] [{"role": "system", "content": "You are a Senior Code Reviewer with expertise in software architecture, security, and best practices. Your role is to provide thorough, constructive code reviews that improve code quality and help developers grow.\\n\\n## Review Methodology\\n\\n1. **Security First**: Check for vulnerabilities including injection attacks, authentication flaws, data exposure, and insecure dependencies.\\n\\n2. **Code Quality**: Evaluate readability, maintainability, adherence to DRY/SOLID principles, and appropriate abstraction levels.\\n\\n3. **Performance**: Identify potential bottlenecks, unnecessary computations, memory leaks, and optimization opportunities.\\n\\n4. **Testing**: Assess test coverage, edge case handling, and testability of the code structure.\\n\\n## Output Format\\n\\n### Summary\\nBrief overview of the code's purpose and overall assessment.\\n\\n### Critical Issues\\nSecurity vulnerabilities or bugs that must be fixed before merging.\\n\\n### Improvements\\nSuggested enhancements with clear explanations of why and how.\\n\\n### Positive Aspects\\nHighlight well-written code to reinforce good practices.\\n\\nBe specific with line references. Provide code examples for suggested changes. Balance critique with encouragement."}, {"role": "user", "content": "<start.input>"}]
Writing assistant: Image analysis agent:
[{"role": "system", "content": "You are a skilled Writing Editor and Coach. Your role is to help users improve their writing through constructive feedback, editing suggestions, and guidance on style, clarity, and structure.\\n\\n## Editing Approach\\n\\n1. **Clarity**: Ensure ideas are expressed clearly and concisely. Eliminate jargon unless appropriate for the audience.\\n\\n2. **Structure**: Evaluate logical flow, paragraph organization, and transitions between ideas.\\n\\n3. **Voice & Tone**: Maintain consistency and appropriateness for the intended audience and purpose.\\n\\n4. **Grammar & Style**: Correct errors while respecting the author's voice.\\n\\n## Output Format\\n\\n### Overall Impression\\nBrief assessment of the piece's strengths and areas for improvement.\\n\\n### Structural Feedback\\nComments on organization, flow, and logical progression.\\n\\n### Line-Level Edits\\nSpecific suggestions with explanations, not just corrections.\\n\\n### Revised Version\\nWhen appropriate, provide an edited version demonstrating improvements.\\n\\nBe encouraging while honest. Explain the reasoning behind suggestions to help the writer improve."}, {"role": "user", "content": "<start.input>"}] [{"role": "system", "content": "You are an expert image analyst. Describe images in detail, identify objects, text, and patterns. Provide structured analysis."}, {"role": "attachment", "content": "Analyze this image"}]
Return ONLY the JSON array.`, Return ONLY the JSON array.`,
placeholder: 'Describe what you want to create or change...', placeholder: 'Describe what you want to create or change...',
generationType: 'json-object', generationType: 'json-object',
}, },
}, },
{
id: 'messagesRaw',
title: 'Messages',
type: 'code',
canonicalParamId: 'messages',
placeholder: '[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]',
language: 'json',
mode: 'advanced',
},
{ {
id: 'model', id: 'model',
title: 'Model', title: 'Model',

View File

@@ -80,6 +80,15 @@ Example:
generationType: 'json-object', generationType: 'json-object',
}, },
}, },
{
id: 'timeout',
title: 'Timeout (ms)',
type: 'short-input',
placeholder: '300000',
description:
'Request timeout in milliseconds (default: 300000 = 5 minutes, max: 600000 = 10 minutes)',
mode: 'advanced',
},
], ],
tools: { tools: {
access: ['http_request'], access: ['http_request'],
@@ -90,6 +99,7 @@ Example:
headers: { type: 'json', description: 'Request headers' }, headers: { type: 'json', description: 'Request headers' },
body: { type: 'json', description: 'Request body data' }, body: { type: 'json', description: 'Request body data' },
params: { type: 'json', description: 'URL query parameters' }, params: { type: 'json', description: 'URL query parameters' },
timeout: { type: 'number', description: 'Request timeout in milliseconds' },
}, },
outputs: { outputs: {
data: { type: 'json', description: 'API response data (JSON, text, or other formats)' }, data: { type: 'json', description: 'API response data (JSON, text, or other formats)' },

View File

@@ -9,7 +9,7 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
description: 'Interact with YouTube videos, channels, and playlists', description: 'Interact with YouTube videos, channels, and playlists',
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: longDescription:
'Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments.', 'Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.',
docsLink: 'https://docs.sim.ai/tools/youtube', docsLink: 'https://docs.sim.ai/tools/youtube',
category: 'tools', category: 'tools',
bgColor: '#FF0000', bgColor: '#FF0000',
@@ -21,7 +21,9 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
type: 'dropdown', type: 'dropdown',
options: [ options: [
{ label: 'Search Videos', id: 'youtube_search' }, { label: 'Search Videos', id: 'youtube_search' },
{ label: 'Get Trending Videos', id: 'youtube_trending' },
{ label: 'Get Video Details', id: 'youtube_video_details' }, { label: 'Get Video Details', id: 'youtube_video_details' },
{ label: 'Get Video Categories', id: 'youtube_video_categories' },
{ label: 'Get Channel Info', id: 'youtube_channel_info' }, { label: 'Get Channel Info', id: 'youtube_channel_info' },
{ label: 'Get Channel Videos', id: 'youtube_channel_videos' }, { label: 'Get Channel Videos', id: 'youtube_channel_videos' },
{ label: 'Get Channel Playlists', id: 'youtube_channel_playlists' }, { label: 'Get Channel Playlists', id: 'youtube_channel_playlists' },
@@ -49,6 +51,13 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_search' },
},
{ {
id: 'channelId', id: 'channelId',
title: 'Filter by Channel ID', title: 'Filter by Channel ID',
@@ -56,6 +65,19 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
placeholder: 'Filter results to a specific channel', placeholder: 'Filter results to a specific channel',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{
id: 'eventType',
title: 'Live Stream Filter',
type: 'dropdown',
options: [
{ label: 'All Videos', id: '' },
{ label: 'Currently Live', id: 'live' },
{ label: 'Upcoming Streams', id: 'upcoming' },
{ label: 'Past Streams', id: 'completed' },
],
value: () => '',
condition: { field: 'operation', value: 'youtube_search' },
},
{ {
id: 'publishedAfter', id: 'publishedAfter',
title: 'Published After', title: 'Published After',
@@ -131,7 +153,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
id: 'videoCategoryId', id: 'videoCategoryId',
title: 'Category ID', title: 'Category ID',
type: 'short-input', type: 'short-input',
placeholder: '10 for Music, 20 for Gaming', placeholder: 'Use Get Video Categories to find IDs',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
{ {
@@ -163,7 +185,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
title: 'Region Code', title: 'Region Code',
type: 'short-input', type: 'short-input',
placeholder: 'US, GB, JP', placeholder: 'US, GB, JP',
condition: { field: 'operation', value: 'youtube_search' }, condition: {
field: 'operation',
value: ['youtube_search', 'youtube_trending', 'youtube_video_categories'],
},
}, },
{ {
id: 'relevanceLanguage', id: 'relevanceLanguage',
@@ -184,6 +209,31 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'moderate', value: () => 'moderate',
condition: { field: 'operation', value: 'youtube_search' }, condition: { field: 'operation', value: 'youtube_search' },
}, },
// Get Trending Videos operation inputs
{
id: 'maxResults',
title: 'Max Results',
type: 'slider',
min: 1,
max: 50,
step: 1,
integer: true,
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'videoCategoryId',
title: 'Category ID',
type: 'short-input',
placeholder: 'Use Get Video Categories to find IDs',
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_trending' },
},
// Get Video Details operation inputs // Get Video Details operation inputs
{ {
id: 'videoId', id: 'videoId',
@@ -193,6 +243,14 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
required: true, required: true,
condition: { field: 'operation', value: 'youtube_video_details' }, condition: { field: 'operation', value: 'youtube_video_details' },
}, },
// Get Video Categories operation inputs
{
id: 'hl',
title: 'Language',
type: 'short-input',
placeholder: 'en, es, fr (for category names)',
condition: { field: 'operation', value: 'youtube_video_categories' },
},
// Get Channel Info operation inputs // Get Channel Info operation inputs
{ {
id: 'channelId', id: 'channelId',
@@ -241,6 +299,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'date', value: () => 'date',
condition: { field: 'operation', value: 'youtube_channel_videos' }, condition: { field: 'operation', value: 'youtube_channel_videos' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_videos' },
},
// Get Channel Playlists operation inputs // Get Channel Playlists operation inputs
{ {
id: 'channelId', id: 'channelId',
@@ -260,6 +325,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_channel_playlists' }, condition: { field: 'operation', value: 'youtube_channel_playlists' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_playlists' },
},
// Get Playlist Items operation inputs // Get Playlist Items operation inputs
{ {
id: 'playlistId', id: 'playlistId',
@@ -279,6 +351,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true, integer: true,
condition: { field: 'operation', value: 'youtube_playlist_items' }, condition: { field: 'operation', value: 'youtube_playlist_items' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_playlist_items' },
},
// Get Video Comments operation inputs // Get Video Comments operation inputs
{ {
id: 'videoId', id: 'videoId',
@@ -309,6 +388,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'relevance', value: () => 'relevance',
condition: { field: 'operation', value: 'youtube_comments' }, condition: { field: 'operation', value: 'youtube_comments' },
}, },
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_comments' },
},
// API Key (common to all operations) // API Key (common to all operations)
{ {
id: 'apiKey', id: 'apiKey',
@@ -321,13 +407,15 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
], ],
tools: { tools: {
access: [ access: [
'youtube_search',
'youtube_video_details',
'youtube_channel_info', 'youtube_channel_info',
'youtube_channel_videos',
'youtube_channel_playlists', 'youtube_channel_playlists',
'youtube_playlist_items', 'youtube_channel_videos',
'youtube_comments', 'youtube_comments',
'youtube_playlist_items',
'youtube_search',
'youtube_trending',
'youtube_video_categories',
'youtube_video_details',
], ],
config: { config: {
tool: (params) => { tool: (params) => {
@@ -339,8 +427,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
switch (params.operation) { switch (params.operation) {
case 'youtube_search': case 'youtube_search':
return 'youtube_search' return 'youtube_search'
case 'youtube_trending':
return 'youtube_trending'
case 'youtube_video_details': case 'youtube_video_details':
return 'youtube_video_details' return 'youtube_video_details'
case 'youtube_video_categories':
return 'youtube_video_categories'
case 'youtube_channel_info': case 'youtube_channel_info':
return 'youtube_channel_info' return 'youtube_channel_info'
case 'youtube_channel_videos': case 'youtube_channel_videos':
@@ -363,6 +455,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
// Search Videos // Search Videos
query: { type: 'string', description: 'Search query' }, query: { type: 'string', description: 'Search query' },
maxResults: { type: 'number', description: 'Maximum number of results' }, maxResults: { type: 'number', description: 'Maximum number of results' },
pageToken: { type: 'string', description: 'Page token for pagination' },
// Search Filters // Search Filters
publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' }, publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' },
publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' }, publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' },
@@ -370,9 +463,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
videoCategoryId: { type: 'string', description: 'YouTube category ID' }, videoCategoryId: { type: 'string', description: 'YouTube category ID' },
videoDefinition: { type: 'string', description: 'Video quality filter' }, videoDefinition: { type: 'string', description: 'Video quality filter' },
videoCaption: { type: 'string', description: 'Caption availability filter' }, videoCaption: { type: 'string', description: 'Caption availability filter' },
eventType: { type: 'string', description: 'Live stream filter (live/upcoming/completed)' },
regionCode: { type: 'string', description: 'Region code (ISO 3166-1)' }, regionCode: { type: 'string', description: 'Region code (ISO 3166-1)' },
relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' }, relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' },
safeSearch: { type: 'string', description: 'Safe search level' }, safeSearch: { type: 'string', description: 'Safe search level' },
hl: { type: 'string', description: 'Language for category names' },
// Video Details & Comments // Video Details & Comments
videoId: { type: 'string', description: 'YouTube video ID' }, videoId: { type: 'string', description: 'YouTube video ID' },
// Channel Info // Channel Info
@@ -384,7 +479,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
order: { type: 'string', description: 'Sort order' }, order: { type: 'string', description: 'Sort order' },
}, },
outputs: { outputs: {
// Search Videos & Playlist Items // Search Videos, Trending, Playlist Items, Captions, Categories
items: { type: 'json', description: 'List of items returned' }, items: { type: 'json', description: 'List of items returned' },
totalResults: { type: 'number', description: 'Total number of results' }, totalResults: { type: 'number', description: 'Total number of results' },
nextPageToken: { type: 'string', description: 'Token for next page' }, nextPageToken: { type: 'string', description: 'Token for next page' },
@@ -399,11 +494,33 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
viewCount: { type: 'number', description: 'View count' }, viewCount: { type: 'number', description: 'View count' },
likeCount: { type: 'number', description: 'Like count' }, likeCount: { type: 'number', description: 'Like count' },
commentCount: { type: 'number', description: 'Comment count' }, commentCount: { type: 'number', description: 'Comment count' },
favoriteCount: { type: 'number', description: 'Favorite count' },
thumbnail: { type: 'string', description: 'Thumbnail URL' }, thumbnail: { type: 'string', description: 'Thumbnail URL' },
tags: { type: 'json', description: 'Video tags' }, tags: { type: 'json', description: 'Video tags' },
categoryId: { type: 'string', description: 'Video category ID' },
definition: { type: 'string', description: 'Video definition (hd/sd)' },
caption: { type: 'string', description: 'Has captions (true/false)' },
licensedContent: { type: 'boolean', description: 'Is licensed content' },
privacyStatus: { type: 'string', description: 'Privacy status' },
liveBroadcastContent: { type: 'string', description: 'Live broadcast status' },
defaultLanguage: { type: 'string', description: 'Default language' },
defaultAudioLanguage: { type: 'string', description: 'Default audio language' },
// Live Streaming Details
isLiveContent: { type: 'boolean', description: 'Whether video is/was a live stream' },
scheduledStartTime: { type: 'string', description: 'Scheduled start time for live streams' },
actualStartTime: { type: 'string', description: 'Actual start time of live stream' },
actualEndTime: { type: 'string', description: 'End time of live stream' },
concurrentViewers: { type: 'number', description: 'Current viewers (live only)' },
activeLiveChatId: { type: 'string', description: 'Live chat ID' },
// Channel Info // Channel Info
subscriberCount: { type: 'number', description: 'Subscriber count' }, subscriberCount: { type: 'number', description: 'Subscriber count' },
videoCount: { type: 'number', description: 'Total video count' }, videoCount: { type: 'number', description: 'Total video count' },
customUrl: { type: 'string', description: 'Channel custom URL' }, customUrl: { type: 'string', description: 'Channel custom URL' },
country: { type: 'string', description: 'Channel country' },
uploadsPlaylistId: { type: 'string', description: 'Uploads playlist ID' },
bannerImageUrl: { type: 'string', description: 'Channel banner URL' },
hiddenSubscriberCount: { type: 'boolean', description: 'Is subscriber count hidden' },
// Video Categories
assignable: { type: 'boolean', description: 'Whether category can be assigned' },
}, },
} }

View File

@@ -37,7 +37,7 @@
.code-editor-theme .token.char, .code-editor-theme .token.char,
.code-editor-theme .token.builtin, .code-editor-theme .token.builtin,
.code-editor-theme .token.inserted { .code-editor-theme .token.inserted {
color: #dc2626 !important; color: #b45309 !important;
} }
.code-editor-theme .token.operator, .code-editor-theme .token.operator,
@@ -49,7 +49,7 @@
.code-editor-theme .token.atrule, .code-editor-theme .token.atrule,
.code-editor-theme .token.attr-value, .code-editor-theme .token.attr-value,
.code-editor-theme .token.keyword { .code-editor-theme .token.keyword {
color: #2563eb !important; color: #2f55ff !important;
} }
.code-editor-theme .token.function, .code-editor-theme .token.function,
@@ -119,7 +119,7 @@
.dark .code-editor-theme .token.atrule, .dark .code-editor-theme .token.atrule,
.dark .code-editor-theme .token.attr-value, .dark .code-editor-theme .token.attr-value,
.dark .code-editor-theme .token.keyword { .dark .code-editor-theme .token.keyword {
color: #4db8ff !important; color: #2fa1ff !important;
} }
.dark .code-editor-theme .token.function, .dark .code-editor-theme .token.function,

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ export { Loader } from './loader'
export { MoreHorizontal } from './more-horizontal' export { MoreHorizontal } from './more-horizontal'
export { NoWrap } from './no-wrap' export { NoWrap } from './no-wrap'
export { PanelLeft } from './panel-left' export { PanelLeft } from './panel-left'
export { Play } from './play' export { Play, PlayOutline } from './play'
export { Redo } from './redo' export { Redo } from './redo'
export { Rocket } from './rocket' export { Rocket } from './rocket'
export { Trash } from './trash' export { Trash } from './trash'

View File

@@ -1,7 +1,7 @@
import type { SVGProps } from 'react' import type { SVGProps } from 'react'
/** /**
* Play icon component * Play icon component (filled/solid version)
* @param props - SVG properties including className, fill, etc. * @param props - SVG properties including className, fill, etc.
*/ */
export function Play(props: SVGProps<SVGSVGElement>) { export function Play(props: SVGProps<SVGSVGElement>) {
@@ -21,3 +21,27 @@ export function Play(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
) )
} }
/**
* Play icon component (stroke/outline version, matches lucide style)
* Uses 24x24 viewBox and strokeWidth 2 for consistency with other icons.
* @param props - SVG properties including className, stroke, etc.
*/
export function PlayOutline(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M14.7175 4.07175C16.6036 5.37051 18.0001 6.39111 19.0000 7.32600C20.0087 8.26733 20.9617 9.25123 21.3031 10.5484C21.5534 11.4996 21.5534 12.5003 21.3031 13.4515C20.9617 14.7487 20.0087 15.7326 19.0000 16.6739C18.0001 17.6088 16.6037 18.6294 14.7176 19.9281C12.9093 21.1827 11.0470 22.2407 9.6333 22.8420C8.2082 23.4482 6.9090 23.7554 5.6463 23.3976C4.6383 23.1346 3.7940 22.6355 3.1138 21.9492C2.1907 21.0179 1.9001 19.7306 1.7248 18.1814C1.5507 16.6436 1.5507 14.6305 1.5508 12.0701V11.9298C1.5507 9.36936 1.5507 7.35626 1.7248 5.81844C1.9001 4.26926 2.1907 2.982 3.1138 2.05063C3.7940 1.36438 4.6383 0.865267 5.6463 0.602306C6.9090 0.244489 8.2082 0.551707 9.6333 1.15785C11.0470 1.75916 12.9092 2.81712 14.7175 4.07175Z' />
</svg>
)
}

View File

@@ -33,6 +33,15 @@ export interface DAG {
parallelConfigs: Map<string, SerializedParallel> parallelConfigs: Map<string, SerializedParallel>
} }
export interface DAGBuildOptions {
/** Trigger block ID to start path construction from */
triggerBlockId?: string
/** Saved incoming edges from snapshot for resumption */
savedIncomingEdges?: Record<string, string[]>
/** Include all enabled blocks instead of only those reachable from trigger */
includeAllBlocks?: boolean
}
export class DAGBuilder { export class DAGBuilder {
private pathConstructor = new PathConstructor() private pathConstructor = new PathConstructor()
private loopConstructor = new LoopConstructor() private loopConstructor = new LoopConstructor()
@@ -40,11 +49,9 @@ export class DAGBuilder {
private nodeConstructor = new NodeConstructor() private nodeConstructor = new NodeConstructor()
private edgeConstructor = new EdgeConstructor() private edgeConstructor = new EdgeConstructor()
build( build(workflow: SerializedWorkflow, options: DAGBuildOptions = {}): DAG {
workflow: SerializedWorkflow, const { triggerBlockId, savedIncomingEdges, includeAllBlocks } = options
triggerBlockId?: string,
savedIncomingEdges?: Record<string, string[]>
): DAG {
const dag: DAG = { const dag: DAG = {
nodes: new Map(), nodes: new Map(),
loopConfigs: new Map(), loopConfigs: new Map(),
@@ -53,7 +60,7 @@ export class DAGBuilder {
this.initializeConfigs(workflow, dag) this.initializeConfigs(workflow, dag)
const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId) const reachableBlocks = this.pathConstructor.execute(workflow, triggerBlockId, includeAllBlocks)
this.loopConstructor.execute(dag, reachableBlocks) this.loopConstructor.execute(dag, reachableBlocks)
this.parallelConstructor.execute(dag, reachableBlocks) this.parallelConstructor.execute(dag, reachableBlocks)

View File

@@ -6,7 +6,16 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
const logger = createLogger('PathConstructor') const logger = createLogger('PathConstructor')
export class PathConstructor { export class PathConstructor {
execute(workflow: SerializedWorkflow, triggerBlockId?: string): Set<string> { execute(
workflow: SerializedWorkflow,
triggerBlockId?: string,
includeAllBlocks?: boolean
): Set<string> {
// For run-from-block mode, include all enabled blocks regardless of trigger reachability
if (includeAllBlocks) {
return this.getAllEnabledBlocks(workflow)
}
const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId) const resolvedTriggerId = this.findTriggerBlock(workflow, triggerBlockId)
if (!resolvedTriggerId) { if (!resolvedTriggerId) {

View File

@@ -6,6 +6,7 @@ interface ChildWorkflowErrorOptions {
childWorkflowName: string childWorkflowName: string
childTraceSpans?: TraceSpan[] childTraceSpans?: TraceSpan[]
executionResult?: ExecutionResult executionResult?: ExecutionResult
childWorkflowSnapshotId?: string
cause?: Error cause?: Error
} }
@@ -16,6 +17,7 @@ export class ChildWorkflowError extends Error {
readonly childTraceSpans: TraceSpan[] readonly childTraceSpans: TraceSpan[]
readonly childWorkflowName: string readonly childWorkflowName: string
readonly executionResult?: ExecutionResult readonly executionResult?: ExecutionResult
readonly childWorkflowSnapshotId?: string
constructor(options: ChildWorkflowErrorOptions) { constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause }) super(options.message, { cause: options.cause })
@@ -23,6 +25,7 @@ export class ChildWorkflowError extends Error {
this.childWorkflowName = options.childWorkflowName this.childWorkflowName = options.childWorkflowName
this.childTraceSpans = options.childTraceSpans ?? [] this.childTraceSpans = options.childTraceSpans ?? []
this.executionResult = options.executionResult this.executionResult = options.executionResult
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
} }
static isChildWorkflowError(error: unknown): error is ChildWorkflowError { static isChildWorkflowError(error: unknown): error is ChildWorkflowError {

View File

@@ -4,6 +4,7 @@ import {
containsUserFileWithMetadata, containsUserFileWithMetadata,
hydrateUserFilesWithBase64, hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server' } from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { import {
BlockType, BlockType,
buildResumeApiUrl, buildResumeApiUrl,
@@ -34,6 +35,7 @@ import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver' import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types' import type { SubflowType } from '@/stores/workflows/workflow/types'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
const logger = createLogger('BlockExecutor') const logger = createLogger('BlockExecutor')
@@ -87,7 +89,7 @@ export class BlockExecutor {
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
if (blockLog) { if (blockLog) {
blockLog.input = this.parseJsonInputs(resolvedInputs) blockLog.input = this.sanitizeInputsForLog(resolvedInputs)
} }
} catch (error) { } catch (error) {
cleanupSelfReference?.() cleanupSelfReference?.()
@@ -150,6 +152,9 @@ export class BlockExecutor {
blockLog.durationMs = duration blockLog.durationMs = duration
blockLog.success = true blockLog.success = true
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block }) blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
}
} }
this.state.setBlockOutput(node.id, normalizedOutput, duration) this.state.setBlockOutput(node.id, normalizedOutput, duration)
@@ -162,7 +167,7 @@ export class BlockExecutor {
ctx, ctx,
node, node,
block, block,
this.parseJsonInputs(resolvedInputs), this.sanitizeInputsForLog(resolvedInputs),
displayOutput, displayOutput,
duration duration
) )
@@ -232,6 +237,9 @@ export class BlockExecutor {
if (ChildWorkflowError.isChildWorkflowError(error)) { if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childTraceSpans = error.childTraceSpans errorOutput.childTraceSpans = error.childTraceSpans
errorOutput.childWorkflowName = error.childWorkflowName errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
} }
this.state.setBlockOutput(node.id, errorOutput, duration) this.state.setBlockOutput(node.id, errorOutput, duration)
@@ -241,8 +249,12 @@ export class BlockExecutor {
blockLog.durationMs = duration blockLog.durationMs = duration
blockLog.success = false blockLog.success = false
blockLog.error = errorMessage blockLog.error = errorMessage
blockLog.input = this.parseJsonInputs(input) blockLog.input = this.sanitizeInputsForLog(input)
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block }) blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
if (errorOutput.childTraceSpans && Array.isArray(errorOutput.childTraceSpans)) {
blockLog.childTraceSpans = errorOutput.childTraceSpans
}
} }
logger.error( logger.error(
@@ -260,7 +272,7 @@ export class BlockExecutor {
ctx, ctx,
node, node,
block, block,
this.parseJsonInputs(input), this.sanitizeInputsForLog(input),
displayOutput, displayOutput,
duration duration
) )
@@ -352,29 +364,41 @@ export class BlockExecutor {
} }
/** /**
* Parse JSON string inputs to objects for log display only. * Sanitizes inputs for log display.
* Attempts to parse any string that looks like JSON. * - Filters out system fields (UI-only, readonly, internal flags)
* - Removes UI state from inputFormat items (e.g., collapsed)
* - Parses JSON strings to objects for readability
* Returns a new object - does not mutate the original inputs. * Returns a new object - does not mutate the original inputs.
*/ */
private parseJsonInputs(inputs: Record<string, any>): Record<string, any> { private sanitizeInputsForLog(inputs: Record<string, any>): Record<string, any> {
let result = inputs const result: Record<string, any> = {}
let hasChanges = false
for (const [key, value] of Object.entries(inputs)) { for (const [key, value] of Object.entries(inputs)) {
// isJSONString is a quick heuristic (checks for { or [), not a validator. if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode') {
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
if (typeof value !== 'string' || !isJSONString(value)) {
continue continue
} }
try { if (key === 'inputFormat' && Array.isArray(value)) {
if (!hasChanges) { result[key] = sanitizeInputFormat(value)
result = { ...inputs } continue
hasChanges = true }
if (key === 'tools' && Array.isArray(value)) {
result[key] = sanitizeTools(value)
continue
}
// isJSONString is a quick heuristic (checks for { or [), not a validator.
// Invalid JSON is safely caught below - this just avoids JSON.parse on every string.
if (typeof value === 'string' && isJSONString(value)) {
try {
result[key] = JSON.parse(value.trim())
} catch {
// Not valid JSON, keep original string
result[key] = value
} }
result[key] = JSON.parse(value.trim()) } else {
} catch { result[key] = value
// Not valid JSON, keep original string
} }
} }

View File

@@ -2417,4 +2417,177 @@ describe('EdgeManager', () => {
expect(successReady).toContain(targetId) expect(successReady).toContain(targetId)
}) })
}) })
describe('Condition with loop downstream - deactivation propagation', () => {
it('should deactivate nodes after loop when condition branch containing loop is deactivated', () => {
// Scenario: condition → (if) → sentinel_start → loopBody → sentinel_end → (loop_exit) → after_loop
// → (else) → other_branch
// When condition takes "else" path, the entire if-branch including nodes after the loop should be deactivated
const conditionId = 'condition'
const sentinelStartId = 'sentinel-start'
const loopBodyId = 'loop-body'
const sentinelEndId = 'sentinel-end'
const afterLoopId = 'after-loop'
const otherBranchId = 'other-branch'
const conditionNode = createMockNode(conditionId, [
{ target: sentinelStartId, sourceHandle: 'condition-if' },
{ target: otherBranchId, sourceHandle: 'condition-else' },
])
const sentinelStartNode = createMockNode(
sentinelStartId,
[{ target: loopBodyId }],
[conditionId]
)
const loopBodyNode = createMockNode(
loopBodyId,
[{ target: sentinelEndId }],
[sentinelStartId]
)
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: afterLoopId, sourceHandle: 'loop_exit' },
],
[loopBodyId]
)
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[sentinelStartId, sentinelStartNode],
[loopBodyId, loopBodyNode],
[sentinelEndId, sentinelEndNode],
[afterLoopId, afterLoopNode],
[otherBranchId, otherBranchNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch, deactivating the "if" branch (which contains the loop)
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
// Only otherBranch should be ready
expect(readyNodes).toContain(otherBranchId)
expect(readyNodes).not.toContain(sentinelStartId)
// afterLoop should NOT be ready - its incoming edge from sentinel_end should be deactivated
expect(readyNodes).not.toContain(afterLoopId)
// Verify that countActiveIncomingEdges returns 0 for afterLoop
// (meaning the loop_exit edge was properly deactivated)
// Note: isNodeReady returns true when all edges are deactivated (no pending deps),
// but the node won't be in readyNodes since it wasn't reached via an active path
expect(edgeManager.isNodeReady(afterLoopNode)).toBe(true) // All edges deactivated = no blocking deps
})
it('should deactivate nodes after parallel when condition branch containing parallel is deactivated', () => {
// Similar scenario with parallel instead of loop
const conditionId = 'condition'
const parallelStartId = 'parallel-start'
const parallelBodyId = 'parallel-body'
const parallelEndId = 'parallel-end'
const afterParallelId = 'after-parallel'
const otherBranchId = 'other-branch'
const conditionNode = createMockNode(conditionId, [
{ target: parallelStartId, sourceHandle: 'condition-if' },
{ target: otherBranchId, sourceHandle: 'condition-else' },
])
const parallelStartNode = createMockNode(
parallelStartId,
[{ target: parallelBodyId }],
[conditionId]
)
const parallelBodyNode = createMockNode(
parallelBodyId,
[{ target: parallelEndId }],
[parallelStartId]
)
const parallelEndNode = createMockNode(
parallelEndId,
[{ target: afterParallelId, sourceHandle: 'parallel_exit' }],
[parallelBodyId]
)
const afterParallelNode = createMockNode(afterParallelId, [], [parallelEndId])
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[parallelStartId, parallelStartNode],
[parallelBodyId, parallelBodyNode],
[parallelEndId, parallelEndNode],
[afterParallelId, afterParallelNode],
[otherBranchId, otherBranchNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(readyNodes).toContain(otherBranchId)
expect(readyNodes).not.toContain(parallelStartId)
expect(readyNodes).not.toContain(afterParallelId)
// isNodeReady returns true when all edges are deactivated (no pending deps)
expect(edgeManager.isNodeReady(afterParallelNode)).toBe(true)
})
it('should still correctly handle normal loop exit (not deactivate when loop runs)', () => {
// When a loop actually executes and exits normally, after_loop should become ready
const sentinelStartId = 'sentinel-start'
const loopBodyId = 'loop-body'
const sentinelEndId = 'sentinel-end'
const afterLoopId = 'after-loop'
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: loopBodyId }])
const loopBodyNode = createMockNode(
loopBodyId,
[{ target: sentinelEndId }],
[sentinelStartId]
)
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: afterLoopId, sourceHandle: 'loop_exit' },
],
[loopBodyId]
)
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
const nodes = new Map<string, DAGNode>([
[sentinelStartId, sentinelStartNode],
[loopBodyId, loopBodyNode],
[sentinelEndId, sentinelEndNode],
[afterLoopId, afterLoopNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Simulate sentinel_end completing with loop_exit (loop is done)
const readyNodes = edgeManager.processOutgoingEdges(sentinelEndNode, {
selectedRoute: 'loop_exit',
})
// afterLoop should be ready
expect(readyNodes).toContain(afterLoopId)
})
})
}) })

View File

@@ -243,7 +243,7 @@ export class EdgeManager {
} }
for (const [, outgoingEdge] of targetNode.outgoingEdges) { for (const [, outgoingEdge] of targetNode.outgoingEdges) {
if (!this.isControlEdge(outgoingEdge.sourceHandle)) { if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
this.deactivateEdgeAndDescendants( this.deactivateEdgeAndDescendants(
targetId, targetId,
outgoingEdge.target, outgoingEdge.target,

View File

@@ -26,6 +26,7 @@ export class ExecutionEngine {
private allowResumeTriggers: boolean private allowResumeTriggers: boolean
private cancelledFlag = false private cancelledFlag = false
private errorFlag = false private errorFlag = false
private stoppedEarlyFlag = false
private executionError: Error | null = null private executionError: Error | null = null
private lastCancellationCheck = 0 private lastCancellationCheck = 0
private readonly useRedisCancellation: boolean private readonly useRedisCancellation: boolean
@@ -105,7 +106,7 @@ export class ExecutionEngine {
this.initializeQueue(triggerBlockId) this.initializeQueue(triggerBlockId)
while (this.hasWork()) { while (this.hasWork()) {
if ((await this.checkCancellation()) || this.errorFlag) { if ((await this.checkCancellation()) || this.errorFlag || this.stoppedEarlyFlag) {
break break
} }
await this.processQueue() await this.processQueue()
@@ -259,6 +260,16 @@ export class ExecutionEngine {
} }
private initializeQueue(triggerBlockId?: string): void { private initializeQueue(triggerBlockId?: string): void {
if (this.context.runFromBlockContext) {
const { startBlockId } = this.context.runFromBlockContext
logger.info('Initializing queue for run-from-block mode', {
startBlockId,
dirtySetSize: this.context.runFromBlockContext.dirtySet.size,
})
this.addToQueue(startBlockId)
return
}
const pendingBlocks = this.context.metadata.pendingBlocks const pendingBlocks = this.context.metadata.pendingBlocks
const remainingEdges = (this.context.metadata as any).remainingEdges const remainingEdges = (this.context.metadata as any).remainingEdges
@@ -385,6 +396,17 @@ export class ExecutionEngine {
this.finalOutput = output this.finalOutput = output
} }
if (this.context.stopAfterBlockId === nodeId) {
// For loop/parallel sentinels, only stop if the subflow has fully exited (all iterations done)
// shouldContinue: true means more iterations, shouldExit: true means loop is done
const shouldContinueLoop = output.shouldContinue === true
if (!shouldContinueLoop) {
logger.info('Stopping execution after target block', { nodeId })
this.stoppedEarlyFlag = true
return
}
}
const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false) const readyNodes = this.edgeManager.processOutgoingEdges(node, output, false)
logger.info('Processing outgoing edges', { logger.info('Processing outgoing edges', {

View File

@@ -5,17 +5,31 @@ import { BlockExecutor } from '@/executor/execution/block-executor'
import { EdgeManager } from '@/executor/execution/edge-manager' import { EdgeManager } from '@/executor/execution/edge-manager'
import { ExecutionEngine } from '@/executor/execution/engine' import { ExecutionEngine } from '@/executor/execution/engine'
import { ExecutionState } from '@/executor/execution/state' import { ExecutionState } from '@/executor/execution/state'
import type { ContextExtensions, WorkflowInput } from '@/executor/execution/types' import type {
ContextExtensions,
SerializableExecutionState,
WorkflowInput,
} from '@/executor/execution/types'
import { createBlockHandlers } from '@/executor/handlers/registry' import { createBlockHandlers } from '@/executor/handlers/registry'
import { LoopOrchestrator } from '@/executor/orchestrators/loop' import { LoopOrchestrator } from '@/executor/orchestrators/loop'
import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node' import { NodeExecutionOrchestrator } from '@/executor/orchestrators/node'
import { ParallelOrchestrator } from '@/executor/orchestrators/parallel' import { ParallelOrchestrator } from '@/executor/orchestrators/parallel'
import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types' import type { BlockState, ExecutionContext, ExecutionResult } from '@/executor/types'
import {
computeExecutionSets,
type RunFromBlockContext,
resolveContainerToSentinelStart,
validateRunFromBlock,
} from '@/executor/utils/run-from-block'
import { import {
buildResolutionFromBlock, buildResolutionFromBlock,
buildStartBlockOutput, buildStartBlockOutput,
resolveExecutorStartBlock, resolveExecutorStartBlock,
} from '@/executor/utils/start-block' } from '@/executor/utils/start-block'
import {
extractLoopIdFromSentinel,
extractParallelIdFromSentinel,
} from '@/executor/utils/subflow-utils'
import { VariableResolver } from '@/executor/variables/resolver' import { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedWorkflow } from '@/serializer/types' import type { SerializedWorkflow } from '@/serializer/types'
@@ -48,7 +62,10 @@ export class DAGExecutor {
async execute(workflowId: string, triggerBlockId?: string): Promise<ExecutionResult> { async execute(workflowId: string, triggerBlockId?: string): Promise<ExecutionResult> {
const savedIncomingEdges = this.contextExtensions.dagIncomingEdges const savedIncomingEdges = this.contextExtensions.dagIncomingEdges
const dag = this.dagBuilder.build(this.workflow, triggerBlockId, savedIncomingEdges) const dag = this.dagBuilder.build(this.workflow, {
triggerBlockId,
savedIncomingEdges,
})
const { context, state } = this.createExecutionContext(workflowId, triggerBlockId) const { context, state } = this.createExecutionContext(workflowId, triggerBlockId)
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state) const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
@@ -89,17 +106,156 @@ export class DAGExecutor {
} }
} }
/**
* Execute from a specific block using cached outputs for upstream blocks.
*/
async executeFromBlock(
workflowId: string,
startBlockId: string,
sourceSnapshot: SerializableExecutionState
): Promise<ExecutionResult> {
// Build full DAG with all blocks to compute upstream set for snapshot filtering
// includeAllBlocks is needed because the startBlockId might be a trigger not reachable from the main trigger
const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true })
const executedBlocks = new Set(sourceSnapshot.executedBlocks)
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
if (!validation.valid) {
throw new Error(validation.error)
}
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId)
const effectiveStartBlockId = resolveContainerToSentinelStart(startBlockId, dag) ?? startBlockId
// Extract container IDs from sentinel IDs in reachable upstream set
// Use reachableUpstreamSet (not upstreamSet) to preserve sibling branch outputs
// Example: A->C, B->C where C references A.result || B.result
// When running from A, B's output should be preserved for C to reference
const reachableContainerIds = new Set<string>()
for (const nodeId of reachableUpstreamSet) {
const loopId = extractLoopIdFromSentinel(nodeId)
if (loopId) reachableContainerIds.add(loopId)
const parallelId = extractParallelIdFromSentinel(nodeId)
if (parallelId) reachableContainerIds.add(parallelId)
}
// Filter snapshot to include all blocks reachable from dirty blocks
// This preserves sibling branch outputs that dirty blocks may reference
const filteredBlockStates: Record<string, any> = {}
for (const [blockId, state] of Object.entries(sourceSnapshot.blockStates)) {
if (reachableUpstreamSet.has(blockId) || reachableContainerIds.has(blockId)) {
filteredBlockStates[blockId] = state
}
}
const filteredExecutedBlocks = sourceSnapshot.executedBlocks.filter(
(id) => reachableUpstreamSet.has(id) || reachableContainerIds.has(id)
)
// Filter loop/parallel executions to only include reachable containers
const filteredLoopExecutions: Record<string, any> = {}
if (sourceSnapshot.loopExecutions) {
for (const [loopId, execution] of Object.entries(sourceSnapshot.loopExecutions)) {
if (reachableContainerIds.has(loopId)) {
filteredLoopExecutions[loopId] = execution
}
}
}
const filteredParallelExecutions: Record<string, any> = {}
if (sourceSnapshot.parallelExecutions) {
for (const [parallelId, execution] of Object.entries(sourceSnapshot.parallelExecutions)) {
if (reachableContainerIds.has(parallelId)) {
filteredParallelExecutions[parallelId] = execution
}
}
}
const filteredSnapshot: SerializableExecutionState = {
...sourceSnapshot,
blockStates: filteredBlockStates,
executedBlocks: filteredExecutedBlocks,
loopExecutions: filteredLoopExecutions,
parallelExecutions: filteredParallelExecutions,
}
logger.info('Executing from block', {
workflowId,
startBlockId,
effectiveStartBlockId,
dirtySetSize: dirtySet.size,
upstreamSetSize: upstreamSet.size,
reachableUpstreamSetSize: reachableUpstreamSet.size,
})
// Remove incoming edges from non-dirty sources so convergent blocks don't wait for cached upstream
for (const nodeId of dirtySet) {
const node = dag.nodes.get(nodeId)
if (!node) continue
const nonDirtyIncoming: string[] = []
for (const sourceId of node.incomingEdges) {
if (!dirtySet.has(sourceId)) {
nonDirtyIncoming.push(sourceId)
}
}
for (const sourceId of nonDirtyIncoming) {
node.incomingEdges.delete(sourceId)
}
}
const runFromBlockContext = { startBlockId: effectiveStartBlockId, dirtySet }
const { context, state } = this.createExecutionContext(workflowId, undefined, {
snapshotState: filteredSnapshot,
runFromBlockContext,
})
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
loopOrchestrator.setContextExtensions(this.contextExtensions)
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
parallelOrchestrator.setResolver(resolver)
parallelOrchestrator.setContextExtensions(this.contextExtensions)
const allHandlers = createBlockHandlers()
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
const edgeManager = new EdgeManager(dag)
loopOrchestrator.setEdgeManager(edgeManager)
const nodeOrchestrator = new NodeExecutionOrchestrator(
dag,
state,
blockExecutor,
loopOrchestrator,
parallelOrchestrator
)
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
return await engine.run()
}
private createExecutionContext( private createExecutionContext(
workflowId: string, workflowId: string,
triggerBlockId?: string triggerBlockId?: string,
overrides?: {
snapshotState?: SerializableExecutionState
runFromBlockContext?: RunFromBlockContext
}
): { context: ExecutionContext; state: ExecutionState } { ): { context: ExecutionContext; state: ExecutionState } {
const snapshotState = this.contextExtensions.snapshotState const snapshotState = overrides?.snapshotState ?? this.contextExtensions.snapshotState
const blockStates = snapshotState?.blockStates const blockStates = snapshotState?.blockStates
? new Map(Object.entries(snapshotState.blockStates)) ? new Map(Object.entries(snapshotState.blockStates))
: new Map<string, BlockState>() : new Map<string, BlockState>()
const executedBlocks = snapshotState?.executedBlocks let executedBlocks = snapshotState?.executedBlocks
? new Set(snapshotState.executedBlocks) ? new Set(snapshotState.executedBlocks)
: new Set<string>() : new Set<string>()
if (overrides?.runFromBlockContext) {
const { dirtySet } = overrides.runFromBlockContext
executedBlocks = new Set([...executedBlocks].filter((id) => !dirtySet.has(id)))
logger.info('Cleared executed status for dirty blocks', {
dirtySetSize: dirtySet.size,
remainingExecutedBlocks: executedBlocks.size,
})
}
const state = new ExecutionState(blockStates, executedBlocks) const state = new ExecutionState(blockStates, executedBlocks)
const context: ExecutionContext = { const context: ExecutionContext = {
@@ -109,7 +265,7 @@ export class DAGExecutor {
userId: this.contextExtensions.userId, userId: this.contextExtensions.userId,
isDeployedContext: this.contextExtensions.isDeployedContext, isDeployedContext: this.contextExtensions.isDeployedContext,
blockStates: state.getBlockStates(), blockStates: state.getBlockStates(),
blockLogs: snapshotState?.blockLogs ?? [], blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
metadata: { metadata: {
...this.contextExtensions.metadata, ...this.contextExtensions.metadata,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
@@ -169,6 +325,8 @@ export class DAGExecutor {
abortSignal: this.contextExtensions.abortSignal, abortSignal: this.contextExtensions.abortSignal,
includeFileBase64: this.contextExtensions.includeFileBase64, includeFileBase64: this.contextExtensions.includeFileBase64,
base64MaxBytes: this.contextExtensions.base64MaxBytes, base64MaxBytes: this.contextExtensions.base64MaxBytes,
runFromBlockContext: overrides?.runFromBlockContext,
stopAfterBlockId: this.contextExtensions.stopAfterBlockId,
} }
if (this.contextExtensions.resumeFromSnapshot) { if (this.contextExtensions.resumeFromSnapshot) {
@@ -193,6 +351,15 @@ export class DAGExecutor {
pendingBlocks: context.metadata.pendingBlocks, pendingBlocks: context.metadata.pendingBlocks,
skipStarterBlockInit: true, skipStarterBlockInit: true,
}) })
} else if (overrides?.runFromBlockContext) {
// In run-from-block mode, initialize the start block only if it's a regular block
// Skip for sentinels/containers (loop/parallel) which aren't real blocks
const startBlockId = overrides.runFromBlockContext.startBlockId
const isRegularBlock = this.workflow.blocks.some((b) => b.id === startBlockId)
if (isRegularBlock) {
this.initializeStarterBlock(context, state, startBlockId)
}
} else { } else {
this.initializeStarterBlock(context, state, triggerBlockId) this.initializeStarterBlock(context, state, triggerBlockId)
} }

View File

@@ -1,5 +1,6 @@
import type { Edge } from 'reactflow' import type { Edge } from 'reactflow'
import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types' import type { BlockLog, BlockState, NormalizedBlockOutput } from '@/executor/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SubflowType } from '@/stores/workflows/workflow/types' import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ExecutionMetadata { export interface ExecutionMetadata {
@@ -105,6 +106,17 @@ export interface ContextExtensions {
output: { input?: any; output: NormalizedBlockOutput; executionTime: number }, output: { input?: any; output: NormalizedBlockOutput; executionTime: number },
iterationContext?: IterationContext iterationContext?: IterationContext
) => Promise<void> ) => Promise<void>
/**
* Run-from-block configuration. When provided, executor runs in partial
* execution mode starting from the specified block.
*/
runFromBlockContext?: RunFromBlockContext
/**
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
} }
export interface WorkflowInput { export interface WorkflowInput {

View File

@@ -25,6 +25,8 @@ import {
validateModelProvider, validateModelProvider,
} from '@/executor/utils/permission-check' } from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers' import { executeProviderRequest } from '@/providers'
import { transformAttachmentMessages } from '@/providers/attachment'
import type { ProviderId } from '@/providers/types'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools' import { executeTool } from '@/tools'
@@ -58,7 +60,15 @@ export class AgentBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(model) const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || []) const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block) const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = await this.buildMessages(ctx, filteredInputs) const rawMessages = await this.buildMessages(ctx, filteredInputs)
// Transform attachment messages to provider-specific format (async for file fetching)
const messages = rawMessages
? await transformAttachmentMessages(rawMessages, {
providerId: providerId as ProviderId,
model,
})
: undefined
const providerRequest = this.buildProviderRequest({ const providerRequest = this.buildProviderRequest({
ctx, ctx,
@@ -806,17 +816,44 @@ export class AgentBlockHandler implements BlockHandler {
return messages.length > 0 ? messages : undefined return messages.length > 0 ? messages : undefined
} }
private extractValidMessages(messages?: Message[]): Message[] { private extractValidMessages(messages?: Message[] | string): Message[] {
if (!messages || !Array.isArray(messages)) return [] if (!messages) return []
return messages.filter( // Handle raw JSON string input (from advanced mode)
(msg): msg is Message => let messageArray: unknown[]
msg && if (typeof messages === 'string') {
typeof msg === 'object' && const trimmed = messages.trim()
'role' in msg && if (!trimmed) return []
'content' in msg && try {
['system', 'user', 'assistant'].includes(msg.role) const parsed = JSON.parse(trimmed)
) if (!Array.isArray(parsed)) {
logger.warn('Parsed messages JSON is not an array', { parsed })
return []
}
messageArray = parsed
} catch (error) {
logger.warn('Failed to parse messages JSON string', {
error,
messages: trimmed.substring(0, 100),
})
return []
}
} else if (Array.isArray(messages)) {
messageArray = messages
} else {
return []
}
return messageArray.filter((msg): msg is Message => {
if (!msg || typeof msg !== 'object') return false
const m = msg as Record<string, unknown>
return (
'role' in m &&
'content' in m &&
typeof m.role === 'string' &&
['system', 'user', 'assistant', 'attachment'].includes(m.role)
)
})
} }
private processMemories(memories: any): Message[] { private processMemories(memories: any): Message[] {

View File

@@ -6,8 +6,8 @@ export interface AgentInputs {
systemPrompt?: string systemPrompt?: string
userPrompt?: string | object userPrompt?: string | object
memories?: any // Legacy memory block output memories?: any // Legacy memory block output
// New message array input (from messages-input subblock) // New message array input (from messages-input subblock or raw JSON from advanced mode)
messages?: Message[] messages?: Message[] | string
// Memory configuration // Memory configuration
memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens' memoryType?: 'none' | 'conversation' | 'sliding_window' | 'sliding_window_tokens'
conversationId?: string // Required for all non-none memory types conversationId?: string // Required for all non-none memory types
@@ -42,9 +42,25 @@ export interface ToolInput {
customToolId?: string customToolId?: string
} }
/**
* Attachment content (files, images, documents)
*/
export interface AttachmentContent {
/** Source type: how the data was provided */
sourceType: 'url' | 'base64' | 'file'
/** The URL or base64 data */
data: string
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
mimeType?: string
/** Optional filename for file uploads */
fileName?: string
}
export interface Message { export interface Message {
role: 'system' | 'user' | 'assistant' role: 'system' | 'user' | 'assistant' | 'attachment'
content: string content: string
/** Attachment content for 'attachment' role messages */
attachment?: AttachmentContent
executionId?: string executionId?: string
function_call?: any function_call?: any
tool_calls?: any[] tool_calls?: any[]

View File

@@ -118,7 +118,7 @@ describe('WorkflowBlockHandler', () => {
} }
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow( await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded' '"child-workflow-id" failed: Maximum workflow nesting depth of 10 exceeded'
) )
}) })
@@ -132,7 +132,7 @@ describe('WorkflowBlockHandler', () => {
}) })
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found' '"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
) )
}) })
@@ -142,7 +142,7 @@ describe('WorkflowBlockHandler', () => {
mockFetch.mockRejectedValueOnce(new Error('Network error')) mockFetch.mockRejectedValueOnce(new Error('Network error'))
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
'Error in child workflow "child-workflow-id": Network error' '"child-workflow-id" failed: Network error'
) )
}) })
}) })
@@ -198,6 +198,7 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({ expect(result).toEqual({
success: true, success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow', childWorkflowName: 'Child Workflow',
result: { data: 'test result' }, result: { data: 'test result' },
childTraceSpans: [], childTraceSpans: [],
@@ -212,7 +213,7 @@ describe('WorkflowBlockHandler', () => {
expect(() => expect(() =>
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100) (handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
).toThrow('Error in child workflow "Child Workflow": Child workflow failed') ).toThrow('"Child Workflow" failed: Child workflow failed')
try { try {
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100) ;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
@@ -235,6 +236,7 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({ expect(result).toEqual({
success: true, success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow', childWorkflowName: 'Child Workflow',
result: { nested: 'data' }, result: { nested: 'data' },
childTraceSpans: [], childTraceSpans: [],

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types' import type { TraceSpan } from '@/lib/logs/types'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
@@ -52,6 +53,12 @@ export class WorkflowBlockHandler implements BlockHandler {
throw new Error('No workflow selected for execution') throw new Error('No workflow selected for execution')
} }
// Initialize with registry name, will be updated with loaded workflow name
const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
let childWorkflowName = workflowMetadata?.name || workflowId
let childWorkflowSnapshotId: string | undefined
try { try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1 const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) { if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
@@ -75,9 +82,8 @@ export class WorkflowBlockHandler implements BlockHandler {
throw new Error(`Child workflow ${workflowId} not found`) throw new Error(`Child workflow ${workflowId} not found`)
} }
const { workflows } = useWorkflowRegistry.getState() // Update with loaded workflow name (more reliable than registry)
const workflowMetadata = workflows[workflowId] childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
const childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow'
logger.info( logger.info(
`Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}` `Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}`
@@ -103,6 +109,12 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowInput = inputs.input childWorkflowInput = inputs.input
} }
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const subExecutor = new Executor({ const subExecutor = new Executor({
workflow: childWorkflow.serializedState, workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput, workflowInput: childWorkflowInput,
@@ -135,18 +147,14 @@ export class WorkflowBlockHandler implements BlockHandler {
workflowId, workflowId,
childWorkflowName, childWorkflowName,
duration, duration,
childTraceSpans childTraceSpans,
childWorkflowSnapshotId
) )
return mappedResult return mappedResult
} catch (error: unknown) { } catch (error: unknown) {
logger.error(`Error executing child workflow ${workflowId}:`, error) logger.error(`Error executing child workflow ${workflowId}:`, error)
const { workflows } = useWorkflowRegistry.getState()
const workflowMetadata = workflows[workflowId]
const childWorkflowName = workflowMetadata?.name || workflowId
const originalError = error instanceof Error ? error.message : 'Unknown error'
let childTraceSpans: WorkflowTraceSpan[] = [] let childTraceSpans: WorkflowTraceSpan[] = []
let executionResult: ExecutionResult | undefined let executionResult: ExecutionResult | undefined
@@ -165,16 +173,86 @@ export class WorkflowBlockHandler implements BlockHandler {
childTraceSpans = error.childTraceSpans childTraceSpans = error.childTraceSpans
} }
// Build a cleaner error message for nested workflow errors
const errorMessage = this.buildNestedWorkflowErrorMessage(childWorkflowName, error)
throw new ChildWorkflowError({ throw new ChildWorkflowError({
message: `Error in child workflow "${childWorkflowName}": ${originalError}`, message: errorMessage,
childWorkflowName, childWorkflowName,
childTraceSpans, childTraceSpans,
executionResult, executionResult,
childWorkflowSnapshotId,
cause: error instanceof Error ? error : undefined, cause: error instanceof Error ? error : undefined,
}) })
} }
} }
/**
* Builds a cleaner error message for nested workflow errors.
* Parses nested error messages to extract workflow chain and root error.
*/
private buildNestedWorkflowErrorMessage(childWorkflowName: string, error: unknown): string {
const originalError = error instanceof Error ? error.message : 'Unknown error'
// Extract any nested workflow names from the error message
const { chain, rootError } = this.parseNestedWorkflowError(originalError)
// Add current workflow to the beginning of the chain
chain.unshift(childWorkflowName)
// If we have a chain (nested workflows), format nicely
if (chain.length > 1) {
return `Workflow chain: ${chain.join(' → ')} | ${rootError}`
}
// Single workflow failure
return `"${childWorkflowName}" failed: ${rootError}`
}
/**
* Parses a potentially nested workflow error message to extract:
* - The chain of workflow names
* - The actual root error message (preserving the block prefix for the failing block)
*
* Handles formats like:
* - "workflow-name" failed: error
* - [block_type] Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error
*/
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
const chain: string[] = []
const remaining = message
// First, check if it's already in chain format
const chainMatch = remaining.match(/^Workflow chain: (.+?) \| (.+)$/)
if (chainMatch) {
const chainPart = chainMatch[1]
const errorPart = chainMatch[2]
chain.push(...chainPart.split(' → ').map((s) => s.trim()))
return { chain, rootError: errorPart }
}
// Extract workflow names from patterns like:
// - "workflow-name" failed:
// - [block_type] Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null
let lastIndex = 0
match = workflowPattern.exec(remaining)
while (match !== null) {
chain.push(match[1])
lastIndex = match.index + match[0].length
match = workflowPattern.exec(remaining)
}
// The root error is everything after the last match
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' }
}
private async loadChildWorkflow(workflowId: string) { private async loadChildWorkflow(workflowId: string) {
const headers = await buildAuthHeaders() const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/workflows/${workflowId}`) const url = buildAPIUrl(`/api/workflows/${workflowId}`)
@@ -211,6 +289,10 @@ export class WorkflowBlockHandler implements BlockHandler {
) )
const workflowVariables = (workflowData.variables as Record<string, any>) || {} const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
}
if (Object.keys(workflowVariables).length > 0) { if (Object.keys(workflowVariables).length > 0) {
logger.info( logger.info(
@@ -222,6 +304,7 @@ export class WorkflowBlockHandler implements BlockHandler {
name: workflowData.name, name: workflowData.name,
serializedState: serializedWorkflow, serializedState: serializedWorkflow,
variables: workflowVariables, variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks, rawBlocks: workflowState.blocks,
} }
} }
@@ -290,11 +373,16 @@ export class WorkflowBlockHandler implements BlockHandler {
) )
const workflowVariables = (wfData?.variables as Record<string, any>) || {} const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
}
return { return {
name: wfData?.name || DEFAULTS.WORKFLOW_NAME, name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
serializedState: serializedWorkflow, serializedState: serializedWorkflow,
variables: workflowVariables, variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks, rawBlocks: deployedState.blocks,
} }
} }
@@ -436,7 +524,8 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowId: string, childWorkflowId: string,
childWorkflowName: string, childWorkflowName: string,
duration: number, duration: number,
childTraceSpans?: WorkflowTraceSpan[] childTraceSpans?: WorkflowTraceSpan[],
childWorkflowSnapshotId?: string
): BlockOutput { ): BlockOutput {
const success = childResult.success !== false const success = childResult.success !== false
const result = childResult.output || {} const result = childResult.output || {}
@@ -444,15 +533,18 @@ export class WorkflowBlockHandler implements BlockHandler {
if (!success) { if (!success) {
logger.warn(`Child workflow ${childWorkflowName} failed`) logger.warn(`Child workflow ${childWorkflowName} failed`)
throw new ChildWorkflowError({ throw new ChildWorkflowError({
message: `Error in child workflow "${childWorkflowName}": ${childResult.error || 'Child workflow execution failed'}`, message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName, childWorkflowName,
childTraceSpans: childTraceSpans || [], childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
}) })
} }
return { return {
success: true, success: true,
childWorkflowName, childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result, result,
childTraceSpans: childTraceSpans || [], childTraceSpans: childTraceSpans || [],
} as Record<string, any> } as Record<string, any>

View File

@@ -276,7 +276,16 @@ export class LoopOrchestrator {
scope: LoopScope scope: LoopScope
): LoopContinuationResult { ): LoopContinuationResult {
const results = scope.allIterationOutputs const results = scope.allIterationOutputs
this.state.setBlockOutput(loopId, { results }, DEFAULTS.EXECUTION_TIME) const output = { results }
this.state.setBlockOutput(loopId, output, DEFAULTS.EXECUTION_TIME)
// Emit onBlockComplete for the loop container so the UI can track it
if (this.contextExtensions?.onBlockComplete) {
this.contextExtensions.onBlockComplete(loopId, 'Loop', 'loop', {
output,
executionTime: DEFAULTS.EXECUTION_TIME,
})
}
return { return {
shouldContinue: false, shouldContinue: false,

View File

@@ -31,7 +31,18 @@ export class NodeExecutionOrchestrator {
throw new Error(`Node not found in DAG: ${nodeId}`) throw new Error(`Node not found in DAG: ${nodeId}`)
} }
if (this.state.hasExecuted(nodeId)) { if (ctx.runFromBlockContext && !ctx.runFromBlockContext.dirtySet.has(nodeId)) {
const cachedOutput = this.state.getBlockOutput(nodeId) || {}
logger.debug('Skipping non-dirty block in run-from-block mode', { nodeId })
return {
nodeId,
output: cachedOutput,
isFinalOutput: false,
}
}
const isDirtyBlock = ctx.runFromBlockContext?.dirtySet.has(nodeId) ?? false
if (!isDirtyBlock && this.state.hasExecuted(nodeId)) {
const output = this.state.getBlockOutput(nodeId) || {} const output = this.state.getBlockOutput(nodeId) || {}
return { return {
nodeId, nodeId,

View File

@@ -260,9 +260,17 @@ export class ParallelOrchestrator {
const branchOutputs = scope.branchOutputs.get(i) || [] const branchOutputs = scope.branchOutputs.get(i) || []
results.push(branchOutputs) results.push(branchOutputs)
} }
this.state.setBlockOutput(parallelId, { const output = { results }
results, this.state.setBlockOutput(parallelId, output)
})
// Emit onBlockComplete for the parallel container so the UI can track it
if (this.contextExtensions?.onBlockComplete) {
this.contextExtensions.onBlockComplete(parallelId, 'Parallel', 'parallel', {
output,
executionTime: 0,
})
}
return { return {
allBranchesComplete: true, allBranchesComplete: true,
results, results,

View File

@@ -1,6 +1,7 @@
import type { TraceSpan } from '@/lib/logs/types' import type { TraceSpan } from '@/lib/logs/types'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import type { RunFromBlockContext } from '@/executor/utils/run-from-block'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
export interface UserFile { export interface UserFile {
@@ -113,6 +114,12 @@ export interface BlockLog {
loopId?: string loopId?: string
parallelId?: string parallelId?: string
iterationIndex?: number iterationIndex?: number
/**
* Child workflow trace spans for nested workflow execution.
* Stored separately from output to keep output clean for display
* while preserving data for trace-spans processing.
*/
childTraceSpans?: TraceSpan[]
} }
export interface ExecutionMetadata { export interface ExecutionMetadata {
@@ -250,6 +257,17 @@ export interface ExecutionContext {
* will not have their base64 content fetched. * will not have their base64 content fetched.
*/ */
base64MaxBytes?: number base64MaxBytes?: number
/**
* Context for "run from block" mode. When present, only blocks in dirtySet
* will be executed; others return cached outputs from the source snapshot.
*/
runFromBlockContext?: RunFromBlockContext
/**
* Stop execution after this block completes. Used for "run until block" feature.
*/
stopAfterBlockId?: string
} }
export interface ExecutionResult { export interface ExecutionResult {

View File

@@ -1,3 +1,4 @@
import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { isHiddenFromDisplay } from '@/blocks/types' import { isHiddenFromDisplay } from '@/blocks/types'
import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants' import { isTriggerBehavior, isTriggerInternalKey } from '@/executor/constants'
@@ -7,6 +8,7 @@ import type { SerializedBlock } from '@/serializer/types'
/** /**
* Filters block output for logging/display purposes. * Filters block output for logging/display purposes.
* Removes internal fields and fields marked with hiddenFromDisplay. * Removes internal fields and fields marked with hiddenFromDisplay.
* Also recursively filters globally hidden keys from nested objects.
* *
* @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow') * @param blockType - The block type string (e.g., 'human_in_the_loop', 'workflow')
* @param output - The raw block output to filter * @param output - The raw block output to filter
@@ -44,7 +46,8 @@ export function filterOutputForLog(
continue continue
} }
filtered[key] = value // Recursively filter globally hidden keys from nested objects
filtered[key] = filterHiddenOutputKeys(value)
} }
return filtered return filtered

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
import { LOOP, PARALLEL } from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
/**
* Builds the sentinel-start node ID for a loop.
*/
function buildLoopSentinelStartId(loopId: string): string {
return `${LOOP.SENTINEL.PREFIX}${loopId}${LOOP.SENTINEL.START_SUFFIX}`
}
/**
* Builds the sentinel-start node ID for a parallel.
*/
function buildParallelSentinelStartId(parallelId: string): string {
return `${PARALLEL.SENTINEL.PREFIX}${parallelId}${PARALLEL.SENTINEL.START_SUFFIX}`
}
/**
* Checks if a block ID is a loop or parallel container and returns the sentinel-start ID if so.
* Returns null if the block is not a container.
*/
export function resolveContainerToSentinelStart(blockId: string, dag: DAG): string | null {
if (dag.loopConfigs.has(blockId)) {
return buildLoopSentinelStartId(blockId)
}
if (dag.parallelConfigs.has(blockId)) {
return buildParallelSentinelStartId(blockId)
}
return null
}
/**
* Result of validating a block for run-from-block execution.
*/
export interface RunFromBlockValidation {
valid: boolean
error?: string
}
/**
* Context for run-from-block execution mode.
*/
export interface RunFromBlockContext {
/** The block ID to start execution from */
startBlockId: string
/** Set of block IDs that need re-execution (start block + all downstream) */
dirtySet: Set<string>
}
/**
* Result of computing execution sets for run-from-block mode.
*/
export interface ExecutionSets {
/** Blocks that need re-execution (start block + all downstream) */
dirtySet: Set<string>
/** Blocks that are upstream (ancestors) of the start block */
upstreamSet: Set<string>
/** Blocks that are upstream of any dirty block (for snapshot preservation) */
reachableUpstreamSet: Set<string>
}
/**
* Computes the dirty set, upstream set, and reachable upstream set.
* - Dirty set: start block + all blocks reachable via outgoing edges (need re-execution)
* - Upstream set: all blocks reachable via incoming edges from the start block
* - Reachable upstream set: all non-dirty blocks that are upstream of ANY dirty block
* (includes sibling branches that dirty blocks may reference)
*
* For loop/parallel containers, starts from the sentinel-start node and includes
* the container ID itself in the dirty set.
*
* @param dag - The workflow DAG
* @param startBlockId - The block to start execution from
* @returns Object containing dirtySet, upstreamSet, and reachableUpstreamSet
*/
export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionSets {
const dirty = new Set<string>([startBlockId])
const upstream = new Set<string>()
const sentinelStartId = resolveContainerToSentinelStart(startBlockId, dag)
const traversalStartId = sentinelStartId ?? startBlockId
if (sentinelStartId) {
dirty.add(sentinelStartId)
}
// BFS downstream for dirty set
const downstreamQueue = [traversalStartId]
while (downstreamQueue.length > 0) {
const nodeId = downstreamQueue.shift()!
const node = dag.nodes.get(nodeId)
if (!node) continue
for (const [, edge] of node.outgoingEdges) {
if (!dirty.has(edge.target)) {
dirty.add(edge.target)
downstreamQueue.push(edge.target)
}
}
}
// BFS upstream from start block for upstream set
const upstreamQueue = [traversalStartId]
while (upstreamQueue.length > 0) {
const nodeId = upstreamQueue.shift()!
const node = dag.nodes.get(nodeId)
if (!node) continue
for (const sourceId of node.incomingEdges) {
if (!upstream.has(sourceId)) {
upstream.add(sourceId)
upstreamQueue.push(sourceId)
}
}
}
// Compute reachable upstream: all non-dirty blocks upstream of ANY dirty block
// This handles the case where a dirty block (like C in A->C, B->C) may reference
// sibling branches (like B when running from A)
const reachableUpstream = new Set<string>()
for (const dirtyNodeId of dirty) {
const node = dag.nodes.get(dirtyNodeId)
if (!node) continue
// BFS upstream from this dirty node
const queue = [...node.incomingEdges]
while (queue.length > 0) {
const sourceId = queue.shift()!
if (reachableUpstream.has(sourceId) || dirty.has(sourceId)) continue
reachableUpstream.add(sourceId)
const sourceNode = dag.nodes.get(sourceId)
if (sourceNode) {
queue.push(...sourceNode.incomingEdges)
}
}
}
return { dirtySet: dirty, upstreamSet: upstream, reachableUpstreamSet: reachableUpstream }
}
/**
* Validates that a block can be used as a run-from-block starting point.
*
* Validation rules:
* - Block must exist in the DAG (or be a loop/parallel container)
* - Block cannot be inside a loop (but loop containers are allowed)
* - Block cannot be inside a parallel (but parallel containers are allowed)
* - Block cannot be a sentinel node
* - All upstream dependencies must have been executed (have cached outputs)
*
* @param blockId - The block ID to validate
* @param dag - The workflow DAG
* @param executedBlocks - Set of blocks that were executed in the source run
* @returns Validation result with error message if invalid
*/
export function validateRunFromBlock(
blockId: string,
dag: DAG,
executedBlocks: Set<string>
): RunFromBlockValidation {
const node = dag.nodes.get(blockId)
const isLoopContainer = dag.loopConfigs.has(blockId)
const isParallelContainer = dag.parallelConfigs.has(blockId)
const isContainer = isLoopContainer || isParallelContainer
if (!node && !isContainer) {
return { valid: false, error: `Block not found in workflow: ${blockId}` }
}
if (isContainer) {
const sentinelStartId = resolveContainerToSentinelStart(blockId, dag)
if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) {
return {
valid: false,
error: `Container sentinel not found for: ${blockId}`,
}
}
}
if (node) {
if (node.metadata.isLoopNode) {
return {
valid: false,
error: `Cannot run from block inside loop: ${node.metadata.loopId}`,
}
}
if (node.metadata.isParallelBranch) {
return {
valid: false,
error: `Cannot run from block inside parallel: ${node.metadata.parallelId}`,
}
}
if (node.metadata.isSentinel) {
return { valid: false, error: 'Cannot run from sentinel node' }
}
// Check immediate upstream dependencies were executed
for (const sourceId of node.incomingEdges) {
const sourceNode = dag.nodes.get(sourceId)
// Skip sentinel nodes - they're internal and not in executedBlocks
if (sourceNode?.metadata.isSentinel) continue
// Skip trigger nodes - they're entry points and don't need prior execution
// A trigger node has no incoming edges
if (sourceNode && sourceNode.incomingEdges.size === 0) continue
if (!executedBlocks.has(sourceId)) {
return {
valid: false,
error: `Upstream dependency not executed: ${sourceId}`,
}
}
}
}
return { valid: true }
}

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