Compare commits

...

54 Commits

Author SHA1 Message Date
Vikhyath Mondreti
5d74db53ff v0.3.33: update copilot docs 2025-08-20 09:56:09 -07:00
Siddharth Ganesan
b39bdfd55e feat(copilot-docs): update readme and docs with local hosting instructions (#1043)
* Docs

* Lint
2025-08-20 09:47:50 -07:00
Waleed Latif
6b185be9a4 v0.3.32: loop block max increase, url-encoded API calls, subflow logs, new supabase tools 2025-08-20 00:36:46 -07:00
Waleed Latif
214a0358b6 fix(billing): fix upgrade to team plan (#1045) 2025-08-20 00:28:07 -07:00
Waleed Latif
bbb5e53e43 improvement(supabase): add supabase upsert tool, insert/replace on PK conflict (#1038) 2025-08-19 21:21:09 -07:00
Waleed Latif
79e932fed9 feat(logs): added sub-workflow logs, updated trace spans UI, fix scroll behavior in workflow registry sidebar (#1037)
* added sub-workflow logs

* indent input/output in trace spans display

* better color scheme for workflow logs

* scroll behavior in sidebar updated

* cleanup

* fixed failing tests
2025-08-19 21:21:09 -07:00
Vikhyath Mondreti
9ad36c0e34 fix(oauth-block): race condition for rendering credential selectors and other subblocks + gdrive fixes (#1029)
* fix(oauth-block): race condition for rendering credential selectors and other subblocks

* fix import

* add dependsOn field to track cros-subblock deps

* remove redundant check

* remove redundant checks

* remove misleading comment

* fix

* fix jira

* fix

* fix

* confluence

* fix triggers

* fix

* fix

* make trigger creds collab supported

* fix for backwards compat

* fix trigger modal
2025-08-19 21:21:09 -07:00
Waleed Latif
2771c688ff improvement(supabase): added more verbose error logging for supabase operations (#1035)
* improvement(supabase): added more verbose error logging for supabase operations

* updated docs
2025-08-19 21:21:09 -07:00
Waleed Latif
d58ceb4bce improvement(api): add native support for form-urlencoded inputs into API block (#1033) 2025-08-19 21:21:09 -07:00
Waleed Latif
69773c3174 improvement(console): increase console max entries for larger workflows (#1032)
* improvement(console): increase console max entries for larger workflows

* increase safety limit for infinite loops
2025-08-19 21:21:09 -07:00
Waleed Latif
1619d63f2a v0.3.31: webhook fixes, advanced mode parameter filtering, credentials fixes, UI/UX improvements 2025-08-19 01:01:45 -07:00
Waleed Latif
9aa1fe8037 fix(logger): fixed logger to show prod server-side logs (#1027) 2025-08-19 00:44:24 -07:00
Emir Karabeg
1b7c111c46 Update README.md (#1026)
* Update README.md

* Update README.md
2025-08-18 23:10:18 -07:00
Siddharth Ganesan
bdfb56b262 fix(copilot): streaming (#1023)
* Fix 1

* Fix

* Bugfix

* Make thinking streaming smoother

* Better autoscroll, still not great

* Updates

* Updates

* Updates

* Restore checkpoitn logic

* Fix aborts

* Checkpoitn ui

* Lint

* Fix empty file
2025-08-18 22:48:56 -07:00
Emir Karabeg
4a7de31eee uploaded brandbook (#1024) 2025-08-18 22:04:55 -07:00
Waleed Latif
adfe56c720 improvement(logger): restore server-side logs in prod (#1022) 2025-08-18 21:01:38 -07:00
Emir Karabeg
72e3efa875 improvement(settings): ui/ux (#1021)
* completed general

* completed environment

* completed account; updated general and environment

* fixed skeleton

* finished credentials

* finished privacy; adjusted all colors and styling

* added reset password

* refactor: team and subscription

* finalized subscription settings

* fixed copilot key UI
2025-08-18 20:57:29 -07:00
Vikhyath Mondreti
b40fa3aa6e fix(picker-ui): picker UI confusing when credential not set + Microsoft OAuth Fixes (#1016)
* fix(picker-ui): picker UI confusing when credential not set

* remove comments

* remove chevron down

* fix collaboration oauth

* fix jira"

* fix

* fix ms excel selector

* fix selectors for MS blocks

* fix ms selectors

* fix

* fix ms onedrive and sharepoint

* fix to grey out dropdowns

* fix background fetches

* fix planner

* fix confluence

* fix

* fix confluence realtime sharing

* fix outlook folder selector

* check outlook folder

* make shared hook

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-18 20:21:23 -07:00
Waleed Latif
f924edde3a improvement(console): redact api keys from console store (#1020) 2025-08-18 16:36:33 -07:00
Waleed Latif
073030bfaa improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution (#1018)
* improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution

* fix serializer exclusion logic
2025-08-18 16:34:53 -07:00
Siddharth Ganesan
871f4e8e18 fix(copilot): env key validation (#1017)
* Fix v1

* Use env var

* Lint

* Fix env key validation

* Remove logger

* Fix agent url

* Fix tests
2025-08-18 16:00:56 -07:00
Siddharth Ganesan
091343a132 fix(copilot): fix origin (#1015)
* Fix v1

* Use env var

* Lint
2025-08-18 13:57:31 -07:00
Waleed Latif
63c66bfc31 fix(webhook): pin webhook URL when creating/saving generic webhook trigger (#1014)
* fix(webhook): pin webhook URL when creating a new generic webhook trigger

* change instructions copy

* remove unrelated scripts

* added optional API key for webhooks, validation tests

* remove extraneous logs
2025-08-18 13:39:49 -07:00
Waleed Latif
445ca78395 fix(export): swap upload & download icons (#1013) 2025-08-18 10:22:55 -07:00
Waleed Latif
d75cc1ed84 v0.3.30: duplication, control bar fixes 2025-08-18 08:57:26 -07:00
Waleed Latif
5a8a703ecb fix(duplicate): fixed detached state on duplication (#1011) 2025-08-18 08:51:18 -07:00
Waleed Latif
6f64188b8d fix(control-bar): fix icons styling in disabled state (#1010) 2025-08-18 08:22:06 -07:00
Vikhyath Mondreti
60a9a25553 Merge pull request #1009 from simstudioai/staging
update migration file for notekeeping purpose
2025-08-18 01:59:02 -07:00
Vikhyath Mondreti
52fa388f81 update migration file for notekeeping purpose 2025-08-18 01:56:34 -07:00
Vikhyath Mondreti
5c56cbd558 Merge pull request #1008 from simstudioai/staging
reduce batch size to prevent timeouts
2025-08-18 01:11:49 -07:00
Vikhyath Mondreti
dc19525a6f reduce batch size to prevent timeouts 2025-08-18 01:10:47 -07:00
Vikhyath Mondreti
3873f44875 Merge pull request #1007 from simstudioai/staging
syntax issue in migration
2025-08-18 00:59:53 -07:00
Vikhyath Mondreti
09b95f41ea syntax issue in migration 2025-08-18 00:58:09 -07:00
Vikhyath Mondreti
af60ccd188 fix: migration mem issues bypass
fix: migration mem issues bypass
2025-08-18 00:50:20 -07:00
Vikhyath Mondreti
eb75afd115 make logs migration batched to prevent mem issues (#1005) 2025-08-18 00:42:38 -07:00
Waleed Latif
fdb8256468 fix(subflow): remove all edges when removing a block from a subflow (#1003) 2025-08-18 00:21:26 -07:00
Vikhyath Mondreti
570c07bf2a Merge pull request #1004 from simstudioai/staging
v0.3.29: copilot fixes, remove block from subflow, code cleanups
2025-08-18 00:18:44 -07:00
Adam Gough
5c16e7d390 fix(subflow): add ability to remove block from subflow and refactor to consolidate subflow code (#983)
* added logic to remove blocks from subflows

* refactored logic into just subflow-node

* bun run lint

* added subflow test

* added a safety check for data.parentId

* added state update logic

* bun run lint

* removed old logic

* removed any

* added tests

* added type safety

* removed test script

* type safety

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-17 22:25:31 -07:00
Waleed Latif
bd38062705 fix(workflow-error): allow users to delete workflows with invalid configs/state (#1000)
* fix(workflow-error): allow users to delete workflows with invalid configs/state

* cleanup
2025-08-17 22:23:41 -07:00
Siddharth Ganesan
d7fd4a9618 feat(copilot): diff improvements (#1002)
* Fix abort

* Cred updates

* Updates

* Fix sheet id showing up in diff view

* Update diff view

* Text overflow

* Optimistic accept

* Serialization catching

* Depth 0 fix

* Fix icons

* Updates

* Lint
2025-08-16 15:09:48 -07:00
Vikhyath Mondreti
d972bab206 fix(logs-sidebar): remove message and fix race condition for quickly switching b/w logs (#1001) 2025-08-16 15:05:39 -07:00
Vikhyath Mondreti
f254d70624 improvement(logs): cleanup code (#999) 2025-08-16 13:44:00 -07:00
Waleed Latif
8748e1d5f9 improvement(db): remove deprecated 'state' column from workflow table (#994)
* improvement(db): remove deprecated  column from workflow table

* removed extraneous logs

* update sockets envvar
2025-08-16 13:04:49 -07:00
Siddharth Ganesan
133a32e6d3 Fix abort (#998) 2025-08-16 11:10:09 -07:00
Waleed Latif
97b6bcc43d v0.3.28: autolayout, export, copilot, kb ui improvements 2025-08-16 09:12:17 -07:00
Waleed Latif
42917ce641 fix(agent): stringify input into user prompt for agent (#984) 2025-08-15 19:36:49 -07:00
Waleed Latif
5f6d219223 fix(kb-ui): fixed upload files modal ui, processing ui to match the rest of the kb (#991)
* fix(kb-ui): fixed upload files modal, processing ui to match the rest of the kb

* more ui fixes

* ack PR comments

* fix help modal
2025-08-15 19:35:50 -07:00
Siddharth Ganesan
bab74307f4 fix(ishosted): make ishosted true on staging (#993)
* Add staging to ishosted

* www
2025-08-15 18:36:32 -07:00
Siddharth Ganesan
16aaa37dad improvement(agent): enable autolayout, export, copilot (#992)
* Enable autolayout, export, and copilot in dev

* Updates
2025-08-15 18:29:34 -07:00
Siddharth Ganesan
c6166a9483 feat(copilot): generate agent api key (#989)
* Add skeleton copilot to settings modal and add migration for copilot api keys

* Add hash index on encrypted key

* Security 1

* Remove sim agent api key

* Fix api key stuff

* Auth

* Status code handling

* Update env key

* Copilot api key ui

* Update copilot costs

* Add copilot stats

* Lint

* Remove logs

* Remove migrations

* Remove another migration

* Updates

* Hide if hosted

* Fix test

* Lint

* Lint

* Fixes

* Lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-15 18:05:54 -07:00
Waleed Latif
0258a1b4ce fix(loading): fix workflow detached on first load (#987) 2025-08-15 17:26:47 -07:00
Vikhyath Mondreti
4d4aefa346 fix(envvar): clear separation between server-side and client-side billing envvar (#988) 2025-08-15 16:41:02 -07:00
Vikhyath Mondreti
a0cf003abf Merge pull request #986 from simstudioai/staging
attempt to fix build issues (#985)
2025-08-15 15:22:26 -07:00
Vikhyath Mondreti
2e027dd77d attempt to fix build issues (#985) 2025-08-15 15:21:34 -07:00
280 changed files with 27384 additions and 6661 deletions

View File

@@ -1,50 +1,46 @@
<p align="center">
<img src="apps/sim/public/static/sim.png" alt="Sim Logo" width="500"/>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
</a>
</p>
<p align="center">
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
<a href="https://docs.sim.ai"><img src="https://img.shields.io/badge/Docs-visit%20documentation-blue.svg" alt="Documentation"></a>
</p>
<p align="center">Build and deploy AI agent workflows in minutes.</p>
<p align="center">
<strong>Sim</strong> is a lightweight, user-friendly platform for building AI agent workflows.
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
</p>
## Getting Started
## Quickstart
1. Use our [cloud-hosted version](https://sim.ai)
2. Self-host using one of the methods below
### Cloud-hosted: [sim.ai](https://sim.ai)
## Self-Hosting Options
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
### Option 1: NPM Package (Simplest)
The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
### Self-hosted: NPM Package
```bash
npx simstudio
```
→ http://localhost:3000
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
#### Note
Docker must be installed and running on your machine.
#### Options
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
- `--no-pull`: Skip pulling the latest Docker images
| Flag | Description |
|------|-------------|
| `-p, --port <port>` | Port to run Sim on (default `3000`) |
| `--no-pull` | Skip pulling latest Docker images |
#### Requirements
- Docker must be installed and running on your machine
### Option 2: Docker Compose
### Self-hosted: Docker Compose
```bash
# Clone the repository
@@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
```
### Option 3: Dev Containers
### Self-hosted: Dev Containers
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open the project and click "Reopen in Container" when prompted
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
- This starts both the main application and the realtime socket server
### Option 4: Manual Setup
### Self-hosted: Manual Setup
**Requirements:**
- [Bun](https://bun.sh/) runtime
@@ -158,6 +154,14 @@ cd apps/sim
bun run dev:sockets
```
## Copilot API Keys
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
- Host Sim on a publicly available DNS and set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to that value ([ngrok](https://ngrok.com/))
## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
@@ -180,4 +184,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
<p align="center">Made with ❤️ by the Sim Team</p>
<p align="center">Made with ❤️ by the Sim Team</p>

View File

@@ -0,0 +1,97 @@
---
title: Copilot
description: Build and edit workflows with Sim Copilot
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
## What is Copilot
Copilot is your in-editor assistant that helps you build, understand, and improve workflows. It can:
- **Explain**: Answer questions about Sim and your current workflow
- **Guide**: Suggest edits and best practices
- **Edit**: Make changes to blocks, connections, and settings when you approve
<Callout type="info">
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
3. Host Sim on a publicly available DNS and set `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` to that value (e.g., using ngrok)
</Callout>
## Modes
<Cards>
<Card title="Ask">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">
Q&A mode for explanations, guidance, and suggestions without making changes to your workflow.
</p>
</div>
</div>
</Card>
<Card title="Agent">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Package className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
</p>
</div>
</div>
</Card>
</Cards>
## Depth Levels
<Cards>
<Card title="Fast">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Zap className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</p>
</div>
</div>
</Card>
<Card title="Auto">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</p>
</div>
</div>
</Card>
<Card title="Pro">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Brain className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</p>
</div>
</div>
</Card>
<Card title="Max">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</p>
</div>
</div>
</Card>
</Cards>

View File

@@ -0,0 +1,4 @@
{
"title": "Copilot",
"pages": ["index"]
}

View File

@@ -12,6 +12,8 @@
"connections",
"---Execution---",
"execution",
"---Copilot---",
"copilot",
"---Advanced---",
"./variables/index",
"yaml",

View File

@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `results` | object | The row data if found, null if not found |
| `results` | array | Array containing the row data if found, empty array if not found |
### `supabase_update`
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
| `message` | string | Operation status message |
| `results` | array | Array of deleted records |
### `supabase_upsert`
Insert or update data in a Supabase table (upsert operation)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
| `table` | string | Yes | The name of the Supabase table to upsert data into |
| `data` | any | Yes | The data to upsert \(insert or update\) |
| `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `results` | array | Array of upserted records |
## Notes

View File

@@ -3,8 +3,7 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { env } from '@/lib/env'
import { isBillingEnabled, isProd } from '@/lib/environment'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userStats } from '@/db/schema'
@@ -17,6 +16,7 @@ const UpdateCostSchema = z.object({
input: z.number().min(0, 'Input tokens must be a non-negative number'),
output: z.number().min(0, 'Output tokens must be a non-negative number'),
model: z.string().min(1, 'Model is required'),
multiplier: z.number().min(0),
})
/**
@@ -75,27 +75,27 @@ export async function POST(req: NextRequest) {
)
}
const { userId, input, output, model } = validation.data
const { userId, input, output, model, multiplier } = validation.data
logger.info(`[${requestId}] Processing cost update`, {
userId,
input,
output,
model,
multiplier,
})
const finalPromptTokens = input
const finalCompletionTokens = output
const totalTokens = input + output
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
// Calculate cost using provided multiplier (required)
const costResult = calculateCost(
model,
finalPromptTokens,
finalCompletionTokens,
false,
copilotMultiplier
multiplier
)
logger.info(`[${requestId}] Cost calculation result`, {
@@ -104,7 +104,7 @@ export async function POST(req: NextRequest) {
promptTokens: finalPromptTokens,
completionTokens: finalCompletionTokens,
totalTokens: totalTokens,
copilotMultiplier,
multiplier,
costResult,
})
@@ -127,6 +127,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: totalTokens,
totalCost: costToStore.toString(),
currentPeriodCost: costToStore.toString(),
// Copilot usage tracking
totalCopilotCost: costToStore.toString(),
totalCopilotTokens: totalTokens,
totalCopilotCalls: 1,
lastActive: new Date(),
})
@@ -141,6 +145,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
// Copilot usage tracking increments
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalApiCalls: sql`total_api_calls`,
lastActive: new Date(),
}

View File

@@ -0,0 +1,70 @@
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateApiKey } from '@/lib/utils'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeysGenerate')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function encryptRandomIv(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
const rawKey = generateApiKey().replace(/^sim_/, '')
const plaintextKey = `sk-sim-copilot-${rawKey}`
// Encrypt with random IV for confidentiality
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Compute deterministic lookup value for O(1) search
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
const [inserted] = await db
.insert(copilotApiKeys)
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
.returning({ id: copilotApiKeys.id })
return NextResponse.json(
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
{ status: 201 }
)
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { createDecipheriv, createHash } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeys')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
throw new Error('Invalid encrypted value format')
}
const [ivHex, encryptedHex, authTagHex] = parts
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
const rows = await db
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.userId, userId))
const keys = rows.map((row) => ({
id: row.id,
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
}))
return NextResponse.json({ keys }, { status: 200 })
} catch (error) {
logger.error('Failed to get copilot API keys', { error })
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
await db
.delete(copilotApiKeys)
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete copilot API key', { error })
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { createHmac } from 'crypto'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys, userStats } from '@/db/schema'
const logger = createLogger('CopilotApiKeysValidate')
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const body = await req.json().catch(() => null)
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
if (!apiKey) {
return new NextResponse(null, { status: 401 })
}
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Find matching API key and its user
const rows = await db
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
.limit(1)
if (rows.length === 0) {
return new NextResponse(null, { status: 401 })
}
const { userId } = rows[0]
// Check usage for the associated user
const usage = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
totalCost: userStats.totalCost,
currentUsageLimit: userStats.currentUsageLimit,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (usage.length > 0) {
const currentUsage = Number.parseFloat(
(usage[0].currentPeriodCost?.toString() as string) ||
(usage[0].totalCost as unknown as string) ||
'0'
)
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
// Usage exceeded
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
return new NextResponse(null, { status: 402 })
}
}
// Valid and within usage limits
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('Error validating copilot API key', { error })
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
}
}

View File

@@ -104,7 +104,8 @@ describe('Copilot Chat API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
SIM_AGENT_API_URL: 'http://localhost:8000',
SIM_AGENT_API_KEY: 'test-sim-agent-key',
COPILOT_API_KEY: 'test-sim-agent-key',
BETTER_AUTH_URL: 'http://localhost:3000',
},
}))
@@ -225,6 +226,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -288,6 +290,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -343,6 +346,7 @@ describe('Copilot Chat API Route', () => {
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -438,6 +442,7 @@ describe('Copilot Chat API Route', () => {
mode: 'ask',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)

View File

@@ -1,3 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { downloadFile } from '@/lib/uploads'
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
@@ -23,6 +25,46 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
const logger = createLogger('CopilotChatAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
function getRequestOrigin(_req: NextRequest): string {
try {
// Strictly use configured Better Auth URL
return env.BETTER_AUTH_URL || ''
} catch (_) {
return ''
}
}
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error('Invalid encrypted format')
}
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
function encryptWithKey(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
// Schema for file attachments
const FileAttachmentSchema = z.object({
id: z.string(),
@@ -39,7 +81,8 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
mode: z.enum(['ask', 'agent']).optional().default('agent'),
depth: z.number().int().min(0).max(3).optional().default(0),
depth: z.number().int().min(-2).max(3).optional().default(0),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
@@ -48,10 +91,6 @@ const ChatMessageSchema = z.object({
conversationId: z.string().optional(),
})
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
/**
* Generate a chat title using LLM
*/
@@ -160,6 +199,7 @@ export async function POST(req: NextRequest) {
workflowId,
mode,
depth,
prefetch,
createNewChat,
stream,
implicitFeedback,
@@ -168,6 +208,27 @@ export async function POST(req: NextRequest) {
conversationId,
} = ChatMessageSchema.parse(body)
// Derive request origin for downstream service
const requestOrigin = getRequestOrigin(req)
if (!requestOrigin) {
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
}
// Consolidation mapping: map negative depths to base depth with prefetch=true
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
let effectivePrefetch: boolean | undefined = prefetch
if (typeof effectiveDepth === 'number') {
if (effectiveDepth === -2) {
effectiveDepth = 1
effectivePrefetch = true
} else if (effectiveDepth === -1) {
effectiveDepth = 0
effectivePrefetch = true
}
}
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
userId: authenticatedUserId,
workflowId,
@@ -179,6 +240,9 @@ export async function POST(req: NextRequest) {
hasImplicitFeedback: !!implicitFeedback,
provider: provider || 'openai',
hasConversationId: !!conversationId,
depth,
prefetch,
origin: requestOrigin,
})
// Handle chat context
@@ -341,34 +405,68 @@ export async function POST(req: NextRequest) {
(currentChat?.conversationId as string | undefined) || conversationId
// If we have a conversationId, only send the most recent user message; else send full history
const messagesForAgent = effectiveConversationId ? [messages[messages.length - 1]] : messages
const latestUserMessage =
[...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1]
const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages
const requestPayload = {
messages: messagesForAgent,
workflowId,
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
mode: mode,
provider: providerToUse,
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(requestOrigin ? { origin: requestOrigin } : {}),
}
// Log the payload being sent to the streaming endpoint
try {
logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, {
url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
provider: providerToUse,
mode,
stream,
workflowId,
hasConversationId: !!effectiveConversationId,
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
messagesCount: requestPayload.messages.length,
...(requestOrigin ? { origin: requestOrigin } : {}),
})
// Full payload as JSON string
logger.info(
`[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}`
)
} catch (e) {
logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e)
}
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
messages: messagesForAgent,
workflowId,
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
mode: mode,
provider: providerToUse,
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
...(typeof depth === 'number' ? { depth } : {}),
...(session?.user?.name && { userName: session.user.name }),
}),
body: JSON.stringify(requestPayload),
})
if (!simAgentResponse.ok) {
const errorText = await simAgentResponse.text()
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
// Rethrow status only; client will render appropriate assistant message
return new NextResponse(null, { status: simAgentResponse.status })
}
const errorText = await simAgentResponse.text().catch(() => '')
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
status: simAgentResponse.status,
error: errorText,
})
return NextResponse.json(
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
{ status: simAgentResponse.status }
@@ -398,6 +496,12 @@ export async function POST(req: NextRequest) {
let isFirstDone = true
let responseIdFromStart: string | undefined
let responseIdFromDone: string | undefined
// Track tool call progress to identify a safe done event
const announcedToolCallIds = new Set<string>()
const startedToolExecutionIds = new Set<string>()
const completedToolExecutionIds = new Set<string>()
let lastDoneResponseId: string | undefined
let lastSafeDoneResponseId: string | undefined
// Send chatId as first event
if (actualChatId) {
@@ -515,6 +619,9 @@ export async function POST(req: NextRequest) {
)
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
announcedToolCallIds.add(event.data.id)
}
}
break
@@ -524,6 +631,14 @@ export async function POST(req: NextRequest) {
toolName: event.toolName,
status: event.status,
})
if (event.toolCallId) {
if (event.status === 'completed') {
startedToolExecutionIds.add(event.toolCallId)
completedToolExecutionIds.add(event.toolCallId)
} else {
startedToolExecutionIds.add(event.toolCallId)
}
}
break
case 'tool_result':
@@ -534,6 +649,9 @@ export async function POST(req: NextRequest) {
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
resultSize: JSON.stringify(event.result).length,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_error':
@@ -543,6 +661,9 @@ export async function POST(req: NextRequest) {
error: event.error,
success: event.success,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'start':
@@ -557,9 +678,25 @@ export async function POST(req: NextRequest) {
case 'done':
if (event.data?.responseId) {
responseIdFromDone = event.data.responseId
lastDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
)
// Mark this done as safe only if no tool call is currently in progress or pending
const announced = announcedToolCallIds.size
const completed = completedToolExecutionIds.size
const started = startedToolExecutionIds.size
const hasToolInProgress = announced > completed || started > completed
if (!hasToolInProgress) {
lastSafeDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
)
} else {
logger.info(
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
)
}
}
if (isFirstDone) {
logger.info(
@@ -654,7 +791,9 @@ export async function POST(req: NextRequest) {
)
}
const responseId = responseIdFromDone || responseIdFromStart
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
// Update chat in database immediately (without title)
await db

View File

@@ -48,11 +48,6 @@ async function updateToolCallStatus(
while (Date.now() - startTime < timeout) {
const exists = await redis.exists(key)
if (exists) {
logger.info('Tool call found in Redis, updating status', {
toolCallId,
key,
pollDuration: Date.now() - startTime,
})
break
}
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
timestamp: new Date().toISOString(),
}
// Log what we're about to update in Redis
logger.info('About to update Redis with tool call data', {
toolCallId,
key,
toolCallData,
serializedData: JSON.stringify(toolCallData),
providedStatus: status,
providedMessage: message,
messageIsUndefined: message === undefined,
messageIsNull: message === null,
})
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
logger.info('Tool call status updated in Redis', {
toolCallId,
key,
status,
message,
pollDuration: Date.now() - startTime,
})
return true
} catch (error) {
logger.error('Failed to update tool call status in Redis', {
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
userId: authenticatedUserId,
toolCallId,
status,
message,
})
// Update the tool call status in Redis
const updated = await updateToolCallStatus(toolCallId, status, message)
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
}
const duration = tracker.getDuration()
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
duration,
})
return NextResponse.json({
success: true,

View File

@@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: 'test-secret-key',
COPILOT_API_KEY: 'test-copilot-key',
},
}))
@@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => {
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'Invalid API key',
})
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 401 when internal API key is not configured', async () => {
@@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: undefined,
COPILOT_API_KEY: 'test-copilot-key',
},
}))
@@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => {
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'Internal API key not configured',
})
expect(responseData.status).toBeUndefined()
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 400 for invalid request body - missing methodId', async () => {

View File

@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
import type { NotificationStatus } from '@/lib/copilot/types'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { getRedisClient } from '@/lib/redis'
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
@@ -69,12 +69,6 @@ async function pollRedisForTool(
const pollInterval = 1000 // 1 second
const startTime = Date.now()
logger.info('Starting to poll Redis for tool call status', {
toolCallId,
timeout,
pollInterval,
})
while (Date.now() - startTime < timeout) {
try {
const redisValue = await redis.get(key)
@@ -112,23 +106,6 @@ async function pollRedisForTool(
rawRedisValue: redisValue,
})
logger.info('Tool call status resolved', {
toolCallId,
status,
message,
duration: Date.now() - startTime,
rawRedisValue: redisValue,
parsedAsJSON: redisValue
? (() => {
try {
return JSON.parse(redisValue)
} catch {
return 'failed-to-parse'
}
})()
: null,
})
// Special logging for set environment variables tool when Redis status is found
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
logger.info('SET_ENV_VARS: Redis polling found status update', {
@@ -255,10 +232,13 @@ export async function POST(req: NextRequest) {
const startTime = Date.now()
try {
// Check authentication (internal API key)
const authResult = checkInternalApiKey(req)
if (!authResult.success) {
return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), {
// Evaluate both auth schemes; pass if either is valid
const internalAuth = checkInternalApiKey(req)
const copilotAuth = checkCopilotApiKey(req)
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
if (!isAuthenticated) {
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
return NextResponse.json(createErrorResponse(errorMessage), {
status: 401,
})
}
@@ -266,7 +246,7 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
logger.info(`[${requestId}] Method execution request: ${methodId}`, {
logger.info(`[${requestId}] Method execution request`, {
methodId,
toolCallId,
hasParams: !!params && Object.keys(params).length > 0,

View File

@@ -178,7 +178,7 @@ export function findLocalFile(filename: string): string | null {
* Create a file response with appropriate headers
*/
export function createFileResponse(file: FileResponse): NextResponse {
return new NextResponse(file.buffer, {
return new NextResponse(file.buffer as BodyInit, {
status: 200,
headers: {
'Content-Type': file.contentType,

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
@@ -9,7 +10,6 @@ const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
const logger = createLogger('HelpAPI')
const helpFormSchema = z.object({
email: z.string().email('Invalid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
@@ -19,6 +19,15 @@ export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get user session
const session = await getSession()
if (!session?.user?.email) {
logger.warn(`[${requestId}] Unauthorized help request attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const email = session.user.email
// Check if Resend API key is configured
if (!resend) {
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
@@ -35,7 +44,6 @@ export async function POST(req: NextRequest) {
const formData = await req.formData()
// Extract form fields
const email = formData.get('email') as string
const subject = formData.get('subject') as string
const message = formData.get('message') as string
const type = formData.get('type') as string
@@ -47,7 +55,6 @@ export async function POST(req: NextRequest) {
// Validate the form data
const result = helpFormSchema.safeParse({
email,
subject,
message,
type,
@@ -97,9 +104,9 @@ ${message}
}
// Send email using Resend
const { data, error } = await resend.emails.send({
from: `Sim <noreply@${getEmailDomain()}>`,
to: [`help@${getEmailDomain()}`],
const { error } = await resend.emails.send({
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
subject: `[${type.toUpperCase()}] ${subject}`,
replyTo: email,
text: emailText,
@@ -121,7 +128,7 @@ ${message}
// Send confirmation email to the user
await resend.emails
.send({
from: `Sim <noreply@${getEmailDomain()}>`,
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
to: [email],
subject: `Your ${type} request has been received: ${subject}`,
text: `
@@ -137,7 +144,7 @@ ${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
Best regards,
The Sim Team
`,
replyTo: `help@${getEmailDomain()}`,
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
})
.catch((err) => {
logger.warn(`[${requestId}] Failed to send confirmation email`, err)

View File

@@ -46,20 +46,7 @@ export async function GET(
startedAt: workflowLog.startedAt.toISOString(),
endedAt: workflowLog.endedAt?.toISOString(),
totalDurationMs: workflowLog.totalDurationMs,
blockStats: {
total: workflowLog.blockCount,
success: workflowLog.successCount,
error: workflowLog.errorCount,
skipped: workflowLog.skippedCount,
},
cost: {
total: workflowLog.totalCost ? Number.parseFloat(workflowLog.totalCost) : null,
input: workflowLog.totalInputCost ? Number.parseFloat(workflowLog.totalInputCost) : null,
output: workflowLog.totalOutputCost
? Number.parseFloat(workflowLog.totalOutputCost)
: null,
},
totalTokens: workflowLog.totalTokens,
cost: workflowLog.cost || null,
},
}

View File

@@ -0,0 +1,102 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { permissions, workflow, workflowExecutionLogs } from '@/db/schema'
const logger = createLogger('LogDetailsByIdAPI')
export const revalidate = 0
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { id } = await params
const rows = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowColor: workflow.color,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(eq(workflowExecutionLogs.id, id))
.limit(1)
const log = rows[0]
if (!log) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
const response = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
level: log.level,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
files: log.files || undefined,
workflow: workflowSummary,
executionData: {
totalDuration: log.totalDurationMs,
...(log.executionData as any),
enhanced: true,
},
cost: log.cost as any,
}
return NextResponse.json({ data: response })
} catch (error: any) {
logger.error(`[${requestId}] log details fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -99,21 +99,13 @@ export async function GET(request: NextRequest) {
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
message: workflowExecutionLogs.message,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
blockCount: workflowExecutionLogs.blockCount,
successCount: workflowExecutionLogs.successCount,
errorCount: workflowExecutionLogs.errorCount,
skippedCount: workflowExecutionLogs.skippedCount,
totalCost: workflowExecutionLogs.totalCost,
totalInputCost: workflowExecutionLogs.totalInputCost,
totalOutputCost: workflowExecutionLogs.totalOutputCost,
totalTokens: workflowExecutionLogs.totalTokens,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
metadata: workflowExecutionLogs.metadata,
createdAt: workflowExecutionLogs.createdAt,
})
.from(workflowExecutionLogs)

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -44,8 +44,7 @@ function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): any[] {
export const revalidate = 0
const QueryParamsSchema = z.object({
includeWorkflow: z.coerce.boolean().optional().default(false),
includeBlocks: z.coerce.boolean().optional().default(false),
details: z.enum(['basic', 'full']).optional().default('basic'),
limit: z.coerce.number().optional().default(100),
offset: z.coerce.number().optional().default(0),
level: z.string().optional(),
@@ -81,20 +80,12 @@ export async function GET(request: NextRequest) {
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
message: workflowExecutionLogs.message,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
blockCount: workflowExecutionLogs.blockCount,
successCount: workflowExecutionLogs.successCount,
errorCount: workflowExecutionLogs.errorCount,
skippedCount: workflowExecutionLogs.skippedCount,
totalCost: workflowExecutionLogs.totalCost,
totalInputCost: workflowExecutionLogs.totalInputCost,
totalOutputCost: workflowExecutionLogs.totalOutputCost,
totalTokens: workflowExecutionLogs.totalTokens,
metadata: workflowExecutionLogs.metadata,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
@@ -163,13 +154,8 @@ export async function GET(request: NextRequest) {
// Filter by search query
if (params.search) {
const searchTerm = `%${params.search}%`
conditions = and(
conditions,
or(
sql`${workflowExecutionLogs.message} ILIKE ${searchTerm}`,
sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`
)
)
// With message removed, restrict search to executionId only
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}
// Execute the query using the optimized join
@@ -290,31 +276,20 @@ export async function GET(request: NextRequest) {
const enhancedLogs = logs.map((log) => {
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
// Use stored trace spans from metadata if available, otherwise create from block executions
const storedTraceSpans = (log.metadata as any)?.traceSpans
// Use stored trace spans if available, otherwise create from block executions
const storedTraceSpans = (log.executionData as any)?.traceSpans
const traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Use extracted cost summary if available, otherwise use stored values
// Prefer stored cost JSON; otherwise synthesize from blocks
const costSummary =
blockExecutions.length > 0
? extractCostSummary(blockExecutions)
: {
input: Number(log.totalInputCost) || 0,
output: Number(log.totalOutputCost) || 0,
total: Number(log.totalCost) || 0,
tokens: {
total: log.totalTokens || 0,
prompt: (log.metadata as any)?.tokenBreakdown?.prompt || 0,
completion: (log.metadata as any)?.tokenBreakdown?.completion || 0,
},
models: (log.metadata as any)?.models || {},
}
log.cost && Object.keys(log.cost as any).length > 0
? (log.cost as any)
: extractCostSummary(blockExecutions)
// Build workflow object from joined data
const workflow = {
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
@@ -329,67 +304,28 @@ export async function GET(request: NextRequest) {
return {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
executionId: params.details === 'full' ? log.executionId : undefined,
level: log.level,
message: log.message,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
files: log.files || undefined,
workflow: params.includeWorkflow ? workflow : undefined,
metadata: {
totalDuration: log.totalDurationMs,
cost: costSummary,
blockStats: {
total: log.blockCount,
success: log.successCount,
error: log.errorCount,
skipped: log.skippedCount,
},
traceSpans,
blockExecutions,
enhanced: true,
},
files: params.details === 'full' ? log.files || undefined : undefined,
workflow: workflowSummary,
executionData:
params.details === 'full'
? {
totalDuration: log.totalDurationMs,
traceSpans,
blockExecutions,
enhanced: true,
}
: undefined,
cost:
params.details === 'full'
? (costSummary as any)
: { total: (costSummary as any)?.total || 0 },
}
})
// Include block execution data if requested
if (params.includeBlocks) {
// Block executions are now extracted from stored trace spans in metadata
const blockLogsByExecution: Record<string, any[]> = {}
logs.forEach((log) => {
const storedTraceSpans = (log.metadata as any)?.traceSpans
if (storedTraceSpans && Array.isArray(storedTraceSpans)) {
blockLogsByExecution[log.executionId] =
extractBlockExecutionsFromTraceSpans(storedTraceSpans)
} else {
blockLogsByExecution[log.executionId] = []
}
})
// Add block logs to metadata
const logsWithBlocks = enhancedLogs.map((log) => ({
...log,
metadata: {
...log.metadata,
blockExecutions: blockLogsByExecution[log.executionId] || [],
},
}))
return NextResponse.json(
{
data: logsWithBlocks,
total: Number(count),
page: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
totalPages: Math.ceil(Number(count) / params.limit),
},
{ status: 200 }
)
}
// Return basic logs
return NextResponse.json(
{
data: enhancedLogs,

View File

@@ -80,7 +80,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
workspaceId: workspaceId,
name: `${templateData.name} (copy)`,
description: templateData.description,
state: templateData.state,
color: templateData.color,
userId: session.user.id,
createdAt: now,
@@ -158,9 +157,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}))
}
// Update the workflow with the corrected state
await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId))
// Insert blocks and edges
if (blockEntries.length > 0) {
await tx.insert(workflowBlocks).values(blockEntries)

View File

@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
// Fetch the file from Google Drive API
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
}
// Resolve shortcuts transparently for UI stability
if (
file.mimeType === 'application/vnd.google-apps.shortcut' &&
file.shortcutDetails?.targetId
) {
const targetId = file.shortcutDetails.targetId
const shortcutResp = await fetch(
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (shortcutResp.ok) {
const targetFile = await shortcutResp.json()
file.id = targetFile.id
file.name = targetFile.name
file.mimeType = targetFile.mimeType
file.iconLink = targetFile.iconLink
file.webViewLink = targetFile.webViewLink
file.thumbnailLink = targetFile.thumbnailLink
file.createdTime = targetFile.createdTime
file.modifiedTime = targetFile.modifiedTime
file.size = targetFile.size
file.owners = targetFile.owners
file.exportLinks = targetFile.exportLinks
}
}
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const format = exportFormats[file.mimeType] || 'application/pdf'

View File

@@ -1,10 +1,8 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
// Authorize use of the credential (supports collaborator credentials via workflow)
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build the query parameters for Google Drive API
let queryParams = 'trashed=false'
// Add mimeType filter if provided
// Build Drive 'q' expression safely
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
}
if (mimeType) {
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
// Instead of using the mimeType parameter directly, we'll add it to the query
if (queryParams.includes('q=')) {
queryParams += ` and mimeType='${mimeType}'`
} else {
queryParams += `&q=mimeType='${mimeType}'`
}
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
}
// Add search query if provided
if (query) {
if (queryParams.includes('q=')) {
queryParams += ` and name contains '${query}'`
} else {
queryParams += `&q=name contains '${query}'`
}
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
}
const q = encodeURIComponent(qParts.join(' and '))
// Fetch files from Google Drive API
// Fetch files from Google Drive API with shared drives support
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,

View File

@@ -0,0 +1,120 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { user } from '@/db/schema'
const logger = createLogger('UpdateUserProfileAPI')
// Schema for updating user profile
const UpdateProfileSchema = z
.object({
name: z.string().min(1, 'Name is required').optional(),
})
.refine((data) => data.name !== undefined, {
message: 'Name field must be provided',
})
export const dynamic = 'force-dynamic'
export async function PATCH(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
const validatedData = UpdateProfileSchema.parse(body)
// Build update object
const updateData: any = { updatedAt: new Date() }
if (validatedData.name !== undefined) updateData.name = validatedData.name
// Update user profile
const [updatedUser] = await db
.update(user)
.set(updateData)
.where(eq(user.id, userId))
.returning()
if (!updatedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
logger.info(`[${requestId}] User profile updated`, {
userId,
updatedFields: Object.keys(validatedData),
})
return NextResponse.json({
success: true,
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
image: updatedUser.image,
},
})
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid profile data`, {
errors: error.errors,
})
return NextResponse.json(
{ error: 'Invalid profile data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Profile update error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// GET endpoint to fetch current user profile
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const [userRecord] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userRecord) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
user: userRecord,
})
} catch (error: any) {
logger.error(`[${requestId}] Profile fetch error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
if (!success) {
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,

View File

@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
// Define mock functions at the top level to be used in mocks
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
@@ -33,7 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({
},
})
// Mock the DB schema objects
const webhookMock = {
id: 'webhook-id-column',
path: 'path-column',
@@ -43,10 +41,6 @@ const webhookMock = {
}
const workflowMock = { id: 'workflow-id-column' }
// Mock global timers
vi.useFakeTimers()
// Mock modules at file scope before any tests
vi.mock('@/lib/redis', () => ({
hasProcessedMessage: hasProcessedMessageMock,
markMessageAsProcessed: markMessageAsProcessedMock,
@@ -77,19 +71,6 @@ vi.mock('@/executor', () => ({
})),
}))
// Mock setTimeout and other timer functions
vi.mock('timers', () => {
return {
setTimeout: (callback: any) => {
// Immediately invoke the callback
callback()
// Return a fake timer id
return 123
},
}
})
// Mock the database and schema
vi.mock('@/db', () => {
const dbMock = {
select: vi.fn().mockImplementation((columns) => ({
@@ -128,11 +109,9 @@ describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.clearAllTimers()
mockExecutionDependencies()
// Mock services/queue for rate limiting
vi.doMock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
@@ -284,10 +263,340 @@ describe('Webhook Trigger API Route', () => {
expect(text).toMatch(/not found/i) // Response should contain "not found" message
})
/**
* Test Slack-specific webhook handling
* Verifies that Slack signature verification is performed
*/
// TODO: Fix failing test - returns 500 instead of 200
// it('should handle Slack webhooks with signature verification', async () => { ... })
describe('Generic Webhook Authentication', () => {
const setupGenericWebhook = async (config: Record<string, any>) => {
const { db } = await import('@/db')
const limitMock = vi.fn().mockReturnValue([
{
webhook: {
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
isActive: true,
providerConfig: config,
workflowId: 'test-workflow-id',
},
workflow: {
id: 'test-workflow-id',
userId: 'test-user-id',
name: 'Test Workflow',
},
},
])
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock })
const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock })
const subscriptionLimitMock = vi.fn().mockReturnValue([{ plan: 'pro' }])
const subscriptionWhereMock = vi.fn().mockReturnValue({ limit: subscriptionLimitMock })
const subscriptionFromMock = vi.fn().mockReturnValue({ where: subscriptionWhereMock })
// @ts-ignore - mocking the query chain
db.select.mockImplementation((columns: any) => {
if (columns.plan) {
return { from: subscriptionFromMock }
}
return { from: fromMock }
})
}
/**
* Test generic webhook without authentication (default behavior)
*/
it('should process generic webhook without authentication', async () => {
await setupGenericWebhook({ requireAuth: false })
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test generic webhook with Bearer token authentication (no custom header)
*/
it('should authenticate with Bearer token when no custom header is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'test-token-123',
// No secretHeaderName - should default to Bearer
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token-123',
}
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test generic webhook with custom header authentication
*/
it('should authenticate with custom header when configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'secret-token-456',
secretHeaderName: 'X-Custom-Auth',
})
const headers = {
'Content-Type': 'application/json',
'X-Custom-Auth': 'secret-token-456',
}
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test case insensitive Bearer token authentication
*/
it('should handle case insensitive Bearer token authentication', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'case-test-token',
})
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const testCases = [
'Bearer case-test-token',
'bearer case-test-token',
'BEARER case-test-token',
'BeArEr case-test-token',
]
for (const authHeader of testCases) {
const headers = {
'Content-Type': 'application/json',
Authorization: authHeader,
}
const req = createMockRequest('POST', { event: 'case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
}
})
/**
* Test case insensitive custom header authentication
*/
it('should handle case insensitive custom header authentication', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'custom-token-789',
secretHeaderName: 'X-Secret-Key',
})
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key']
for (const headerName of testCases) {
const headers = {
'Content-Type': 'application/json',
[headerName]: 'custom-token-789',
}
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
}
})
/**
* Test rejection of wrong Bearer token
*/
it('should reject wrong Bearer token', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-token',
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer wrong-token',
}
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test rejection of wrong custom header token
*/
it('should reject wrong custom header token', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-custom-token',
secretHeaderName: 'X-Auth-Key',
})
const headers = {
'Content-Type': 'application/json',
'X-Auth-Key': 'wrong-custom-token',
}
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test rejection of missing authentication
*/
it('should reject missing authentication when required', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'required-token',
})
const req = createMockRequest('POST', { event: 'no.auth.test' })
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test exclusivity - Bearer token should be rejected when custom header is configured
*/
it('should reject Bearer token when custom header is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'exclusive-token',
secretHeaderName: 'X-Only-Header',
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer exclusive-token', // Correct token but wrong header type
}
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test wrong custom header name is rejected
*/
it('should reject wrong custom header name', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-token',
secretHeaderName: 'X-Expected-Header',
})
const headers = {
'Content-Type': 'application/json',
'X-Wrong-Header': 'correct-token', // Correct token but wrong header name
}
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test authentication required but no token configured
*/
it('should reject when auth is required but no token is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
// No token configured
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer any-token',
}
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain(
'Unauthorized - Authentication required but not configured'
)
expect(processWebhookMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -196,6 +196,53 @@ export async function POST(
}
}
// Handle generic webhook authentication if enabled
if (foundWebhook.provider === 'generic') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
if (providerConfig.requireAuth) {
const configToken = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
// --- Token Validation ---
if (configToken) {
let isTokenValid = false
if (secretHeaderName) {
// Check custom header (headers are case-insensitive)
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === configToken) {
isTokenValid = true
}
} else {
// Check standard Authorization header (case-insensitive Bearer keyword)
const authHeader = request.headers.get('authorization')
// Case-insensitive comparison for "Bearer" keyword
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7) // Remove "Bearer " (7 characters)
if (token === configToken) {
isTokenValid = true
}
}
}
if (!isTokenValid) {
const expectedHeader = secretHeaderName || 'Authorization: Bearer TOKEN'
logger.warn(
`[${requestId}] Generic webhook authentication failed. Expected header: ${expectedHeader}`
)
return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 })
}
} else {
logger.warn(`[${requestId}] Generic webhook requires auth but no token configured`)
return new NextResponse('Unauthorized - Authentication required but not configured', {
status: 401,
})
}
}
}
// --- PHASE 3: Rate limiting for webhook execution ---
try {
// Get user subscription for rate limiting

View File

@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('AutoLayoutAPI')
// Check API key configuration at module level
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
if (!SIM_AGENT_API_KEY) {
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
}
const AutoLayoutRequestSchema = z.object({
strategy: z
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
}
// Apply autolayout
logger.info(
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`,
{
hasApiKey: !!SIM_AGENT_API_KEY,
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
}
)
// Create workflow state for autolayout
const workflowState = {
blocks: currentWorkflowData.blocks,
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
// Log the full response for debugging

View File

@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
import type { LoopConfig, ParallelConfig, WorkflowState } from '@/stores/workflows/workflow/types'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateAPI')
@@ -90,7 +90,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
folderId: folderId || source.folderId,
name,
description: description || source.description,
state: source.state, // We'll update this later with new block IDs
color: color || source.color,
lastSynced: now,
createdAt: now,
@@ -112,9 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Create a mapping from old block IDs to new block IDs
const blockIdMapping = new Map<string, string>()
// Initialize state for updating with new block IDs
let updatedState: WorkflowState = source.state as WorkflowState
if (sourceBlocks.length > 0) {
// First pass: Create all block ID mappings
sourceBlocks.forEach((block) => {
@@ -265,86 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
// Update the JSON state to use new block IDs
if (updatedState && typeof updatedState === 'object') {
updatedState = JSON.parse(JSON.stringify(updatedState)) as WorkflowState
// Update blocks object keys
if (updatedState.blocks && typeof updatedState.blocks === 'object') {
const newBlocks = {} as Record<string, (typeof updatedState.blocks)[string]>
for (const [oldId, blockData] of Object.entries(updatedState.blocks)) {
const newId = blockIdMapping.get(oldId) || oldId
newBlocks[newId] = {
...blockData,
id: newId,
// Update data.parentId and extent in the JSON state as well
data: (() => {
const block = blockData as any
if (block.data && typeof block.data === 'object' && block.data.parentId) {
return {
...block.data,
parentId: blockIdMapping.get(block.data.parentId) || block.data.parentId,
extent: 'parent', // Ensure extent is set for child blocks
}
}
return block.data
})(),
}
}
updatedState.blocks = newBlocks
}
// Update edges array
if (updatedState.edges && Array.isArray(updatedState.edges)) {
updatedState.edges = updatedState.edges.map((edge) => ({
...edge,
id: crypto.randomUUID(),
source: blockIdMapping.get(edge.source) || edge.source,
target: blockIdMapping.get(edge.target) || edge.target,
}))
}
// Update loops and parallels if they exist
if (updatedState.loops && typeof updatedState.loops === 'object') {
const newLoops = {} as Record<string, (typeof updatedState.loops)[string]>
for (const [oldId, loopData] of Object.entries(updatedState.loops)) {
const newId = blockIdMapping.get(oldId) || oldId
const loopConfig = loopData as any
newLoops[newId] = {
...loopConfig,
id: newId,
// Update node references in loop config
nodes: loopConfig.nodes
? loopConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
: [],
}
}
updatedState.loops = newLoops
}
if (updatedState.parallels && typeof updatedState.parallels === 'object') {
const newParallels = {} as Record<string, (typeof updatedState.parallels)[string]>
for (const [oldId, parallelData] of Object.entries(updatedState.parallels)) {
const newId = blockIdMapping.get(oldId) || oldId
const parallelConfig = parallelData as any
newParallels[newId] = {
...parallelConfig,
id: newId,
// Update node references in parallel config
nodes: parallelConfig.nodes
? parallelConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
: [],
}
}
updatedState.parallels = newParallels
}
}
// Update the workflow state with the new block IDs
// Update the workflow timestamp
await tx
.update(workflow)
.set({
state: updatedState,
updatedAt: now,
})
.where(eq(workflow.id, newWorkflowId))

View File

@@ -89,7 +89,14 @@ describe('Workflow By ID API Route', () => {
userId: 'user-123',
name: 'Test Workflow',
workspaceId: null,
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
vi.doMock('@/lib/auth', () => ({
@@ -110,6 +117,10 @@ describe('Workflow By ID API Route', () => {
},
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
}))
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
@@ -127,7 +138,14 @@ describe('Workflow By ID API Route', () => {
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
vi.doMock('@/lib/auth', () => ({
@@ -148,6 +166,10 @@ describe('Workflow By ID API Route', () => {
},
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
}))
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
hasAdminPermission: vi.fn().mockResolvedValue(false),
@@ -170,7 +192,6 @@ describe('Workflow By ID API Route', () => {
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
state: { blocks: {}, edges: [] },
}
vi.doMock('@/lib/auth', () => ({
@@ -213,7 +234,6 @@ describe('Workflow By ID API Route', () => {
userId: 'user-123',
name: 'Test Workflow',
workspaceId: null,
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {

View File

@@ -120,8 +120,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
const finalWorkflowData = { ...workflowData }
if (normalizedData) {
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
blocksCount: Object.keys(normalizedData.blocks).length,
@@ -131,38 +129,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
loops: normalizedData.loops,
})
// Use normalized table data - reconstruct complete state object
// First get any existing state properties, then override with normalized data
const existingState =
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
finalWorkflowData.state = {
// Default values for expected properties
deploymentStatuses: {},
hasActiveWebhook: false,
// Preserve any existing state properties
...existingState,
// Override with normalized data (this takes precedence)
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
// Construct response object with workflow data and state from normalized tables
const finalWorkflowData = {
...workflowData,
state: {
// Default values for expected properties
deploymentStatuses: {},
hasActiveWebhook: false,
// Data from normalized tables
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
},
}
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
} else {
// Fallback to JSON blob
logger.info(
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
)
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)

View File

@@ -220,7 +220,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
.set({
lastSynced: new Date(),
updatedAt: new Date(),
state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility
})
.where(eq(workflow.id, workflowId))

View File

@@ -1,9 +1,10 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { simAgentClient } from '@/lib/sim-agent'
import { SIM_AGENT_API_URL_DEFAULT, simAgentClient } from '@/lib/sim-agent'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
@@ -17,15 +18,12 @@ import { db } from '@/db'
import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export const dynamic = 'force-dynamic'
const logger = createLogger('WorkflowYamlAPI')
// Request schema for YAML workflow operations
const YamlWorkflowRequestSchema = z.object({
yamlContent: z.string().min(1, 'YAML content is required'),
description: z.string().optional(),
@@ -74,7 +72,6 @@ async function createWorkflowCheckpoint(
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState: currentWorkflowData,
@@ -288,7 +285,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,
@@ -649,14 +645,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
.set({
lastSynced: new Date(),
updatedAt: new Date(),
state: saveResult.jsonBlob,
})
.where(eq(workflowTable.id, workflowId))
// Notify socket server for real-time collaboration (for copilot and editor)
if (source === 'copilot' || source === 'editor') {
try {
const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002'
const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -151,7 +151,6 @@ export async function POST(req: NextRequest) {
folderId: folderId || null,
name,
description,
state: initialState,
color,
lastSynced: now,
createdAt: now,

View File

@@ -8,9 +8,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('WorkflowYamlAPI')
// Get API key at module level like working routes
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
@@ -55,7 +52,6 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
if (!result.success || !result.data?.yaml) {

View File

@@ -14,9 +14,6 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('WorkflowYamlExportAPI')
// Get API key at module level like working routes
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
const url = new URL(request.url)
@@ -88,14 +85,10 @@ export async function GET(request: NextRequest) {
edgesCount: normalizedData.edges.length,
})
// Use normalized table data - reconstruct complete state object
const existingState =
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
// Use normalized table data - construct state from normalized tables
workflowState = {
deploymentStatuses: {},
hasActiveWebhook: false,
...existingState,
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -119,33 +112,10 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
} else {
// Fallback to JSON blob
logger.info(
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
return NextResponse.json(
{ success: false, error: 'Workflow has no normalized data' },
{ status: 400 }
)
if (!workflowData.state || typeof workflowData.state !== 'object') {
return NextResponse.json(
{ success: false, error: 'Workflow has no valid state data' },
{ status: 400 }
)
}
workflowState = workflowData.state as any
// Extract subblock values from JSON blob state
if (workflowState.blocks) {
Object.entries(workflowState.blocks).forEach(([blockId, block]: [string, any]) => {
subBlockValues[blockId] = {}
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) {
subBlockValues[blockId][subBlockId] = subBlock.value
}
})
}
})
}
}
// Gather block registry and utilities for sim-agent
@@ -176,7 +146,6 @@ export async function GET(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
if (!result.success || !result.data?.yaml) {

View File

@@ -113,64 +113,6 @@ async function createWorkspace(userId: string, name: string) {
// Create initial workflow for the workspace with start block
const starterId = crypto.randomUUID()
const initialState = {
blocks: {
[starterId]: {
id: starterId,
type: 'starter',
name: 'Start',
position: { x: 100, y: 100 },
subBlocks: {
startWorkflow: {
id: 'startWorkflow',
type: 'dropdown',
value: 'manual',
},
webhookPath: {
id: 'webhookPath',
type: 'short-input',
value: '',
},
webhookSecret: {
id: 'webhookSecret',
type: 'short-input',
value: '',
},
scheduleType: {
id: 'scheduleType',
type: 'dropdown',
value: 'daily',
},
minutesInterval: {
id: 'minutesInterval',
type: 'short-input',
value: '',
},
minutesStartingAt: {
id: 'minutesStartingAt',
type: 'short-input',
value: '',
},
},
outputs: {
response: { type: { input: 'any' } },
},
enabled: true,
horizontalHandles: true,
isWide: false,
advancedMode: false,
height: 95,
},
},
edges: [],
subflows: {},
variables: {},
metadata: {
version: '1.0.0',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
}
// Create the workflow
await tx.insert(workflow).values({
@@ -180,7 +122,6 @@ async function createWorkspace(userId: string, name: string) {
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
state: initialState,
color: '#3972F6',
lastSynced: now,
createdAt: now,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlAutoLayoutAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const AutoLayoutRequestSchema = z.object({
workflowState: z.object({
@@ -58,7 +59,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Applying auto layout`, {
blockCount: Object.keys(workflowState.blocks).length,
edgeCount: workflowState.edges.length,
hasApiKey: !!SIM_AGENT_API_KEY,
strategy: options?.strategy || 'smart',
simAgentUrl: SIM_AGENT_API_URL,
})
@@ -102,7 +102,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState: {

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlDiffCreateAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const CreateDiffRequestSchema = z.object({
yamlContent: z.string().min(1),
@@ -89,7 +90,6 @@ export async function POST(request: NextRequest) {
hasDiffAnalysis: !!diffAnalysis,
hasOptions: !!options,
options: options,
hasApiKey: !!SIM_AGENT_API_KEY,
hasCurrentWorkflowState: !!currentWorkflowState,
currentBlockCount: currentWorkflowState
? Object.keys(currentWorkflowState.blocks || {}).length
@@ -117,7 +117,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -16,8 +18,7 @@ import {
const logger = createLogger('YamlDiffMergeAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const MergeDiffRequestSchema = z.object({
existingDiff: z.object({
@@ -64,7 +65,6 @@ export async function POST(request: NextRequest) {
hasDiffAnalysis: !!diffAnalysis,
hasOptions: !!options,
options: options,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry
@@ -88,7 +88,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
existingDiff,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlGenerateAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const GenerateRequestSchema = z.object({
workflowState: z.any(), // Let the yaml service handle validation
@@ -27,7 +28,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Generating YAML from workflow`, {
blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0,
edgesCount: workflowState.edges ? workflowState.edges.length : 0,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -51,7 +51,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
workflowState,

View File

@@ -1,26 +1,24 @@
import { NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
const logger = createLogger('YamlHealthAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
logger.info(`[${requestId}] Checking YAML service health`, {
hasApiKey: !!SIM_AGENT_API_KEY,
})
logger.info(`[${requestId}] Checking YAML service health`)
// Check sim-agent health
const response = await fetch(`${SIM_AGENT_API_URL}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
})

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,11 +11,10 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlParseAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ParseRequestSchema = z.object({
yamlContent: z.string().min(1),
yamlContent: z.string(),
})
export async function POST(request: NextRequest) {
@@ -25,7 +26,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Parsing YAML`, {
contentLength: yamlContent.length,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -49,7 +49,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
@@ -9,8 +11,7 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w
const logger = createLogger('YamlToWorkflowAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ConvertRequestSchema = z.object({
yamlContent: z.string().min(1),
@@ -33,7 +34,6 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Converting YAML to workflow`, {
contentLength: yamlContent.length,
hasOptions: !!options,
hasApiKey: !!SIM_AGENT_API_KEY,
})
// Gather block registry and utilities
@@ -57,7 +57,6 @@ export async function POST(request: NextRequest) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
},
body: JSON.stringify({
yamlContent,

View File

@@ -14,7 +14,8 @@
}
.workflow-container .react-flow__node-loopNode,
.workflow-container .react-flow__node-parallelNode {
.workflow-container .react-flow__node-parallelNode,
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}
@@ -205,23 +206,22 @@
}
::-webkit-scrollbar-track {
background-color: hsl(var(--scrollbar-track));
border-radius: var(--radius);
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: hsl(var(--scrollbar-thumb));
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--scrollbar-thumb-hover));
background-color: hsl(var(--muted-foreground) / 0.3);
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}

View File

@@ -86,7 +86,7 @@ const getStatusDisplay = (doc: DocumentData) => {
</>
),
className:
'inline-flex items-center rounded-md bg-[var(--brand-primary-hex)]/10 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-[var(--brand-primary-hex)]/20 dark:text-[var(--brand-primary-hex)]',
'inline-flex items-center rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-purple-900/30 dark:text-[var(--brand-primary-hex)]',
}
case 'failed':
return {

View File

@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
const logger = createLogger('UploadModal')
@@ -152,6 +153,19 @@ export function UploadModal({
}
}
const getFileIcon = (mimeType: string, filename: string) => {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-10 w-8' />
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
}
// Calculate progress percentage
const progressPercentage =
uploadProgress.totalFiles > 0
@@ -221,11 +235,11 @@ export function UploadModal({
multiple
/>
<p className='text-sm'>
{isDragging ? 'Drop more files here!' : 'Add more files'}
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
</p>
</div>
<div className='max-h-60 space-y-1.5 overflow-auto'>
<div className='max-h-60 space-y-2 overflow-auto'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isCurrentlyUploading = fileStatus?.status === 'uploading'
@@ -233,26 +247,31 @@ export function UploadModal({
const isFailed = fileStatus?.status === 'failed'
return (
<div key={index} className='space-y-1.5 rounded-md border p-2'>
<div className='flex items-center justify-between'>
<div key={index} className='rounded-md border p-3'>
<div className='flex items-center gap-3'>
{getFileIcon(file.type, file.name)}
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
{isCurrentlyUploading && (
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
)}
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
{isFailed && <X className='h-4 w-4 text-red-500' />}
{!isCurrentlyUploading && !isCompleted && !isFailed && (
<div className='h-4 w-4' />
)}
<p className='truncate text-sm'>
<span className='font-medium'>{file.name}</span>
<span className='text-muted-foreground'>
{' '}
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
</p>
<p className='truncate font-medium text-sm'>{file.name}</p>
</div>
<div className='flex items-center gap-2'>
<p className='text-muted-foreground text-xs'>
{formatFileSize(file.size)}
</p>
{isCurrentlyUploading && (
<div className='min-w-0 max-w-32 flex-1'>
<Progress value={fileStatus?.progress || 0} className='h-1' />
</div>
)}
</div>
{isFailed && fileStatus?.error && (
<p className='mt-1 text-red-500 text-xs'>{fileStatus.error}</p>
)}
</div>
<Button
type='button'
@@ -260,17 +279,11 @@ export function UploadModal({
size='sm'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0'
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
>
<X className='h-4 w-4' />
</Button>
</div>
{isCurrentlyUploading && (
<Progress value={fileStatus?.progress || 0} className='h-1' />
)}
{isFailed && fileStatus?.error && (
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
)}
</div>
)
})}
@@ -287,7 +300,11 @@ export function UploadModal({
<Button variant='outline' onClick={handleClose} disabled={isUploading}>
Cancel
</Button>
<Button onClick={handleUpload} disabled={files.length === 0 || isUploading}>
<Button
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, CheckCircle2, X } from 'lucide-react'
import { AlertCircle, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
@@ -109,6 +109,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
@@ -119,9 +120,32 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
maxChunkSize: 1024,
overlapSize: 200,
},
mode: 'onChange',
mode: 'onSubmit',
})
// Watch the name field to enable/disable the submit button
const nameValue = watch('name')
// Reset state when modal opens/closes
useEffect(() => {
if (open) {
// Reset states when modal opens
setSubmitStatus(null)
setFileError(null)
setFiles([])
setIsDragging(false)
setDragCounter(0)
// Reset form to default values
reset({
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
})
}
}, [open, reset])
const processFiles = async (fileList: FileList | File[]) => {
setFileError(null)
@@ -292,18 +316,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
}
setSubmitStatus({
type: 'success',
message: 'Your knowledge base has been created successfully!',
})
reset({
name: '',
description: '',
minChunkSize: 1,
maxChunkSize: 1024,
overlapSize: 200,
})
// Clean up file previews
files.forEach((file) => URL.revokeObjectURL(file.preview))
setFiles([])
@@ -313,10 +325,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
onKnowledgeBaseCreated(newKnowledgeBase)
}
// Close modal after a short delay to show success message
setTimeout(() => {
onOpenChange(false)
}, 1500)
// Close modal immediately - no need for success message
onOpenChange(false)
} catch (error) {
logger.error('Error creating knowledge base:', error)
setSubmitStatus({
@@ -357,31 +367,13 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
>
<div className='flex min-h-full flex-col py-4'>
{submitStatus && submitStatus.type === 'success' ? (
<Alert className='mb-6 border-border border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'>
<div className='flex items-start gap-4 py-1'>
<div className='mt-[-1.5px] flex-shrink-0'>
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
</div>
<div className='mr-4 flex-1 space-y-2'>
<AlertTitle className='-mt-0.5 flex items-center justify-between'>
<span className='font-medium text-green-600 dark:text-green-400'>
Success
</span>
</AlertTitle>
<AlertDescription className='text-green-600 dark:text-green-400'>
{submitStatus.message}
</AlertDescription>
</div>
</div>
</Alert>
) : submitStatus && submitStatus.type === 'error' ? (
{submitStatus && submitStatus.type === 'error' && (
<Alert variant='destructive' className='mb-6'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitStatus.message}</AlertDescription>
</Alert>
) : null}
)}
{/* Form Fields Section - Fixed at top */}
<div className='flex-shrink-0 space-y-4'>
@@ -611,8 +603,8 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</Button>
<Button
type='submit'
disabled={isSubmitting}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
disabled={isSubmitting || !nameValue?.trim()}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
>
{isSubmitting ? 'Creating...' : 'Create Knowledge Base'}
</Button>

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Eye, X } from 'lucide-react'
import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -209,29 +209,30 @@ export function Sidebar({
}
}, [log?.id])
const isLoadingDetails = useMemo(() => {
if (!log) return false
// Only show while we expect details to arrive (has executionId)
if (!log.executionId) return false
const hasEnhanced = !!log.executionData?.enhanced
const hasAnyDetails = hasEnhanced || !!log.cost || Array.isArray(log.executionData?.traceSpans)
return !hasAnyDetails
}, [log])
const formattedContent = useMemo(() => {
if (!log) return null
let blockInput: Record<string, any> | undefined
if (log.metadata?.blockInput) {
blockInput = log.metadata.blockInput
} else if (log.metadata?.traceSpans) {
const blockIdMatch = log.message.match(/Block .+?(\d+)/i)
const blockId = blockIdMatch ? blockIdMatch[1] : null
if (blockId) {
const matchingSpan = log.metadata.traceSpans.find(
(span) => span.blockId === blockId || span.name.includes(`Block ${blockId}`)
)
if (matchingSpan?.input) {
blockInput = matchingSpan.input
}
if (log.executionData?.blockInput) {
blockInput = log.executionData.blockInput
} else if (log.executionData?.traceSpans) {
const firstSpanWithInput = log.executionData.traceSpans.find((s) => s.input)
if (firstSpanWithInput?.input) {
blockInput = firstSpanWithInput.input as any
}
}
return formatJsonContent(log.message, blockInput)
return null
}, [log])
useEffect(() => {
@@ -243,22 +244,16 @@ export function Sidebar({
// Determine if this is a workflow execution log
const isWorkflowExecutionLog = useMemo(() => {
if (!log) return false
// Check if message contains workflow execution phrases (success or failure)
return (
log.message.toLowerCase().includes('workflow executed') ||
log.message.toLowerCase().includes('execution completed') ||
log.message.toLowerCase().includes('workflow execution failed') ||
log.message.toLowerCase().includes('execution failed') ||
(log.trigger === 'manual' && log.duration) ||
// Also check if we have enhanced logging metadata with trace spans
(log.metadata?.enhanced && log.metadata?.traceSpans)
(log.trigger === 'manual' && !!log.duration) ||
(log.executionData?.enhanced && log.executionData?.traceSpans)
)
}, [log])
// Helper to determine if we have cost information to display
// All workflow executions now have cost info (base charge + any model costs)
const hasCostInfo = useMemo(() => {
return isWorkflowExecutionLog && log?.metadata?.cost
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
const isWorkflowWithCost = useMemo(() => {
@@ -490,6 +485,14 @@ export function Sidebar({
</div>
)}
{/* Suspense while details load (positioned after summary fields) */}
{isLoadingDetails && (
<div className='flex w-full items-center justify-start gap-2 py-2 text-muted-foreground'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-sm'>Loading details</span>
</div>
)}
{/* Files */}
{log.files && log.files.length > 0 && (
<div>
@@ -541,19 +544,15 @@ export function Sidebar({
</div>
)}
{/* Message Content */}
<div className='w-full pb-2'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Message</h3>
<div className='w-full'>{formattedContent}</div>
</div>
{/* end suspense */}
{/* Trace Spans (if available and this is a workflow execution log) */}
{isWorkflowExecutionLog && log.metadata?.traceSpans && (
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
<div className='w-full'>
<div className='w-full overflow-x-hidden'>
<TraceSpansDisplay
traceSpans={log.metadata.traceSpans}
totalDuration={log.metadata.totalDuration}
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
onExpansionChange={handleTraceSpanToggle}
/>
</div>
@@ -561,11 +560,11 @@ export function Sidebar({
)}
{/* Tool Calls (if available) */}
{log.metadata?.toolCalls && log.metadata.toolCalls.length > 0 && (
{log.executionData?.toolCalls && log.executionData.toolCalls.length > 0 && (
<div className='w-full'>
<h3 className='mb-1 font-medium text-muted-foreground text-xs'>Tool Calls</h3>
<div className='w-full overflow-x-hidden rounded-md bg-secondary/30 p-3'>
<ToolCallsDisplay metadata={log.metadata} />
<ToolCallsDisplay metadata={log.executionData} />
</div>
</div>
)}
@@ -584,86 +583,80 @@ export function Sidebar({
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Model Input:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.input || 0)}
</span>
<span className='text-sm'>{formatCost(log.cost?.input || 0)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Model Output:</span>
<span className='text-sm'>
{formatCost(log.metadata?.cost?.output || 0)}
</span>
<span className='text-sm'>{formatCost(log.cost?.output || 0)}</span>
</div>
<div className='mt-1 flex items-center justify-between border-t pt-2'>
<span className='text-muted-foreground text-sm'>Total:</span>
<span className='text-foreground text-sm'>
{formatCost(log.metadata?.cost?.total || 0)}
{formatCost(log.cost?.total || 0)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>Tokens:</span>
<span className='text-muted-foreground text-xs'>
{log.metadata?.cost?.tokens?.prompt || 0} in /{' '}
{log.metadata?.cost?.tokens?.completion || 0} out
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
out
</span>
</div>
</div>
{/* Models Breakdown */}
{log.metadata?.cost?.models &&
Object.keys(log.metadata?.cost?.models).length > 0 && (
<div className='border-t'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
Model Breakdown (
{Object.keys(log.metadata?.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</button>
{log.cost?.models && Object.keys(log.cost?.models).length > 0 && (
<div className='border-t'>
<button
onClick={() => setIsModelsExpanded(!isModelsExpanded)}
className='flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-muted/50'
>
<span className='font-medium text-muted-foreground text-xs'>
Model Breakdown ({Object.keys(log.cost?.models || {}).length})
</span>
{isModelsExpanded ? (
<ChevronUp className='h-3 w-3 text-muted-foreground' />
) : (
<ChevronDown className='h-3 w-3 text-muted-foreground' />
)}
</button>
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
{Object.entries(log.metadata?.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
</span>
</div>
{isModelsExpanded && (
<div className='space-y-3 border-t bg-muted/30 p-3'>
{Object.entries(log.cost?.models || {}).map(
([model, cost]: [string, any]) => (
<div key={model} className='space-y-1'>
<div className='font-medium font-mono text-xs'>{model}</div>
<div className='space-y-1 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Input:</span>
<span>{formatCost(cost.input || 0)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Output:</span>
<span>{formatCost(cost.output || 0)}</span>
</div>
<div className='flex justify-between border-t pt-1'>
<span className='text-muted-foreground'>Total:</span>
<span className='font-medium'>
{formatCost(cost.total || 0)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Tokens:</span>
<span>
{cost.tokens?.prompt || 0} in /{' '}
{cost.tokens?.completion || 0} out
</span>
</div>
</div>
)
)}
</div>
)}
</div>
)}
</div>
)
)}
</div>
)}
</div>
)}
{isWorkflowWithCost && (
<div className='border-t bg-muted p-3 text-muted-foreground text-xs'>
@@ -688,7 +681,7 @@ export function Sidebar({
executionId={log.executionId}
workflowName={log.workflow?.name}
trigger={log.trigger || undefined}
traceSpans={log.metadata?.traceSpans}
traceSpans={log.executionData?.traceSpans}
isOpen={isFrozenCanvasOpen}
onClose={() => setIsFrozenCanvasOpen(false)}
/>

View File

@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
interface CollapsibleInputOutputProps {
span: TraceSpan
spanId: string
depth: number
}
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
const [inputExpanded, setInputExpanded] = useState(false)
const [outputExpanded, setOutputExpanded] = useState(false)
// Calculate the left margin based on depth to match the parent span's indentation
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
return (
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
<div
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
style={{ marginLeft: `${leftMargin}px` }}
>
{/* Input Data - Collapsible */}
{span.input && (
<div>
@@ -162,26 +169,30 @@ function BlockDataDisplay({
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
return (
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
)
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
<span className='text-muted-foreground'>[</span>
<div className='ml-4 space-y-1'>
<div className='ml-2 space-y-0.5'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
<div key={index} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
{index}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
@@ -196,10 +207,10 @@ function BlockDataDisplay({
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-1'>
<div className='space-y-0.5'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-2'>
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
<div key={objKey} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
@@ -227,12 +238,12 @@ function BlockDataDisplay({
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-1'>
<div className='space-y-0.5'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
<div key={key} className='flex gap-1.5'>
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
@@ -592,7 +603,9 @@ function TraceSpanItem({
{expanded && (
<div>
{/* Block Input/Output Data - Collapsible */}
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
{(span.input || span.output) && (
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
)}
{/* Children and tool calls */}
{/* Render child spans */}

View File

@@ -85,6 +85,10 @@ export default function Logs() {
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isDetailsLoading, setIsDetailsLoading] = useState(false)
const detailsCacheRef = useRef<Map<string, any>>(new Map())
const detailsAbortRef = useRef<AbortController | null>(null)
const currentDetailsIdRef = useRef<string | null>(null)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
@@ -116,13 +120,122 @@ export default function Logs() {
const index = logs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setIsSidebarOpen(true)
setIsDetailsLoading(true)
// Fetch details for current, previous, and next concurrently with cache
const currentId = log.id
const prevId = index > 0 ? logs[index - 1]?.id : undefined
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
currentDetailsIdRef.current = currentId
const idsToFetch: Array<{ id: string; merge: boolean }> = []
const cachedCurrent = currentId ? detailsCacheRef.current.get(currentId) : undefined
if (currentId && !cachedCurrent) idsToFetch.push({ id: currentId, merge: true })
if (prevId && !detailsCacheRef.current.has(prevId))
idsToFetch.push({ id: prevId, merge: false })
if (nextId && !detailsCacheRef.current.has(nextId))
idsToFetch.push({ id: nextId, merge: false })
// Merge cached current immediately
if (cachedCurrent) {
setSelectedLog((prev) =>
prev && prev.id === currentId
? ({ ...(prev as any), ...(cachedCurrent as any) } as any)
: prev
)
setIsDetailsLoading(false)
}
if (idsToFetch.length === 0) return
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === currentId) {
setSelectedLog((prev) =>
prev && prev.id === id ? ({ ...(prev as any), ...(detailed as any) } as any) : prev
)
if (currentDetailsIdRef.current === id) setIsDetailsLoading(false)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
const handleNavigateNext = useCallback(() => {
if (selectedLogIndex < logs.length - 1) {
const nextIndex = selectedLogIndex + 1
setSelectedLogIndex(nextIndex)
setSelectedLog(logs[nextIndex])
const nextLog = logs[nextIndex]
setSelectedLog(nextLog)
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
const cached = detailsCacheRef.current.get(nextLog.id)
if (cached) {
setSelectedLog((prev) =>
prev && prev.id === nextLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
)
} else {
const prevId = nextIndex > 0 ? logs[nextIndex - 1]?.id : undefined
const afterId = nextIndex < logs.length - 1 ? logs[nextIndex + 1]?.id : undefined
const idsToFetch: Array<{ id: string; merge: boolean }> = []
if (nextLog.id && !detailsCacheRef.current.has(nextLog.id))
idsToFetch.push({ id: nextLog.id, merge: true })
if (prevId && !detailsCacheRef.current.has(prevId))
idsToFetch.push({ id: prevId, merge: false })
if (afterId && !detailsCacheRef.current.has(afterId))
idsToFetch.push({ id: afterId, merge: false })
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === nextLog.id) {
setSelectedLog((prev) =>
prev && prev.id === id
? ({ ...(prev as any), ...(detailed as any) } as any)
: prev
)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
}
}, [selectedLogIndex, logs])
@@ -130,7 +243,57 @@ export default function Logs() {
if (selectedLogIndex > 0) {
const prevIndex = selectedLogIndex - 1
setSelectedLogIndex(prevIndex)
setSelectedLog(logs[prevIndex])
const prevLog = logs[prevIndex]
setSelectedLog(prevLog)
// Abort any previous details fetch batch
if (detailsAbortRef.current) {
try {
detailsAbortRef.current.abort()
} catch {
/* no-op */
}
}
const controller = new AbortController()
detailsAbortRef.current = controller
const cached = detailsCacheRef.current.get(prevLog.id)
if (cached) {
setSelectedLog((prev) =>
prev && prev.id === prevLog.id ? ({ ...(prev as any), ...(cached as any) } as any) : prev
)
} else {
const beforeId = prevIndex > 0 ? logs[prevIndex - 1]?.id : undefined
const afterId = prevIndex < logs.length - 1 ? logs[prevIndex + 1]?.id : undefined
const idsToFetch: Array<{ id: string; merge: boolean }> = []
if (prevLog.id && !detailsCacheRef.current.has(prevLog.id))
idsToFetch.push({ id: prevLog.id, merge: true })
if (beforeId && !detailsCacheRef.current.has(beforeId))
idsToFetch.push({ id: beforeId, merge: false })
if (afterId && !detailsCacheRef.current.has(afterId))
idsToFetch.push({ id: afterId, merge: false })
Promise.all(
idsToFetch.map(async ({ id, merge }) => {
try {
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
if (!res.ok) return
const body = await res.json()
const detailed = body?.data
if (detailed) {
detailsCacheRef.current.set(id, detailed)
if (merge && id === prevLog.id) {
setSelectedLog((prev) =>
prev && prev.id === id
? ({ ...(prev as any), ...(detailed as any) } as any)
: prev
)
}
}
} catch (e: any) {
if (e?.name === 'AbortError') return
}
})
).catch(() => {})
}
}
}, [selectedLogIndex, logs])
@@ -160,7 +323,7 @@ export default function Logs() {
// Get fresh query params by calling buildQueryParams from store
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
const response = await fetch(`/api/logs?${queryParams}`)
const response = await fetch(`/api/logs?${queryParams}&details=basic`)
if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`)
@@ -262,7 +425,7 @@ export default function Logs() {
// Build query params inline to avoid dependency issues
const params = new URLSearchParams()
params.set('includeWorkflow', 'true')
params.set('details', 'basic')
params.set('limit', LOGS_PER_PAGE.toString())
params.set('offset', '0') // Always start from page 1
params.set('workspaceId', workspaceId)
@@ -482,7 +645,7 @@ export default function Logs() {
{/* Header */}
<div>
<div className='border-border border-b'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] gap-2 px-2 pb-3 md:grid-cols-[140px_90px_140px_120px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
@@ -493,14 +656,12 @@ export default function Logs() {
Workflow
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
ID
Cost
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Trigger
</div>
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Message
</div>
<div className='hidden font-[480] font-sans text-[13px] text-muted-foreground leading-normal xl:block'>
Duration
</div>
@@ -547,7 +708,7 @@ export default function Logs() {
}`}
onClick={() => handleLogClick(log)}
>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_80px_1fr] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_90px_1fr] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_100px_1fr] lg:gap-4 xl:grid-cols-[160px_100px_160px_100px_100px_1fr_100px]'>
<div className='grid min-w-[600px] grid-cols-[120px_80px_120px_120px] items-center gap-2 px-2 py-4 md:grid-cols-[140px_90px_140px_120px] md:gap-3 lg:min-w-0 lg:grid-cols-[160px_100px_160px_120px] lg:gap-4 xl:grid-cols-[160px_100px_160px_120px_120px_100px]'>
{/* Time */}
<div>
<div className='text-[13px]'>
@@ -584,10 +745,12 @@ export default function Logs() {
</div>
</div>
{/* ID */}
{/* Cost */}
<div>
<div className='font-medium text-muted-foreground text-xs'>
#{log.id.slice(-4)}
{typeof (log as any)?.cost?.total === 'number'
? `$${((log as any).cost.total as number).toFixed(4)}`
: '—'}
</div>
</div>
@@ -614,11 +777,6 @@ export default function Logs() {
)}
</div>
{/* Message */}
<div className='min-w-0'>
<div className='truncate font-[420] text-[13px]'>{log.message}</div>
</div>
{/* Duration */}
<div className='hidden xl:block'>
<div className='text-muted-foreground text-xs'>

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Download } from 'lucide-react'
import { Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
@@ -81,7 +81,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Download className='h-5 w-5' />
<Upload className='h-5 w-5' />
</div>
) : (
<Button
@@ -89,7 +89,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
onClick={handleExportYaml}
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
>
<Download className='h-5 w-5' />
<Upload className='h-5 w-5' />
<span className='sr-only'>Export as YAML</span>
</Button>
)}

View File

@@ -32,7 +32,6 @@ import {
TooltipTrigger,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -341,10 +340,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
* Handle deleting the current workflow
*/
const handleDeleteWorkflow = () => {
if (!activeWorkflowId || !userPermissions.canEdit) return
const currentWorkflowId = params.workflowId as string
if (!currentWorkflowId || !userPermissions.canEdit) return
const sidebarWorkflows = getSidebarOrderedWorkflows()
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId)
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
// Find next workflow: try next, then previous
let nextWorkflowId: string | null = null
@@ -363,8 +363,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
router.push(`/workspace/${workspaceId}`)
}
// Remove the workflow from the registry
useWorkflowRegistry.getState().removeWorkflow(activeWorkflowId)
// Remove the workflow from the registry using the URL parameter
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
}
// Helper function to open subscription settings
@@ -413,7 +413,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<Tooltip>
<TooltipTrigger asChild>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Trash2 className='h-5 w-5' />
<Trash2 className='h-4 w-4' />
</div>
</TooltipTrigger>
<TooltipContent>{getTooltipText()}</TooltipContent>
@@ -498,7 +498,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Copy className='h-5 w-5' />
<Copy className='h-4 w-4' />
</div>
) : (
<Button
@@ -563,9 +563,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
{isAutoLayouting ? (
<RefreshCw className='h-5 w-5 animate-spin' />
<RefreshCw className='h-4 w-4 animate-spin' />
) : (
<Layers className='h-5 w-5' />
<Layers className='h-4 w-4' />
)}
</div>
) : (
@@ -721,7 +721,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Store className='h-5 w-5' />
<Store className='h-4 w-4' />
</div>
) : (
<Button
@@ -775,7 +775,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
isDebugging && 'text-amber-500'
)}
>
<Bug className='h-5 w-5' />
<Bug className='h-4 w-4' />
</div>
) : (
<Button variant='outline' onClick={handleDebugToggle} className={buttonClass}>
@@ -999,14 +999,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
return (
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
{renderDisconnectionNotice()}
{!isDev && renderToggleButton()}
{isExpanded && !isDev && <ExportControls />}
{isExpanded && !isDev && renderAutoLayoutButton()}
{!isDev && isExpanded && renderDuplicateButton()}
{isDev && renderDuplicateButton()}
{renderToggleButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()}
{renderDuplicateButton()}
{renderDeleteButton()}
{!isDebugging && renderDebugModeToggle()}
{renderPublishButton()}
{isExpanded && renderPublishButton()}
{renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()}

View File

@@ -191,27 +191,27 @@ export function DiffControls() {
logger.info('Accepting proposed changes with backup protection')
try {
// Create a checkpoint before applying changes so it appears under the triggering user message
await createCheckpoint().catch((error) => {
logger.warn('Failed to create checkpoint before accept:', error)
})
// Clear preview YAML immediately
await clearPreviewYaml().catch((error) => {
logger.warn('Failed to clear preview YAML:', error)
})
// Accept changes with automatic backup and rollback on failure
await acceptChanges()
// Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error)
})
logger.info('Successfully accepted and saved workflow changes')
// Show success feedback if needed
logger.info('Accept triggered; UI will update optimistically')
} catch (error) {
logger.error('Failed to accept changes:', error)
// Show error notification to user
// Note: The acceptChanges function has already rolled back the state
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
// You could add toast notification here
console.error('Workflow update failed:', errorMessage)
// Optionally show user-facing error dialog
alert(`Failed to save workflow changes: ${errorMessage}`)
}
}
@@ -224,10 +224,10 @@ export function DiffControls() {
logger.warn('Failed to clear preview YAML:', error)
})
// Reject is immediate (no server save needed)
rejectChanges()
logger.info('Successfully rejected proposed changes')
// Reject changes optimistically
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
})
}
return (

View File

@@ -4,6 +4,8 @@ import { Component, type ReactNode, useEffect } from 'react'
import { BotIcon } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console/logger'
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
const logger = createLogger('ErrorBoundary')
@@ -22,18 +24,32 @@ export function ErrorUI({
fullScreen = false,
}: ErrorUIProps) {
const containerClass = fullScreen
? 'flex items-center justify-center w-full h-screen bg-muted/40'
: 'flex items-center justify-center w-full h-full bg-muted/40'
? 'flex flex-col w-full h-screen bg-muted/40'
: 'flex flex-col w-full h-full bg-muted/40'
return (
<div className={containerClass}>
<Card className='max-w-md space-y-4 p-6 text-center'>
<div className='flex justify-center'>
<BotIcon className='h-16 w-16 text-muted-foreground' />
{/* Control bar */}
<ControlBar hasValidationErrors={false} />
{/* Main content area */}
<div className='relative flex flex-1'>
{/* Error message */}
<div className='flex flex-1 items-center justify-center'>
<Card className='max-w-md space-y-4 p-6 text-center'>
<div className='flex justify-center'>
<BotIcon className='h-16 w-16 text-muted-foreground' />
</div>
<h3 className='font-semibold text-lg'>{title}</h3>
<p className='text-muted-foreground'>{message}</p>
</Card>
</div>
<h3 className='font-semibold text-lg'>{title}</h3>
<p className='text-muted-foreground'>{message}</p>
</Card>
{/* Console panel */}
<div className='fixed top-0 right-0 z-10'>
<Panel />
</div>
</div>
</div>
)
}

View File

@@ -2,8 +2,7 @@ export { ControlBar } from './control-bar/control-bar'
export { ErrorBoundary } from './error/index'
export { Panel } from './panel/panel'
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
export { LoopNodeComponent } from './subflows/loop/loop-node'
export { ParallelNodeComponent } from './subflows/parallel/parallel-node'
export { SubflowNodeComponent } from './subflows/subflow-node'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'

View File

@@ -13,6 +13,7 @@ import {
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { redactApiKeys } from '@/lib/utils'
import {
CodeDisplay,
JSONView,
@@ -349,9 +350,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
// For code display, copy just the code string
textToCopy = entry.input.code
} else {
// For regular JSON display, copy the full JSON
// For regular JSON display, copy the full JSON with redaction applied
const dataToCopy = showInput ? entry.input : entry.output
textToCopy = JSON.stringify(dataToCopy, null, 2)
const redactedData = redactApiKeys(dataToCopy)
textToCopy = JSON.stringify(redactedData, null, 2)
}
navigator.clipboard.writeText(textToCopy)

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { redactApiKeys } from '@/lib/utils'
interface JSONViewProps {
data: any
@@ -154,6 +155,9 @@ export const JSONView = ({ data }: JSONViewProps) => {
y: number
} | null>(null)
// Apply redaction to the data before displaying
const redactedData = redactApiKeys(data)
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
@@ -167,18 +171,18 @@ export const JSONView = ({ data }: JSONViewProps) => {
}
}, [contextMenuPosition])
if (data === null)
if (redactedData === null)
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
// For non-object data, show simple JSON
if (typeof data !== 'object') {
const stringValue = JSON.stringify(data)
if (typeof redactedData !== 'object') {
const stringValue = JSON.stringify(redactedData)
return (
<span
onContextMenu={handleContextMenu}
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
>
{typeof data === 'string' ? (
{typeof redactedData === 'string' ? (
<TruncatedValue value={stringValue} />
) : (
<span className='break-all font-[380] text-muted-foreground leading-normal'>
@@ -192,7 +196,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
>
<button
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
onClick={() => copyToClipboard(redactedData)}
>
Copy value
</button>
@@ -206,7 +210,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
return (
<div onContextMenu={handleContextMenu}>
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
<CollapsibleJSON data={data} />
<CollapsibleJSON data={redactedData} />
</pre>
{contextMenuPosition && (
<div
@@ -215,7 +219,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
>
<button
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
onClick={() => copyToClipboard(data)}
onClick={() => copyToClipboard(redactedData)}
>
Copy object
</button>

View File

@@ -27,6 +27,18 @@ export function ThinkingBlock({
}
}, [persistedStartTime])
useEffect(() => {
// Auto-collapse when streaming ends
if (!isStreaming) {
setIsExpanded(false)
return
}
// Expand once there is visible content while streaming
if (content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])
useEffect(() => {
// If we already have a persisted duration, just use it
if (typeof persistedDuration === 'number') {
@@ -52,29 +64,10 @@ export function ThinkingBlock({
return `${seconds}s`
}
if (!isExpanded) {
return (
<button
onClick={() => setIsExpanded(true)}
className={cn(
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'font-normal italic'
)}
type='button'
>
<Brain className='h-3 w-3' />
<span>Thought for {formatDuration(duration)}</span>
{isStreaming && (
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
)}
</button>
)
}
return (
<div className='my-1'>
<button
onClick={() => setIsExpanded(false)}
onClick={() => setIsExpanded((v) => !v)}
className={cn(
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'font-normal italic'
@@ -82,14 +75,25 @@ export function ThinkingBlock({
type='button'
>
<Brain className='h-3 w-3' />
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
<span>
Thought for {formatDuration(duration)}
{isExpanded ? ' (click to collapse)' : ''}
</span>
{isStreaming && (
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
)}
</button>
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
{content}
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
</pre>
</div>
{isExpanded && (
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
{content}
{isStreaming && (
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Checkpoints below message */}
{hasCheckpoints && (
<div className='mt-1 flex justify-end'>
{showRestoreConfirmation ? (
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>Restore?</span>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
>
<X className='h-3 w-3' />
</button>
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
<span className='select-none'>
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
</span>
<div className='inline-flex w-8 items-center justify-center'>
{showRestoreConfirmation ? (
<div className='inline-flex items-center gap-1'>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
aria-label='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
aria-label='Cancel restore'
>
<X className='h-3 w-3' />
</button>
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Restore workflow to this checkpoint state'
aria-label='Restore'
>
<RotateCcw className='h-3 w-3' />
</button>
)}
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
className='flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
title='Restore workflow to this checkpoint state'
>
<RotateCcw className='h-3 w-3' />
Restore
</button>
)}
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,25 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
export const CopilotSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
<SliderPrimitive.Range className='absolute h-full bg-primary' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
</SliderPrimitive.Root>
))
CopilotSlider.displayName = 'CopilotSlider'

View File

@@ -10,12 +10,13 @@ import {
} from 'react'
import {
ArrowUp,
Boxes,
Brain,
BrainCircuit,
BrainCog,
Check,
FileText,
Image,
Infinity as InfinityIcon,
Info,
Loader2,
MessageCircle,
Package,
@@ -30,11 +31,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { cn } from '@/lib/utils'
import { useCopilotStore } from '@/stores/copilot/store'
import { CopilotSlider as Slider } from './copilot-slider'
export interface MessageFileAttachment {
id: string
@@ -426,32 +429,31 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
// Depth toggle state comes from global store; access via useCopilotStore
const { agentDepth, setAgentDepth } = useCopilotStore()
const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore()
const cycleDepth = () => {
// Allowed UI values: 0 (Lite), 1 (Default), 2 (Pro), 3 (Max)
const next = agentDepth === 0 ? 1 : agentDepth === 1 ? 2 : agentDepth === 2 ? 3 : 0
setAgentDepth(next)
// 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping.
const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3)
if (nextDepth === 0 && agentDepth === 3) {
setAgentPrefetch(!agentPrefetch)
}
setAgentDepth(nextDepth)
}
const getDepthLabel = () => {
if (agentDepth === 0) return 'Lite'
if (agentDepth === 1) return 'Auto'
if (agentDepth === 2) return 'Pro'
return 'Max'
const getCollapsedModeLabel = () => {
const base = getDepthLabelFor(agentDepth)
return !agentPrefetch ? `${base} MAX` : base
}
const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => {
if (value === 0) return 'Lite'
if (value === 1) return 'Auto'
if (value === 2) return 'Pro'
return 'Max'
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert'
}
// Removed descriptive suffixes; concise labels only
const getDepthDescription = (value: 0 | 1 | 2 | 3) => {
if (value === 0)
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.'
if (value === 1) return 'Automatically balances speed and reasoning. Good fit for most tasks.'
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.'
if (value === 2)
return 'More reasoning for larger workflows and complex edits, still balanced for speed.'
return 'Maximum reasoning power. Best for complex workflow building and debugging.'
@@ -459,9 +461,9 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const getDepthIconFor = (value: 0 | 1 | 2 | 3) => {
if (value === 0) return <Zap className='h-3 w-3 text-muted-foreground' />
if (value === 1) return <Boxes className='h-3 w-3 text-muted-foreground' />
if (value === 2) return <BrainCircuit className='h-3 w-3 text-muted-foreground' />
return <BrainCog className='h-3 w-3 text-muted-foreground' />
if (value === 1) return <InfinityIcon className='h-3 w-3 text-muted-foreground' />
if (value === 2) return <Brain className='h-3 w-3 text-muted-foreground' />
return <BrainCircuit className='h-3 w-3 text-muted-foreground' />
}
const getDepthIcon = () => getDepthIconFor(agentDepth)
@@ -548,7 +550,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
placeholder={isDragging ? 'Drop files here...' : placeholder}
disabled={disabled}
rows={1}
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ height: 'auto' }}
/>
@@ -635,126 +637,72 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
variant='ghost'
size='sm'
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
title='Choose depth'
title='Choose mode'
>
{getDepthIcon()}
<span>{getDepthLabel()}</span>
<span>{getCollapsedModeLabel()}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='p-0'>
<TooltipProvider>
<div className='w-[180px] p-1'>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => setAgentDepth(1)}
className={cn(
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
agentDepth === 1 && 'bg-muted/40'
)}
>
<span className='flex items-center gap-1.5'>
<Boxes className='h-3 w-3 text-muted-foreground' />
Auto
</span>
{agentDepth === 1 && (
<Check className='h-3 w-3 text-muted-foreground' />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Automatically balances speed and reasoning. Good fit for most tasks.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => setAgentDepth(0)}
className={cn(
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
agentDepth === 0 && 'bg-muted/40'
)}
>
<span className='flex items-center gap-1.5'>
<Zap className='h-3 w-3 text-muted-foreground' />
Lite
</span>
{agentDepth === 0 && (
<Check className='h-3 w-3 text-muted-foreground' />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Fastest and cheapest. Good for small edits, simple workflows, and small
tasks.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => setAgentDepth(2)}
className={cn(
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
agentDepth === 2 && 'bg-muted/40'
)}
>
<span className='flex items-center gap-1.5'>
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
Pro
</span>
{agentDepth === 2 && (
<Check className='h-3 w-3 text-muted-foreground' />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
More reasoning for larger workflows and complex edits, still balanced
for speed.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
onSelect={() => setAgentDepth(3)}
className={cn(
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
agentDepth === 3 && 'bg-muted/40'
)}
>
<span className='flex items-center gap-1.5'>
<BrainCog className='h-3 w-3 text-muted-foreground' />
Max
</span>
{agentDepth === 3 && (
<Check className='h-3 w-3 text-muted-foreground' />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Maximum reasoning power. Best for complex workflow building and
debugging.
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
<div className='w-[260px] p-3'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<span className='font-medium text-xs'>MAX mode</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type='button'
className='h-3.5 w-3.5 rounded text-muted-foreground transition-colors hover:text-foreground'
aria-label='MAX mode info'
>
<Info className='h-3.5 w-3.5' />
</button>
</TooltipTrigger>
<TooltipContent
side='right'
sideOffset={6}
align='center'
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
>
Significantly increases depth of reasoning
</TooltipContent>
</Tooltip>
</div>
<Switch
checked={!agentPrefetch}
onCheckedChange={(checked) => setAgentPrefetch(!checked)}
/>
</div>
<div className='my-2 flex justify-center'>
<div className='h-px w-[100%] bg-border' />
</div>
<div className='mb-3'>
<div className='mb-2 flex items-center justify-between'>
<span className='font-medium text-xs'>Mode</span>
<span className='text-muted-foreground text-xs'>
{getDepthLabelFor(agentDepth)}
</span>
</div>
<div className='relative'>
<Slider
min={0}
max={3}
step={1}
value={[agentDepth]}
onValueChange={(val) =>
setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3)
}
/>
<div className='pointer-events-none absolute inset-0'>
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[33.333%] h-2 w-[3px] bg-background' />
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[66.667%] h-2 w-[3px] bg-background' />
</div>
</div>
</div>
<div className='mt-3 text-[11px] text-muted-foreground'>
{getDepthDescription(agentDepth)}
</div>
</div>
</TooltipProvider>
</DropdownMenuContent>

View File

@@ -44,6 +44,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
// Scroll state
const [isNearBottom, setIsNearBottom] = useState(true)
const [showScrollButton, setShowScrollButton] = useState(false)
// New state to track if user has intentionally scrolled during streaming
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
const isUserScrollingRef = useRef(false) // Track if scroll event is user-initiated
const { activeWorkflowId } = useWorkflowRegistry()
@@ -119,6 +122,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
'[data-radix-scroll-area-viewport]'
)
if (scrollContainer) {
// Mark that we're programmatically scrolling
isUserScrollingRef.current = false
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
@@ -143,7 +148,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const nearBottom = distanceFromBottom <= 100
setIsNearBottom(nearBottom)
setShowScrollButton(!nearBottom)
}, [])
// If user scrolled up during streaming, mark it
if (isSendingMessage && !nearBottom && isUserScrollingRef.current) {
setUserHasScrolledDuringStream(true)
}
// Reset the user scrolling flag after processing
isUserScrollingRef.current = true
}, [isSendingMessage])
// Attach scroll listener
useEffect(() => {
@@ -154,7 +167,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
if (!viewport) return
viewport.addEventListener('scroll', handleScroll, { passive: true })
// Mark user-initiated scrolls
const handleUserScroll = () => {
isUserScrollingRef.current = true
handleScroll()
}
viewport.addEventListener('scroll', handleUserScroll, { passive: true })
// Also listen for scrollend event if available (for smooth scrolling)
if ('onscrollend' in viewport) {
@@ -165,34 +184,63 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
setTimeout(handleScroll, 100)
return () => {
viewport.removeEventListener('scroll', handleScroll)
viewport.removeEventListener('scroll', handleUserScroll)
if ('onscrollend' in viewport) {
viewport.removeEventListener('scrollend', handleScroll)
}
}
}, [handleScroll])
// Smart auto-scroll: only scroll if user is near bottom or for user messages
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
useEffect(() => {
if (messages.length === 0) return
const lastMessage = messages[messages.length - 1]
const isNewUserMessage = lastMessage?.role === 'user'
// Always scroll for new user messages, or only if near bottom for assistant messages
if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) {
// Conditions for auto-scrolling:
// 1. Always scroll for new user messages (resets the user scroll state)
// 2. For assistant messages during streaming: only if user hasn't scrolled up
// 3. For assistant messages when not streaming: only if near bottom
const shouldAutoScroll =
isNewUserMessage ||
(isSendingMessage && !userHasScrolledDuringStream) ||
(!isSendingMessage && isNearBottom)
if (shouldAutoScroll && scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector(
'[data-radix-scroll-area-viewport]'
)
if (scrollContainer) {
// Mark that we're programmatically scrolling
isUserScrollingRef.current = false
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
// Let the scroll event handler update the state naturally after animation completes
}
}
}, [messages, isNearBottom])
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream])
// Reset user scroll state when streaming starts or when user sends a message
useEffect(() => {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role === 'user') {
// User sent a new message - reset scroll state
setUserHasScrolledDuringStream(false)
isUserScrollingRef.current = false
}
}, [messages])
// Reset user scroll state when streaming completes
const prevIsSendingRef = useRef(false)
useEffect(() => {
// When streaming transitions from true to false, reset the user scroll state
if (prevIsSendingRef.current && !isSendingMessage) {
setUserHasScrolledDuringStream(false)
}
prevIsSendingRef.current = isSendingMessage
}, [isSendingMessage])
// Auto-scroll to bottom when chat loads in
useEffect(() => {

View File

@@ -9,7 +9,6 @@ import {
} from '@/components/ui/dropdown-menu'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { isDev } from '@/lib/environment'
import { useCopilotStore } from '@/stores/copilot/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useConsoleStore } from '@/stores/panel/console/store'
@@ -305,16 +304,14 @@ export function Panel() {
>
Console
</button>
{!isDev && (
<button
onClick={() => handleTabClick('copilot')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Copilot
</button>
)}
<button
onClick={() => handleTabClick('copilot')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Copilot
</button>
<button
onClick={() => handleTabClick('variables')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${

View File

@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock hooks
const mockCollaborativeUpdates = {
collaborativeUpdateLoopType: vi.fn(),
collaborativeUpdateParallelType: vi.fn(),
collaborativeUpdateIterationCount: vi.fn(),
collaborativeUpdateIterationCollection: vi.fn(),
}
const mockStoreData = {
loops: {},
parallels: {},
}
vi.mock('@/hooks/use-collaborative-workflow', () => ({
useCollaborativeWorkflow: () => mockCollaborativeUpdates,
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: () => mockStoreData,
}))
vi.mock('@/components/ui/badge', () => ({
Badge: ({ children, ...props }: any) => (
<div data-testid='badge' {...props}>
{children}
</div>
),
}))
vi.mock('@/components/ui/input', () => ({
Input: (props: any) => <input data-testid='input' {...props} />,
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: any) => <div data-testid='popover'>{children}</div>,
PopoverContent: ({ children }: any) => <div data-testid='popover-content'>{children}</div>,
PopoverTrigger: ({ children }: any) => <div data-testid='popover-trigger'>{children}</div>,
}))
vi.mock('@/components/ui/tag-dropdown', () => ({
checkTagTrigger: vi.fn(() => ({ show: false })),
TagDropdown: ({ children }: any) => <div data-testid='tag-dropdown'>{children}</div>,
}))
vi.mock('react-simple-code-editor', () => ({
default: (props: any) => <textarea data-testid='code-editor' {...props} />,
}))
describe('IterationBadges', () => {
const defaultProps = {
nodeId: 'test-node-1',
data: {
width: 500,
height: 300,
isPreview: false,
},
iterationType: 'loop' as const,
}
beforeEach(() => {
vi.clearAllMocks()
mockStoreData.loops = {}
mockStoreData.parallels = {}
})
describe('Component Interface', () => {
it.concurrent('should accept required props', () => {
expect(defaultProps.nodeId).toBeDefined()
expect(defaultProps.data).toBeDefined()
expect(defaultProps.iterationType).toBeDefined()
})
it.concurrent('should handle loop iteration type prop', () => {
const loopProps = { ...defaultProps, iterationType: 'loop' as const }
expect(loopProps.iterationType).toBe('loop')
})
it.concurrent('should handle parallel iteration type prop', () => {
const parallelProps = { ...defaultProps, iterationType: 'parallel' as const }
expect(parallelProps.iterationType).toBe('parallel')
})
})
describe('Configuration System', () => {
it.concurrent('should use correct config for loop type', () => {
const CONFIG = {
loop: {
typeLabels: { for: 'For Loop', forEach: 'For Each' },
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
},
},
}
expect(CONFIG.loop.typeLabels.for).toBe('For Loop')
expect(CONFIG.loop.typeLabels.forEach).toBe('For Each')
expect(CONFIG.loop.maxIterations).toBe(100)
expect(CONFIG.loop.storeKey).toBe('loops')
})
it.concurrent('should use correct config for parallel type', () => {
const CONFIG = {
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
typeKey: 'parallelType' as const,
storeKey: 'parallels' as const,
maxIterations: 20,
configKeys: {
iterations: 'count' as const,
items: 'distribution' as const,
},
},
}
expect(CONFIG.parallel.typeLabels.count).toBe('Parallel Count')
expect(CONFIG.parallel.typeLabels.collection).toBe('Parallel Each')
expect(CONFIG.parallel.maxIterations).toBe(20)
expect(CONFIG.parallel.storeKey).toBe('parallels')
})
})
describe('Type Determination Logic', () => {
it.concurrent('should default to "for" for loop type', () => {
type IterationType = 'loop' | 'parallel'
const determineDefaultType = (iterationType: IterationType) => {
return iterationType === 'loop' ? 'for' : 'count'
}
const currentType = determineDefaultType('loop')
expect(currentType).toBe('for')
})
it.concurrent('should default to "count" for parallel type', () => {
type IterationType = 'loop' | 'parallel'
const determineDefaultType = (iterationType: IterationType) => {
return iterationType === 'loop' ? 'for' : 'count'
}
const currentType = determineDefaultType('parallel')
expect(currentType).toBe('count')
})
it.concurrent('should use explicit loopType when provided', () => {
type IterationType = 'loop' | 'parallel'
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
}
const currentType = determineType('forEach', 'loop')
expect(currentType).toBe('forEach')
})
it.concurrent('should use explicit parallelType when provided', () => {
type IterationType = 'loop' | 'parallel'
const determineType = (explicitType: string | undefined, iterationType: IterationType) => {
return explicitType || (iterationType === 'loop' ? 'for' : 'count')
}
const currentType = determineType('collection', 'parallel')
expect(currentType).toBe('collection')
})
})
describe('Count Mode Detection', () => {
it.concurrent('should be in count mode for loop + for combination', () => {
type IterationType = 'loop' | 'parallel'
type LoopType = 'for' | 'forEach'
type ParallelType = 'count' | 'collection'
const iterationType: IterationType = 'loop'
const currentType: LoopType = 'for'
const isCountMode = iterationType === 'loop' && currentType === 'for'
expect(isCountMode).toBe(true)
})
it.concurrent('should be in count mode for parallel + count combination', () => {
type IterationType = 'loop' | 'parallel'
type ParallelType = 'count' | 'collection'
const iterationType: IterationType = 'parallel'
const currentType: ParallelType = 'count'
const isCountMode = iterationType === 'parallel' && currentType === 'count'
expect(isCountMode).toBe(true)
})
it.concurrent('should not be in count mode for loop + forEach combination', () => {
type IterationType = 'loop' | 'parallel'
const testCountMode = (iterationType: IterationType, currentType: string) => {
return iterationType === 'loop' && currentType === 'for'
}
const isCountMode = testCountMode('loop', 'forEach')
expect(isCountMode).toBe(false)
})
it.concurrent('should not be in count mode for parallel + collection combination', () => {
type IterationType = 'loop' | 'parallel'
const testCountMode = (iterationType: IterationType, currentType: string) => {
return iterationType === 'parallel' && currentType === 'count'
}
const isCountMode = testCountMode('parallel', 'collection')
expect(isCountMode).toBe(false)
})
})
describe('Configuration Values', () => {
it.concurrent('should handle default iteration count', () => {
const data = { count: undefined }
const configIterations = data.count ?? 5
expect(configIterations).toBe(5)
})
it.concurrent('should use provided iteration count', () => {
const data = { count: 10 }
const configIterations = data.count ?? 5
expect(configIterations).toBe(10)
})
it.concurrent('should handle string collection', () => {
const collection = '[1, 2, 3, 4, 5]'
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('[1, 2, 3, 4, 5]')
})
it.concurrent('should handle object collection', () => {
const collection = { items: [1, 2, 3] }
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('{"items":[1,2,3]}')
})
it.concurrent('should handle array collection', () => {
const collection = [1, 2, 3, 4, 5]
const collectionString =
typeof collection === 'string' ? collection : JSON.stringify(collection) || ''
expect(collectionString).toBe('[1,2,3,4,5]')
})
})
describe('Preview Mode Handling', () => {
it.concurrent('should handle preview mode for loops', () => {
const previewProps = {
...defaultProps,
data: { ...defaultProps.data, isPreview: true },
iterationType: 'loop' as const,
}
expect(previewProps.data.isPreview).toBe(true)
// In preview mode, collaborative functions shouldn't be called
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).not.toHaveBeenCalled()
})
it.concurrent('should handle preview mode for parallels', () => {
const previewProps = {
...defaultProps,
data: { ...defaultProps.data, isPreview: true },
iterationType: 'parallel' as const,
}
expect(previewProps.data.isPreview).toBe(true)
// In preview mode, collaborative functions shouldn't be called
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).not.toHaveBeenCalled()
})
})
describe('Store Integration', () => {
it.concurrent('should access loops store for loop iteration type', () => {
const nodeId = 'loop-node-1'
;(mockStoreData.loops as any)[nodeId] = { iterations: 10 }
const nodeConfig = (mockStoreData.loops as any)[nodeId]
expect(nodeConfig).toBeDefined()
expect(nodeConfig.iterations).toBe(10)
})
it.concurrent('should access parallels store for parallel iteration type', () => {
const nodeId = 'parallel-node-1'
;(mockStoreData.parallels as any)[nodeId] = { count: 5 }
const nodeConfig = (mockStoreData.parallels as any)[nodeId]
expect(nodeConfig).toBeDefined()
expect(nodeConfig.count).toBe(5)
})
it.concurrent('should handle missing node configuration gracefully', () => {
const nodeId = 'missing-node'
const nodeConfig = (mockStoreData.loops as any)[nodeId]
expect(nodeConfig).toBeUndefined()
})
})
describe('Max Iterations Limits', () => {
it.concurrent('should enforce max iterations for loops (100)', () => {
const maxIterations = 100
const testValue = 150
const clampedValue = Math.min(maxIterations, testValue)
expect(clampedValue).toBe(100)
})
it.concurrent('should enforce max iterations for parallels (20)', () => {
const maxIterations = 20
const testValue = 50
const clampedValue = Math.min(maxIterations, testValue)
expect(clampedValue).toBe(20)
})
it.concurrent('should allow values within limits', () => {
const loopMaxIterations = 100
const parallelMaxIterations = 20
expect(Math.min(loopMaxIterations, 50)).toBe(50)
expect(Math.min(parallelMaxIterations, 10)).toBe(10)
})
})
describe('Collaborative Update Functions', () => {
it.concurrent('should have the correct collaborative functions available', () => {
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCount).toBeDefined()
expect(mockCollaborativeUpdates.collaborativeUpdateIterationCollection).toBeDefined()
})
it.concurrent('should call correct function for loop type updates', () => {
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
if (iterationType === 'loop') {
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
} else {
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
}
}
handleTypeChange('forEach', 'loop', 'test-node')
expect(mockCollaborativeUpdates.collaborativeUpdateLoopType).toHaveBeenCalledWith(
'test-node',
'forEach'
)
})
it.concurrent('should call correct function for parallel type updates', () => {
const handleTypeChange = (newType: string, iterationType: string, nodeId: string) => {
if (iterationType === 'loop') {
mockCollaborativeUpdates.collaborativeUpdateLoopType(nodeId, newType)
} else {
mockCollaborativeUpdates.collaborativeUpdateParallelType(nodeId, newType)
}
}
handleTypeChange('collection', 'parallel', 'test-node')
expect(mockCollaborativeUpdates.collaborativeUpdateParallelType).toHaveBeenCalledWith(
'test-node',
'collection'
)
})
})
describe('Input Sanitization', () => {
it.concurrent('should sanitize numeric input by removing non-digits', () => {
const testInput = 'abc123def456'
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('123456')
})
it.concurrent('should handle empty input', () => {
const testInput = ''
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('')
})
it.concurrent('should preserve valid numeric input', () => {
const testInput = '42'
const sanitized = testInput.replace(/[^0-9]/g, '')
expect(sanitized).toBe('42')
})
})
})

View File

@@ -1,452 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: vi.fn(),
}))
vi.mock('@/lib/logs/logger', () => ({
createLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}))
vi.mock('reactflow', () => ({
Handle: ({ id, type, position }: any) => ({ id, type, position }),
Position: {
Top: 'top',
Bottom: 'bottom',
Left: 'left',
Right: 'right',
},
useReactFlow: () => ({
getNodes: vi.fn(() => []),
}),
NodeResizer: ({ isVisible }: any) => ({ isVisible }),
memo: (component: any) => component,
}))
vi.mock('react', async () => {
const actual = await vi.importActual('react')
return {
...actual,
memo: (component: any) => component,
useMemo: (fn: any) => fn(),
useRef: () => ({ current: null }),
}
})
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
}))
vi.mock('@/components/ui/card', () => ({
Card: ({ children, ...props }: any) => ({ children, ...props }),
}))
vi.mock('@/components/icons', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
// Override specific icons if needed for testing
StartIcon: ({ className }: any) => ({ className }),
}
})
vi.mock('@/lib/utils', () => ({
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-badges', () => ({
LoopBadges: ({ loopId }: any) => ({ loopId }),
}))
describe('LoopNodeComponent', () => {
const mockRemoveBlock = vi.fn()
const mockGetNodes = vi.fn()
const defaultProps = {
id: 'loop-1',
type: 'loopNode',
data: {
width: 500,
height: 300,
state: 'valid',
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
beforeEach(() => {
vi.clearAllMocks()
;(useWorkflowStore as any).mockImplementation((selector: any) => {
const state = {
removeBlock: mockRemoveBlock,
}
return selector(state)
})
mockGetNodes.mockReturnValue([])
})
describe('Component Definition and Structure', () => {
it('should be defined as a function component', () => {
expect(LoopNodeComponent).toBeDefined()
expect(typeof LoopNodeComponent).toBe('function')
})
it('should have correct display name', () => {
expect(LoopNodeComponent.displayName).toBe('LoopNodeComponent')
})
it('should be a memoized component', () => {
expect(LoopNodeComponent).toBeDefined()
})
})
describe('Props Validation and Type Safety', () => {
it('should accept NodeProps interface', () => {
const validProps = {
id: 'test-id',
type: 'loopNode' as const,
data: {
width: 400,
height: 300,
state: 'valid' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
expect(() => {
const _component: typeof LoopNodeComponent = LoopNodeComponent
expect(_component).toBeDefined()
}).not.toThrow()
})
it('should handle different data configurations', () => {
const configurations = [
{ width: 500, height: 300, state: 'valid' },
{ width: 800, height: 600, state: 'invalid' },
{ width: 0, height: 0, state: 'pending' },
{},
]
configurations.forEach((data) => {
const props = { ...defaultProps, data }
expect(() => {
const _component: typeof LoopNodeComponent = LoopNodeComponent
expect(_component).toBeDefined()
}).not.toThrow()
})
})
})
describe('Store Integration', () => {
it('should integrate with workflow store', () => {
expect(useWorkflowStore).toBeDefined()
const mockState = { removeBlock: mockRemoveBlock }
const selector = vi.fn((state) => state.removeBlock)
expect(() => {
selector(mockState)
}).not.toThrow()
expect(selector(mockState)).toBe(mockRemoveBlock)
})
it('should handle removeBlock function', () => {
expect(mockRemoveBlock).toBeDefined()
expect(typeof mockRemoveBlock).toBe('function')
mockRemoveBlock('test-id')
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
})
describe('Component Logic Tests', () => {
it('should handle nesting level calculation logic', () => {
const testCases = [
{ nodes: [], parentId: undefined, expectedLevel: 0 },
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
{
nodes: [
{ id: 'parent', data: { parentId: 'grandparent' } },
{ id: 'grandparent', data: {} },
],
parentId: 'parent',
expectedLevel: 2,
},
]
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
mockGetNodes.mockReturnValue(nodes)
// Simulate the nesting level calculation logic
let level = 0
let currentParentId = parentId
while (currentParentId) {
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(expectedLevel)
})
})
it('should handle nested styles generation', () => {
// Test the nested styles logic
const testCases = [
{ nestingLevel: 0, state: 'valid', expectedBg: 'rgba(34,197,94,0.05)' },
{ nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
{ nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
{ nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
]
testCases.forEach(({ nestingLevel, state, expectedBg }) => {
// Simulate the getNestedStyles logic
const styles: Record<string, string> = {
backgroundColor: state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent',
}
if (nestingLevel > 0) {
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30`
}
expect(styles.backgroundColor).toBe(expectedBg)
})
})
})
describe('Component Configuration', () => {
it('should handle different dimensions', () => {
const dimensionTests = [
{ width: 500, height: 300 },
{ width: 800, height: 600 },
{ width: 0, height: 0 },
{ width: 10000, height: 10000 },
]
dimensionTests.forEach(({ width, height }) => {
const data = { width, height, state: 'valid' }
expect(data.width).toBe(width)
expect(data.height).toBe(height)
})
})
it('should handle different states', () => {
const stateTests = ['valid', 'invalid', 'pending', 'executing']
stateTests.forEach((state) => {
const data = { width: 500, height: 300, state }
expect(data.state).toBe(state)
})
})
})
describe('Event Handling Logic', () => {
it('should handle delete button click logic', () => {
const mockEvent = {
stopPropagation: vi.fn(),
}
// Simulate the delete button click handler
const handleDelete = (e: any, nodeId: string) => {
e.stopPropagation()
mockRemoveBlock(nodeId)
}
handleDelete(mockEvent, 'test-id')
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
it('should handle event propagation prevention', () => {
const mockEvent = {
stopPropagation: vi.fn(),
}
// Test that stopPropagation is called
mockEvent.stopPropagation()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
})
describe('Component Data Handling', () => {
it('should handle missing data properties gracefully', () => {
const testCases = [
undefined,
{},
{ width: 500 },
{ height: 300 },
{ state: 'valid' },
{ width: 500, height: 300 },
]
testCases.forEach((data) => {
const props = { ...defaultProps, data }
// Test default values logic
const width = Math.max(0, data?.width || 500)
const height = Math.max(0, data?.height || 300)
expect(width).toBeGreaterThanOrEqual(0)
expect(height).toBeGreaterThanOrEqual(0)
})
})
it('should handle parent ID relationships', () => {
const testCases = [
{ parentId: undefined, hasParent: false },
{ parentId: 'parent-1', hasParent: true },
{ parentId: '', hasParent: false },
]
testCases.forEach(({ parentId, hasParent }) => {
const data = { ...defaultProps.data, parentId }
expect(Boolean(data.parentId)).toBe(hasParent)
})
})
})
describe('Edge Cases and Error Handling', () => {
it('should handle circular parent references', () => {
// Test circular reference prevention
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
// Test the actual component's nesting level calculation logic
// This simulates the real useMemo logic from the component
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
// This is the actual logic pattern used in the component
while (currentParentId) {
// If we've seen this parent before, we have a cycle - break immediately
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// With proper circular reference detection, we should stop at level 2
// (node1 -> node2, then detect cycle when trying to go back to node1)
expect(level).toBe(2)
expect(visited.has('node1')).toBe(true)
expect(visited.has('node2')).toBe(true)
})
it('should handle complex circular reference chains', () => {
// Test more complex circular reference scenarios
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node3' } },
{ id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break // Cycle detected
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// Should traverse node1 -> node2 -> node3, then detect cycle
expect(level).toBe(3)
expect(visited.size).toBe(3)
})
it('should handle self-referencing nodes', () => {
// Test node that references itself
const nodes = [
{ id: 'node1', data: { parentId: 'node1' } }, // Self-reference
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break // Cycle detected immediately
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// Should detect self-reference immediately after first iteration
expect(level).toBe(1)
expect(visited.has('node1')).toBe(true)
})
it('should handle extreme values', () => {
const extremeValues = [
{ width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
{ width: -1, height: -1 },
{ width: 0, height: 0 },
{ width: null, height: null },
]
extremeValues.forEach((data) => {
expect(() => {
const width = data.width || 500
const height = data.height || 300
expect(typeof width).toBe('number')
expect(typeof height).toBe('number')
}).not.toThrow()
})
})
})
})

View File

@@ -1,585 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: vi.fn(),
}))
vi.mock('@/lib/logs/logger', () => ({
createLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}))
vi.mock('reactflow', () => ({
Handle: ({ id, type, position }: any) => ({ id, type, position }),
Position: {
Top: 'top',
Bottom: 'bottom',
Left: 'left',
Right: 'right',
},
useReactFlow: () => ({
getNodes: vi.fn(() => []),
}),
NodeResizer: ({ isVisible }: any) => ({ isVisible }),
memo: (component: any) => component,
}))
vi.mock('react', async () => {
const actual = await vi.importActual('react')
return {
...actual,
memo: (component: any) => component,
useMemo: (fn: any) => fn(),
useRef: () => ({ current: null }),
}
})
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
}))
vi.mock('@/components/ui/card', () => ({
Card: ({ children, ...props }: any) => ({ children, ...props }),
}))
vi.mock('@/blocks/registry', () => ({
getBlock: vi.fn(() => ({
name: 'Mock Block',
description: 'Mock block description',
icon: () => null,
subBlocks: [],
outputs: {},
})),
getAllBlocks: vi.fn(() => ({})),
}))
vi.mock('@/lib/utils', () => ({
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/components/parallel-badges',
() => ({
ParallelBadges: ({ parallelId }: any) => ({ parallelId }),
})
)
describe('ParallelNodeComponent', () => {
const mockRemoveBlock = vi.fn()
const mockGetNodes = vi.fn()
const defaultProps = {
id: 'parallel-1',
type: 'parallelNode',
data: {
width: 500,
height: 300,
state: 'valid',
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
beforeEach(() => {
vi.clearAllMocks()
;(useWorkflowStore as any).mockImplementation((selector: any) => {
const state = {
removeBlock: mockRemoveBlock,
}
return selector(state)
})
mockGetNodes.mockReturnValue([])
})
describe('Component Definition and Structure', () => {
it.concurrent('should be defined as a function component', () => {
expect(ParallelNodeComponent).toBeDefined()
expect(typeof ParallelNodeComponent).toBe('function')
})
it.concurrent('should have correct display name', () => {
expect(ParallelNodeComponent.displayName).toBe('ParallelNodeComponent')
})
it.concurrent('should be a memoized component', () => {
expect(ParallelNodeComponent).toBeDefined()
})
})
describe('Props Validation and Type Safety', () => {
it.concurrent('should accept NodeProps interface', () => {
expect(() => {
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
expect(_component).toBeDefined()
}).not.toThrow()
})
it.concurrent('should handle different data configurations', () => {
const configurations = [
{ width: 500, height: 300, state: 'valid' },
{ width: 800, height: 600, state: 'invalid' },
{ width: 0, height: 0, state: 'pending' },
{},
]
configurations.forEach((data) => {
const props = { ...defaultProps, data }
expect(() => {
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
expect(_component).toBeDefined()
}).not.toThrow()
})
})
})
describe('Store Integration', () => {
it.concurrent('should integrate with workflow store', () => {
expect(useWorkflowStore).toBeDefined()
const mockState = { removeBlock: mockRemoveBlock }
const selector = vi.fn((state) => state.removeBlock)
expect(() => {
selector(mockState)
}).not.toThrow()
expect(selector(mockState)).toBe(mockRemoveBlock)
})
it.concurrent('should handle removeBlock function', () => {
expect(mockRemoveBlock).toBeDefined()
expect(typeof mockRemoveBlock).toBe('function')
mockRemoveBlock('test-id')
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
})
describe('Component Logic Tests', () => {
it.concurrent('should handle nesting level calculation logic', () => {
const testCases = [
{ nodes: [], parentId: undefined, expectedLevel: 0 },
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
{
nodes: [
{ id: 'parent', data: { parentId: 'grandparent' } },
{ id: 'grandparent', data: {} },
],
parentId: 'parent',
expectedLevel: 2,
},
]
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = parentId
while (currentParentId) {
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(expectedLevel)
})
})
it.concurrent('should handle nested styles generation for parallel nodes', () => {
const testCases = [
{ nestingLevel: 0, state: 'valid', expectedBg: 'rgba(254,225,43,0.05)' },
{ nestingLevel: 0, state: 'invalid', expectedBg: 'transparent' },
{ nestingLevel: 1, state: 'valid', expectedBg: '#e2e8f030' },
{ nestingLevel: 2, state: 'valid', expectedBg: '#cbd5e130' },
]
testCases.forEach(({ nestingLevel, state, expectedBg }) => {
const styles: Record<string, string> = {
backgroundColor: state === 'valid' ? 'rgba(254,225,43,0.05)' : 'transparent',
}
if (nestingLevel > 0) {
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30`
}
expect(styles.backgroundColor).toBe(expectedBg)
})
})
})
describe('Parallel-Specific Features', () => {
it.concurrent('should handle parallel execution states', () => {
const parallelStates = ['valid', 'invalid', 'executing', 'completed', 'pending']
parallelStates.forEach((state) => {
const data = { width: 500, height: 300, state }
expect(data.state).toBe(state)
const isExecuting = state === 'executing'
const isCompleted = state === 'completed'
expect(typeof isExecuting).toBe('boolean')
expect(typeof isCompleted).toBe('boolean')
})
})
it.concurrent('should handle parallel node color scheme', () => {
const parallelColors = {
background: 'rgba(254,225,43,0.05)',
ring: '#FEE12B',
startIcon: '#FEE12B',
}
expect(parallelColors.background).toContain('254,225,43')
expect(parallelColors.ring).toBe('#FEE12B')
expect(parallelColors.startIcon).toBe('#FEE12B')
})
it.concurrent('should differentiate from loop node styling', () => {
const loopColors = {
background: 'rgba(34,197,94,0.05)',
ring: '#2FB3FF',
startIcon: '#2FB3FF',
}
const parallelColors = {
background: 'rgba(254,225,43,0.05)',
ring: '#FEE12B',
startIcon: '#FEE12B',
}
expect(parallelColors.background).not.toBe(loopColors.background)
expect(parallelColors.ring).not.toBe(loopColors.ring)
expect(parallelColors.startIcon).not.toBe(loopColors.startIcon)
})
})
describe('Component Configuration', () => {
it.concurrent('should handle different dimensions', () => {
const dimensionTests = [
{ width: 500, height: 300 },
{ width: 800, height: 600 },
{ width: 0, height: 0 },
{ width: 10000, height: 10000 },
]
dimensionTests.forEach(({ width, height }) => {
const data = { width, height, state: 'valid' }
expect(data.width).toBe(width)
expect(data.height).toBe(height)
})
})
it.concurrent('should handle different states', () => {
const stateTests = ['valid', 'invalid', 'pending', 'executing', 'completed']
stateTests.forEach((state) => {
const data = { width: 500, height: 300, state }
expect(data.state).toBe(state)
})
})
})
describe('Event Handling Logic', () => {
it.concurrent('should handle delete button click logic', () => {
const mockEvent = {
stopPropagation: vi.fn(),
}
const handleDelete = (e: any, nodeId: string) => {
e.stopPropagation()
mockRemoveBlock(nodeId)
}
handleDelete(mockEvent, 'test-id')
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
it.concurrent('should handle event propagation prevention', () => {
const mockEvent = {
stopPropagation: vi.fn(),
}
mockEvent.stopPropagation()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
})
describe('Component Data Handling', () => {
it.concurrent('should handle missing data properties gracefully', () => {
const testCases = [
undefined,
{},
{ width: 500 },
{ height: 300 },
{ state: 'valid' },
{ width: 500, height: 300 },
]
testCases.forEach((data) => {
const props = { ...defaultProps, data }
// Test default values logic
const width = data?.width || 500
const height = data?.height || 300
expect(width).toBeGreaterThanOrEqual(0)
expect(height).toBeGreaterThanOrEqual(0)
})
})
it.concurrent('should handle parent ID relationships', () => {
const testCases = [
{ parentId: undefined, hasParent: false },
{ parentId: 'parent-1', hasParent: true },
{ parentId: '', hasParent: false },
]
testCases.forEach(({ parentId, hasParent }) => {
const data = { ...defaultProps.data, parentId }
expect(Boolean(data.parentId)).toBe(hasParent)
})
})
})
describe('Handle Configuration', () => {
it.concurrent('should have correct handle IDs for parallel nodes', () => {
const handleIds = {
startSource: 'parallel-start-source',
endSource: 'parallel-end-source',
}
expect(handleIds.startSource).toContain('parallel')
expect(handleIds.endSource).toContain('parallel')
expect(handleIds.startSource).not.toContain('loop')
expect(handleIds.endSource).not.toContain('loop')
})
it.concurrent('should handle different handle positions', () => {
const positions = {
left: 'left',
right: 'right',
top: 'top',
bottom: 'bottom',
}
Object.values(positions).forEach((position) => {
expect(typeof position).toBe('string')
expect(position.length).toBeGreaterThan(0)
})
})
})
describe('Edge Cases and Error Handling', () => {
it.concurrent('should handle circular parent references', () => {
// Test circular reference prevention
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
// Test the actual component's nesting level calculation logic
// This simulates the real useMemo logic from the component
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
// This is the actual logic pattern used in the component
while (currentParentId) {
// If we've seen this parent before, we have a cycle - break immediately
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// With proper circular reference detection, we should stop at level 2
// (node1 -> node2, then detect cycle when trying to go back to node1)
expect(level).toBe(2)
expect(visited.has('node1')).toBe(true)
expect(visited.has('node2')).toBe(true)
})
it.concurrent('should handle complex circular reference chains', () => {
// Test more complex circular reference scenarios
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node3' } },
{ id: 'node3', data: { parentId: 'node1' } }, // Creates a 3-node cycle
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break // Cycle detected
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// Should traverse node1 -> node2 -> node3, then detect cycle
expect(level).toBe(3)
expect(visited.size).toBe(3)
})
it.concurrent('should handle self-referencing nodes', () => {
// Test node that references itself
const nodes = [
{ id: 'node1', data: { parentId: 'node1' } }, // Self-reference
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break // Cycle detected immediately
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
// Should detect self-reference immediately after first iteration
expect(level).toBe(1)
expect(visited.has('node1')).toBe(true)
})
it.concurrent('should handle extreme values', () => {
const extremeValues = [
{ width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
{ width: -1, height: -1 },
{ width: 0, height: 0 },
{ width: null, height: null },
]
extremeValues.forEach((data) => {
expect(() => {
const width = data.width || 500
const height = data.height || 300
expect(typeof width).toBe('number')
expect(typeof height).toBe('number')
}).not.toThrow()
})
})
it.concurrent('should handle negative position values', () => {
const positions = [
{ xPos: -100, yPos: -200 },
{ xPos: 0, yPos: 0 },
{ xPos: 1000, yPos: 2000 },
]
positions.forEach(({ xPos, yPos }) => {
const props = { ...defaultProps, xPos, yPos }
expect(props.xPos).toBe(xPos)
expect(props.yPos).toBe(yPos)
expect(typeof props.xPos).toBe('number')
expect(typeof props.yPos).toBe('number')
})
})
})
describe('Component Comparison with Loop Node', () => {
it.concurrent('should have similar structure to loop node but different type', () => {
expect(defaultProps.type).toBe('parallelNode')
expect(defaultProps.id).toContain('parallel')
// Should not be a loop node
expect(defaultProps.type).not.toBe('loopNode')
expect(defaultProps.id).not.toContain('loop')
})
it.concurrent('should handle the same prop structure as loop node', () => {
// Test that parallel node accepts the same prop structure as loop node
const sharedPropStructure = {
id: 'test-parallel',
type: 'parallelNode' as const,
data: {
width: 400,
height: 300,
state: 'valid' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
expect(() => {
const _component: typeof ParallelNodeComponent = ParallelNodeComponent
expect(_component).toBeDefined()
}).not.toThrow()
// Verify the structure
expect(sharedPropStructure.type).toBe('parallelNode')
expect(sharedPropStructure.data.width).toBe(400)
expect(sharedPropStructure.data.height).toBe(300)
})
it.concurrent('should maintain consistency with loop node interface', () => {
const baseProps = [
'id',
'type',
'data',
'selected',
'zIndex',
'isConnectable',
'xPos',
'yPos',
'dragging',
]
baseProps.forEach((prop) => {
expect(defaultProps).toHaveProperty(prop)
})
})
})
})

View File

@@ -1,273 +0,0 @@
import type React from 'react'
import { memo, useMemo, useRef } from 'react'
import { Trash2 } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
const ParallelNodeStyles: React.FC = () => {
return (
<style jsx global>{`
@keyframes parallel-node-pulse {
0% {
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3);
}
70% {
box-shadow: 0 0 0 6px rgba(139, 195, 74, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(139, 195, 74, 0);
}
}
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1)
infinite;
border-style: solid !important;
background-color: rgba(139, 195, 74, 0.08) !important;
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
/* Make resizer handles more visible */
.react-flow__resize-control {
z-index: 10;
pointer-events: all !important;
}
/* Ensure parent borders are visible when hovering over resize controls */
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* React Flow position transitions within parallel blocks */
.react-flow__node[data-parent-node-id] {
transition: transform 0.05s ease;
pointer-events: all;
}
/* Prevent jumpy drag behavior */
.parallel-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(139, 195, 74, 0.05);
transition: all 0.2s ease-in-out;
}
`}</style>
)
}
export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(null)
// Use the clean abstraction for current workflow state
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
const diffStatus =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
const maxDepth = 100 // Prevent infinite loops
let level = 0
let currentParentId = data?.parentId
while (currentParentId && level < maxDepth) {
level++
const parentNode = getNodes().find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
return level
}, [id, data?.parentId, getNodes])
// Generate different background styles based on nesting level
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: 'rgba(0, 0, 0, 0.02)',
}
// Apply nested styles
if (nestingLevel > 0) {
// Each nesting level gets a different color
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background
}
return styles
}
const nestedStyles = getNestedStyles()
return (
<>
<ParallelNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
// Diff highlighting
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
diffStatus === 'edited' &&
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
)}
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='parallelNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the parallel node movement */}
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
collaborativeRemoveBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Parallel Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='parallel-start'
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
<Handle
type='source'
position={Position.Right}
id='parallel-start-source'
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!left-[-10px] hover:!rounded-l-full hover:!rounded-r-none !cursor-crosshair transition-[colors] duration-150'
style={{
left: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className='!w-[7px] !h-5 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-7px',
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='parallel-end-source'
/>
{/* Parallel Configuration Badges */}
<IterationBadges nodeId={id} data={data} iterationType='parallel' />
</Card>
</div>
</>
)
})
ParallelNodeComponent.displayName = 'ParallelNodeComponent'

View File

@@ -0,0 +1,579 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
// Shared spies used across mocks
const mockRemoveBlock = vi.fn()
const mockGetNodes = vi.fn()
// Mocks
vi.mock('@/hooks/use-collaborative-workflow', () => ({
useCollaborativeWorkflow: vi.fn(() => ({
collaborativeRemoveBlock: mockRemoveBlock,
})),
}))
vi.mock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}))
vi.mock('reactflow', () => ({
Handle: ({ id, type, position }: any) => ({ id, type, position }),
Position: {
Top: 'top',
Bottom: 'bottom',
Left: 'left',
Right: 'right',
},
useReactFlow: () => ({
getNodes: mockGetNodes,
}),
memo: (component: any) => component,
}))
vi.mock('react', async () => {
const actual = await vi.importActual<any>('react')
return {
...actual,
memo: (component: any) => component,
useMemo: (fn: any) => fn(),
useRef: () => ({ current: null }),
}
})
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, ...props }: any) => ({ children, onClick, ...props }),
}))
vi.mock('@/components/ui/card', () => ({
Card: ({ children, ...props }: any) => ({ children, ...props }),
}))
vi.mock('@/components/icons', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
StartIcon: ({ className }: any) => ({ className }),
}
})
vi.mock('@/lib/utils', () => ({
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges',
() => ({
IterationBadges: ({ nodeId, iterationType }: any) => ({ nodeId, iterationType }),
})
)
describe('SubflowNodeComponent', () => {
const defaultProps = {
id: 'subflow-1',
type: 'subflowNode',
data: {
width: 500,
height: 300,
isPreview: false,
kind: 'loop' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
beforeEach(() => {
vi.clearAllMocks()
mockGetNodes.mockReturnValue([])
})
describe('Component Definition and Structure', () => {
it.concurrent('should be defined as a function component', () => {
expect(SubflowNodeComponent).toBeDefined()
expect(typeof SubflowNodeComponent).toBe('function')
})
it.concurrent('should have correct display name', () => {
expect(SubflowNodeComponent.displayName).toBe('SubflowNodeComponent')
})
it.concurrent('should be a memoized component', () => {
expect(SubflowNodeComponent).toBeDefined()
})
})
describe('Props Validation and Type Safety', () => {
it.concurrent('should accept NodeProps interface', () => {
const validProps = {
id: 'test-id',
type: 'subflowNode' as const,
data: {
width: 400,
height: 300,
isPreview: true,
kind: 'parallel' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
expect(() => {
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
expect(_component).toBeDefined()
expect(validProps.type).toBe('subflowNode')
}).not.toThrow()
})
it.concurrent('should handle different data configurations', () => {
const configurations = [
{ width: 500, height: 300, isPreview: false, kind: 'loop' as const },
{ width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
{ width: 0, height: 0, isPreview: false, kind: 'loop' as const },
{ kind: 'loop' as const },
]
configurations.forEach((data) => {
const props = { ...defaultProps, data }
expect(() => {
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
expect(_component).toBeDefined()
expect(props.data).toBeDefined()
}).not.toThrow()
})
})
})
describe('Hook Integration', () => {
it.concurrent('should provide collaborativeRemoveBlock', () => {
expect(mockRemoveBlock).toBeDefined()
expect(typeof mockRemoveBlock).toBe('function')
mockRemoveBlock('test-id')
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
})
describe('Component Logic Tests', () => {
it.concurrent('should handle nesting level calculation logic', () => {
const testCases = [
{ nodes: [], parentId: undefined, expectedLevel: 0 },
{ nodes: [{ id: 'parent', data: {} }], parentId: 'parent', expectedLevel: 1 },
{
nodes: [
{ id: 'parent', data: { parentId: 'grandparent' } },
{ id: 'grandparent', data: {} },
],
parentId: 'parent',
expectedLevel: 2,
},
]
testCases.forEach(({ nodes, parentId, expectedLevel }) => {
mockGetNodes.mockReturnValue(nodes)
// Simulate the nesting level calculation logic
let level = 0
let currentParentId = parentId
while (currentParentId) {
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(expectedLevel)
})
})
it.concurrent('should handle nested styles generation', () => {
// Test the nested styles logic
const testCases = [
{ nestingLevel: 0, expectedBg: 'rgba(34,197,94,0.05)' },
{ nestingLevel: 1, expectedBg: '#e2e8f030' },
{ nestingLevel: 2, expectedBg: '#cbd5e130' },
]
testCases.forEach(({ nestingLevel, expectedBg }) => {
// Simulate the getNestedStyles logic
const styles: Record<string, string> = {
backgroundColor: 'rgba(34,197,94,0.05)',
}
if (nestingLevel > 0) {
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30`
}
expect(styles.backgroundColor).toBe(expectedBg)
})
})
})
describe('Component Configuration', () => {
it.concurrent('should handle different dimensions', () => {
const dimensionTests = [
{ width: 500, height: 300 },
{ width: 800, height: 600 },
{ width: 0, height: 0 },
{ width: 10000, height: 10000 },
]
dimensionTests.forEach(({ width, height }) => {
const data = { width, height }
expect(data.width).toBe(width)
expect(data.height).toBe(height)
})
})
})
describe('Event Handling Logic', () => {
it.concurrent('should handle delete button click logic (simulated)', () => {
const mockEvent = { stopPropagation: vi.fn() }
const handleDelete = (e: any, nodeId: string) => {
e.stopPropagation()
mockRemoveBlock(nodeId)
}
handleDelete(mockEvent, 'test-id')
expect(mockEvent.stopPropagation).toHaveBeenCalled()
expect(mockRemoveBlock).toHaveBeenCalledWith('test-id')
})
it.concurrent('should handle event propagation prevention', () => {
const mockEvent = { stopPropagation: vi.fn() }
mockEvent.stopPropagation()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
})
describe('Component Data Handling', () => {
it.concurrent('should handle missing data properties gracefully', () => {
const testCases = [
undefined,
{},
{ width: 500 },
{ height: 300 },
{ width: 500, height: 300 },
]
testCases.forEach((data: any) => {
const props = { ...defaultProps, data }
const width = Math.max(0, data?.width || 500)
const height = Math.max(0, data?.height || 300)
expect(width).toBeGreaterThanOrEqual(0)
expect(height).toBeGreaterThanOrEqual(0)
expect(props.type).toBe('subflowNode')
})
})
it.concurrent('should handle parent ID relationships', () => {
const testCases = [
{ parentId: undefined, hasParent: false },
{ parentId: 'parent-1', hasParent: true },
{ parentId: '', hasParent: false },
]
testCases.forEach(({ parentId, hasParent }) => {
const data = { ...defaultProps.data, parentId }
expect(Boolean(data.parentId)).toBe(hasParent)
})
})
})
describe('Loop vs Parallel Kind Specific Tests', () => {
it.concurrent('should generate correct handle IDs for loop kind', () => {
const loopData = { ...defaultProps.data, kind: 'loop' as const }
const startHandleId = loopData.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = loopData.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
expect(startHandleId).toBe('loop-start-source')
expect(endHandleId).toBe('loop-end-source')
})
it.concurrent('should generate correct handle IDs for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testHandleGeneration = (kind: SubflowKind) => {
const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
return { startHandleId, endHandleId }
}
const result = testHandleGeneration('parallel')
expect(result.startHandleId).toBe('parallel-start-source')
expect(result.endHandleId).toBe('parallel-end-source')
})
it.concurrent('should generate correct background colors for loop kind', () => {
const loopData = { ...defaultProps.data, kind: 'loop' as const }
const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
expect(startBg).toBe('#2FB3FF')
})
it.concurrent('should generate correct background colors for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testBgGeneration = (kind: SubflowKind) => {
return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
}
const startBg = testBgGeneration('parallel')
expect(startBg).toBe('#FEE12B')
})
it.concurrent('should demonstrate handle ID generation for any kind', () => {
type SubflowKind = 'loop' | 'parallel'
const testKind = (kind: SubflowKind) => {
const data = { kind }
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
return { startHandleId, endHandleId }
}
const loopResult = testKind('loop')
expect(loopResult.startHandleId).toBe('loop-start-source')
expect(loopResult.endHandleId).toBe('loop-end-source')
const parallelResult = testKind('parallel')
expect(parallelResult.startHandleId).toBe('parallel-start-source')
expect(parallelResult.endHandleId).toBe('parallel-end-source')
})
it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
// Mock IterationBadges should receive the kind as iterationType
expect(loopProps.data.kind).toBe('loop')
})
it.concurrent('should pass correct iterationType to IterationBadges for parallel', () => {
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// Mock IterationBadges should receive the kind as iterationType
expect(parallelProps.data.kind).toBe('parallel')
})
it.concurrent('should handle both kinds in configuration arrays', () => {
const bothKinds = ['loop', 'parallel'] as const
bothKinds.forEach((kind) => {
const data = { ...defaultProps.data, kind }
expect(['loop', 'parallel']).toContain(data.kind)
// Test handle ID generation for both kinds
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
if (kind === 'loop') {
expect(startHandleId).toBe('loop-start-source')
expect(endHandleId).toBe('loop-end-source')
expect(startBg).toBe('#2FB3FF')
} else {
expect(startHandleId).toBe('parallel-start-source')
expect(endHandleId).toBe('parallel-end-source')
expect(startBg).toBe('#FEE12B')
}
})
})
it.concurrent('should maintain consistent styling behavior across both kinds', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// Both should have same base properties except kind-specific ones
expect(loopProps.data.width).toBe(parallelProps.data.width)
expect(loopProps.data.height).toBe(parallelProps.data.height)
expect(loopProps.data.isPreview).toBe(parallelProps.data.isPreview)
// But different kinds
expect(loopProps.data.kind).toBe('loop')
expect(parallelProps.data.kind).toBe('parallel')
})
})
describe('Integration with IterationBadges', () => {
it.concurrent('should pass nodeId to IterationBadges', () => {
const testId = 'test-subflow-123'
const props = { ...defaultProps, id: testId }
// Verify the props would be passed correctly
expect(props.id).toBe(testId)
})
it.concurrent('should pass data object to IterationBadges', () => {
const testData = { ...defaultProps.data, customProperty: 'test' }
const props = { ...defaultProps, data: testData }
// Verify the data object structure
expect(props.data).toEqual(testData)
expect(props.data.kind).toBeDefined()
})
it.concurrent('should pass iterationType matching the kind', () => {
const loopProps = { ...defaultProps, data: { ...defaultProps.data, kind: 'loop' as const } }
const parallelProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
// The iterationType should match the kind
expect(loopProps.data.kind).toBe('loop')
expect(parallelProps.data.kind).toBe('parallel')
})
})
describe('CSS Class Generation', () => {
it.concurrent('should generate proper CSS classes for nested loops', () => {
const nestingLevel = 2
const expectedBorderClass =
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
expect(expectedBorderClass).toBeTruthy()
expect(expectedBorderClass).toContain('border-slate-300/60') // even nesting level
})
it.concurrent('should generate proper CSS classes for odd nested levels', () => {
const nestingLevel = 3
const expectedBorderClass =
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
expect(expectedBorderClass).toBeTruthy()
expect(expectedBorderClass).toContain('border-slate-400/60') // odd nesting level
})
it.concurrent('should handle error state styling', () => {
const hasNestedError = true
const errorClasses = hasNestedError && 'border-2 border-red-500 bg-red-50/50'
expect(errorClasses).toBe('border-2 border-red-500 bg-red-50/50')
})
it.concurrent('should handle diff status styling', () => {
const diffStatuses = ['new', 'edited'] as const
diffStatuses.forEach((status) => {
let diffClass = ''
if (status === 'new') {
diffClass = 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10'
} else if (status === 'edited') {
diffClass = 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
}
expect(diffClass).toBeTruthy()
if (status === 'new') {
expect(diffClass).toContain('ring-green-500')
} else {
expect(diffClass).toContain('ring-orange-500')
}
})
})
})
describe('Edge Cases and Error Handling', () => {
it.concurrent('should handle circular parent references', () => {
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(2)
expect(visited.has('node1')).toBe(true)
expect(visited.has('node2')).toBe(true)
})
it.concurrent('should handle complex circular reference chains', () => {
const nodes = [
{ id: 'node1', data: { parentId: 'node2' } },
{ id: 'node2', data: { parentId: 'node3' } },
{ id: 'node3', data: { parentId: 'node1' } },
]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(3)
expect(visited.size).toBe(3)
})
it.concurrent('should handle self-referencing nodes', () => {
const nodes = [{ id: 'node1', data: { parentId: 'node1' } }]
mockGetNodes.mockReturnValue(nodes)
let level = 0
let currentParentId = 'node1'
const visited = new Set<string>()
while (currentParentId) {
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
level++
const parentNode = nodes.find((n) => n.id === currentParentId)
if (!parentNode) break
currentParentId = parentNode.data?.parentId
}
expect(level).toBe(1)
expect(visited.has('node1')).toBe(true)
})
})
})

View File

@@ -6,60 +6,54 @@ import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
// Add these styles to your existing global CSS file or create a separate CSS module
const LoopNodeStyles: React.FC = () => {
const SubflowNodeStyles: React.FC = () => {
return (
<style jsx global>{`
@keyframes loop-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); }
100% { box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); }
0% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(47, 179, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 179, 255, 0); }
}
@keyframes parallel-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(139, 195, 74, 0); }
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
}
.loop-node-drag-over {
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(47, 179, 255, 0.08) !important;
box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1);
}
/* Ensure parent borders are visible when hovering over resize controls */
.parallel-node-drag-over {
animation: parallel-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(139, 195, 74, 0.08) !important;
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
}
/* Ensure hover effects work well */
.group-node-container:hover .react-flow__resize-control.bottom-right {
opacity: 1 !important;
visibility: visible !important;
}
/* Prevent jumpy drag behavior */
.loop-drop-container .react-flow__node {
transform-origin: center;
position: absolute;
}
/* Remove default border from React Flow group nodes */
.react-flow__node-group {
border: none;
background-color: transparent;
outline: none;
box-shadow: none;
}
/* Ensure child nodes stay within parent bounds */
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}
/* Enhanced drag detection */
.react-flow__node-group.dragging-over {
background-color: rgba(34,197,94,0.05);
transition: all 0.2s ease-in-out;
@@ -68,21 +62,30 @@ const LoopNodeStyles: React.FC = () => {
)
}
export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
export interface SubflowNodeData {
width?: number
height?: number
parentId?: string
extent?: 'parent'
hasNestedError?: boolean
isPreview?: boolean
kind: 'loop' | 'parallel'
}
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
const blockRef = useRef<HTMLDivElement>(null)
// Use the clean abstraction for current workflow state
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
const diffStatus =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.is_diff
: undefined
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
let level = 0
let currentParentId = data?.parentId
@@ -97,42 +100,37 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
return level
}, [id, data?.parentId, getNodes])
// Generate different background styles based on nesting level
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: 'rgba(0, 0, 0, 0.02)',
}
// Apply nested styles
if (nestingLevel > 0) {
// Each nesting level gets a different color
const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']
const colorIndex = (nestingLevel - 1) % colors.length
styles.backgroundColor = `${colors[colorIndex]}30` // Slightly more visible background
styles.backgroundColor = `${colors[colorIndex]}30`
}
return styles
}
const nestedStyles = getNestedStyles()
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
return (
<>
<LoopNodeStyles />
<SubflowNodeStyles />
<div className='group relative'>
<Card
ref={blockRef}
className={cn(
' relative cursor-default select-none',
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`,
data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50',
// Diff highlighting
diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10',
diffStatus === 'edited' &&
'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10'
@@ -146,10 +144,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='loopNode'
data-type='subflowNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the loop node movement */}
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
@@ -157,7 +154,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
/>
)}
{/* Custom visible resize handle */}
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
@@ -165,7 +161,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
/>
)}
{/* Child nodes container - Enable pointer events to allow dragging of children */}
<div
className='h-[calc(100%-10px)] p-4'
data-dragarea='true'
@@ -175,7 +170,6 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
{!isPreview && (
<Button
variant='ghost'
@@ -191,12 +185,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
</Button>
)}
{/* Loop Start Block */}
{/* Subflow Start */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto', backgroundColor: startBg }}
data-parent-id={id}
data-node-role='loop-start'
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<StartIcon className='h-6 w-6 text-white' />
@@ -204,7 +198,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
<Handle
type='source'
position={Position.Right}
id='loop-start-source'
id={startHandleId}
className='!w-[6px] !h-4 !bg-slate-300 dark:!bg-slate-500 !rounded-[2px] !border-none !z-[30] hover:!w-[10px] hover:!right-[-10px] hover:!rounded-r-full hover:!rounded-l-none !cursor-crosshair transition-[colors] duration-150'
style={{
right: '-6px',
@@ -241,15 +235,14 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
id='loop-end-source'
id={endHandleId}
/>
{/* Loop Configuration Badges */}
<IterationBadges nodeId={id} data={data} iterationType='loop' />
<IterationBadges nodeId={id} data={data} iterationType={data.kind} />
</Card>
</div>
</>
)
})
LoopNodeComponent.displayName = 'LoopNodeComponent'
SubflowNodeComponent.displayName = 'SubflowNodeComponent'

View File

@@ -1,4 +1,4 @@
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, LogOut, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -23,6 +23,10 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
const horizontalHandles = useWorkflowStore(
(state) => state.blocks[blockId]?.horizontalHandles ?? false
)
const parentId = useWorkflowStore((state) => state.blocks[blockId]?.data?.parentId)
const parentType = useWorkflowStore((state) =>
parentId ? state.blocks[parentId]?.type : undefined
)
const userPermissions = useUserPermissionsContext()
const isStarterBlock = blockType === 'starter'
@@ -102,6 +106,33 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
</Tooltip>
)}
{/* Remove from subflow - only show when inside loop/parallel */}
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -6,9 +6,9 @@ import {
type SlackChannelInfo,
SlackChannelSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface ChannelSelectorInputProps {
blockId: string
@@ -29,28 +29,29 @@ export function ChannelSelectorInput({
isPreview = false,
previewValue,
}: ChannelSelectorInputProps) {
const { getValue } = useSubBlockStore()
// Use the proper hook to get the current value and setter (same as file-selector)
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Reactive upstream fields
const [authMethod] = useSubBlockValue(blockId, 'authMethod')
const [botToken] = useSubBlockValue(blockId, 'botToken')
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
// Get provider-specific values
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the credential for the provider - use provided credential or fall back to store
const authMethod = getValue(blockId, 'authMethod') as string
const botToken = getValue(blockId, 'botToken') as string
// Get the credential for the provider - use provided credential or fall back to reactive values
let credential: string
if (providedCredential) {
credential = providedCredential
} else if (authMethod === 'bot_token' && botToken) {
credential = botToken
} else if ((authMethod as string) === 'bot_token' && (botToken as string)) {
credential = botToken as string
} else {
credential = (getValue(blockId, 'credential') as string) || ''
credential = (connectedCredential as string) || ''
}
// Use preview value when in preview mode, otherwise use store value
@@ -58,18 +59,11 @@ export function ChannelSelectorInput({
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
useEffect(() => {
if (isPreview && previewValue !== undefined) {
const value = previewValue
if (value && typeof value === 'string') {
setSelectedChannelId(value)
}
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedChannelId(value)
}
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (val && typeof val === 'string') {
setSelectedChannelId(val)
}
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
}, [isPreview, previewValue, storeValue])
// Handle channel selection (same pattern as file-selector)
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
@@ -95,15 +89,10 @@ export function ChannelSelectorInput({
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={disabled || !credential}
disabled={finalDisabled}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select a Slack account or enter a bot token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)

View File

@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('CredentialSelector')
@@ -124,12 +123,10 @@ export function CredentialSelector({
}
}, [effectiveProviderId, selectedId, activeWorkflowId])
// Fetch credentials on initial mount
// Fetch credentials on initial mount and whenever the subblock value changes externally
useEffect(() => {
fetchCredentials()
// This effect should only run once on mount, so empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [fetchCredentials, effectiveValue])
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
useEffect(() => {
@@ -180,6 +177,19 @@ export function CredentialSelector({
}
}, [fetchCredentials])
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
useEffect(() => {
const handlePageShow = (event: any) => {
if (event?.persisted) {
fetchCredentials()
}
}
window.addEventListener('pageshow', handlePageShow)
return () => {
window.removeEventListener('pageshow', handlePageShow)
}
}, [fetchCredentials])
// Handle popover open to fetch fresh credentials
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
@@ -193,23 +203,19 @@ export function CredentialSelector({
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
// If the list doesnt contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
const displayName = selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: undefined
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
setSelectedId(credentialId)
if (!isPreview) {
setStoreValue(credentialId)
// If credential changed, clear other sub-block fields for a clean state
if (previousId && previousId !== credentialId) {
const wfId = (activeWorkflowId as string) || ''
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
const blockValues = workflowValues[blockId] || {}
Object.keys(blockValues).forEach((key) => {
if (key !== subBlock.id) {
collaborativeSetSubblockValue(blockId, key, '')
}
})
}
}
setOpen(false)
}
@@ -263,15 +269,9 @@ export function CredentialSelector({
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
<span
className={
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
}
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
>
{selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: label}
{displayName || label}
</span>
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />

View File

@@ -12,6 +12,7 @@ import {
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -65,6 +66,9 @@ export function DocumentSelector({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
const isDisabled = finalDisabled
// Fetch documents for the selected knowledge base
const fetchDocuments = useCallback(async () => {
if (!knowledgeBaseId) {
@@ -103,6 +107,7 @@ export function DocumentSelector({
// Handle dropdown open/close - fetch documents when opening
const handleOpenChange = (isOpen: boolean) => {
if (isPreview) return
if (isDisabled) return
setOpen(isOpen)
@@ -124,13 +129,14 @@ export function DocumentSelector({
// Sync selected document with value prop
useEffect(() => {
if (isDisabled) return
if (value && documents.length > 0) {
const docInfo = documents.find((doc) => doc.id === value)
setSelectedDocument(docInfo || null)
} else {
setSelectedDocument(null)
}
}, [value, documents])
}, [value, documents, isDisabled])
// Reset documents when knowledge base changes
useEffect(() => {
@@ -141,10 +147,10 @@ export function DocumentSelector({
// Fetch documents when knowledge base is available
useEffect(() => {
if (knowledgeBaseId && !isPreview) {
if (knowledgeBaseId && !isPreview && !isDisabled) {
fetchDocuments()
}
}, [knowledgeBaseId, isPreview, fetchDocuments])
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
const formatDocumentName = (document: DocumentData) => {
return document.filename
@@ -166,9 +172,6 @@ export function DocumentSelector({
const label = subBlock.placeholder || 'Select document'
// Show disabled state if no knowledge base is selected
const isDisabled = disabled || isPreview || !knowledgeBaseId
return (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>

View File

@@ -46,6 +46,8 @@ interface ConfluenceFileSelectorProps {
showPreview?: boolean
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function ConfluenceFileSelector({
@@ -60,6 +62,8 @@ export function ConfluenceFileSelector({
showPreview = true,
onFileInfoChange,
credentialId,
workflowId,
isForeignCredential = false,
}: ConfluenceFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
@@ -71,6 +75,12 @@ export function ConfluenceFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
const [error, setError] = useState<string | null>(null)
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -156,6 +166,7 @@ export function ConfluenceFileSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -189,6 +200,18 @@ export function ConfluenceFileSelector({
if (data.file) {
setSelectedFile(data.file)
onFileInfoChange?.(data.file)
} else {
const fileInfo: ConfluenceFileInfo = {
id: data.id || pageId,
name: data.title || `Page ${pageId}`,
mimeType: 'confluence/page',
webViewLink: undefined,
modifiedTime: undefined,
spaceId: undefined,
url: undefined,
}
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
}
} catch (error) {
logger.error('Error fetching page info:', error)
@@ -197,13 +220,14 @@ export function ConfluenceFileSelector({
setIsLoading(false)
}
},
[selectedCredentialId, domain, onFileInfoChange]
[selectedCredentialId, domain, onFileInfoChange, workflowId]
)
// Fetch pages from Confluence
const fetchFiles = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
if (isForeignCredential) return
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
@@ -228,6 +252,7 @@ export function ConfluenceFileSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -267,6 +292,12 @@ export function ConfluenceFileSelector({
if (!response.ok) {
const errorData = await response.json()
if (response.status === 401 || response.status === 403) {
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
setFiles([])
setIsLoading(false)
return
}
logger.error('Confluence API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch pages')
}
@@ -294,7 +325,15 @@ export function ConfluenceFileSelector({
setIsLoading(false)
}
},
[selectedCredentialId, domain, selectedFileId, onFileInfoChange, fetchPageInfo]
[
selectedCredentialId,
domain,
selectedFileId,
onFileInfoChange,
fetchPageInfo,
workflowId,
isForeignCredential,
]
)
// Fetch credentials on initial mount
@@ -310,7 +349,7 @@ export function ConfluenceFileSelector({
setOpen(isOpen)
// Only fetch files when opening the dropdown and if we have valid credentials and domain
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
fetchFiles()
}
}
@@ -320,7 +359,15 @@ export function ConfluenceFileSelector({
if (value && selectedCredentialId && !selectedFile && domain && domain.includes('.')) {
fetchPageInfo(value)
}
}, [value, selectedCredentialId, selectedFile, domain, fetchPageInfo])
}, [
value,
selectedCredentialId,
selectedFile,
domain,
fetchPageInfo,
workflowId,
isForeignCredential,
])
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
@@ -329,6 +376,14 @@ export function ConfluenceFileSelector({
}
}, [value])
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
useEffect(() => {
if (!value) {
setSelectedFile(null)
onFileInfoChange?.(null)
}
}, [value, onFileInfoChange])
// Handle file selection
const handleSelectFile = (file: ConfluenceFileInfo) => {
setSelectedFileId(file.id)
@@ -363,7 +418,7 @@ export function ConfluenceFileSelector({
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain}
disabled={disabled || !domain || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
@@ -381,122 +436,126 @@ export function ConfluenceFileSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading pages...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Confluence account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No pages found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</CommandEmpty>
</div>
)}
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Pages
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{file.name}</span>
</div>
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<ConfluenceIcon className='h-4 w-4' />
<span>Connect Confluence account</span>
<Command>
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading pages...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Confluence account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No pages found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<ConfluenceIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Files list */}
{files.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Pages
</div>
{files.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleSelectFile(file)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<ConfluenceIcon className='h-4 w-4' />
<span className='truncate font-normal'>{file.name}</span>
</div>
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<ConfluenceIcon className='h-4 w-4' />
<span>Connect Confluence account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -1,18 +1,10 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
import useDrivePicker from 'react-google-drive-picker'
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -74,7 +66,6 @@ export function GoogleDrivePicker({
credentialId,
workflowId,
}: GoogleDrivePickerProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
@@ -237,8 +228,9 @@ export function GoogleDrivePicker({
])
// Fetch the access token for the selected credential
const fetchAccessToken = async (): Promise<string | null> => {
if (!selectedCredentialId) {
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
if (!effectiveCredentialId) {
logger.error('No credential ID selected for Google Drive Picker')
return null
}
@@ -246,7 +238,7 @@ export function GoogleDrivePicker({
setIsLoading(true)
try {
const url = new URL('/api/auth/oauth/token', window.location.origin)
url.searchParams.set('credentialId', selectedCredentialId)
url.searchParams.set('credentialId', effectiveCredentialId)
// include workflowId if available via global registry (server adds session owner otherwise)
const response = await fetch(url.toString())
@@ -265,10 +257,10 @@ export function GoogleDrivePicker({
}
// Handle opening the Google Drive Picker
const handleOpenPicker = async () => {
const handleOpenPicker = async (credentialOverrideId?: string) => {
try {
// First, get the access token for the selected credential
const accessToken = await fetchAccessToken()
const accessToken = await fetchAccessToken(credentialOverrideId)
if (!accessToken) {
logger.error('Failed to get access token for Google Drive Picker')
@@ -335,7 +327,6 @@ export function GoogleDrivePicker({
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
@@ -423,142 +414,60 @@ export function GoogleDrivePicker({
return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
</>
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='truncate text-muted-foreground'>Loading document...</span>
</>
) : (
<>
{getProviderIcon(provider)}
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
<Button
variant='outline'
role='combobox'
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || isLoading}
onClick={async () => {
// Decide which credential to use
let idToUse = selectedCredentialId
if (!idToUse && credentials.length === 1) {
idToUse = credentials[0].id
setSelectedCredentialId(idToUse)
}
if (!idToUse) {
// No credentials — prompt OAuth
handleAddCredential()
return
}
await handleOpenPicker(idToUse)
}}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
</>
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
<>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='truncate text-muted-foreground'>Loading document...</span>
</>
) : (
<>
{getProviderIcon(provider)}
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
<Command>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No documents available.</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Open picker button - only show if we have credentials */}
{credentials.length > 0 && selectedCredentialId && (
<CommandGroup>
<div className='p-2'>
<Button
className='w-full'
onClick={() => {
setOpen(false)
handleOpenPicker()
}}
>
Open Google Drive Picker
</Button>
</div>
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</Button>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -48,6 +48,7 @@ interface JiraIssueSelectorProps {
projectId?: string
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraIssueSelector({
@@ -63,6 +64,8 @@ export function JiraIssueSelector({
onIssueInfoChange,
projectId,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraIssueSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
@@ -168,6 +171,7 @@ export function JiraIssueSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -264,6 +268,7 @@ export function JiraIssueSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -377,6 +382,10 @@ export function JiraIssueSelector({
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch recent/default issues when opening the dropdown
@@ -451,7 +460,7 @@ export function JiraIssueSelector({
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain || !selectedCredentialId}
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedIssue ? (
@@ -469,118 +478,122 @@ export function JiraIssueSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading issues...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No issues found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</CommandEmpty>
</div>
)}
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Issues list */}
{issues.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Issues
</div>
{issues.map((issue) => (
<CommandItem
key={issue.id}
value={`issue-${issue.id}-${issue.name}`}
onSelect={() => handleSelectIssue(issue)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{issue.name}</span>
</div>
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
<Command>
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading issues...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No issues found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Issues list */}
{issues.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Issues
</div>
{issues.map((issue) => (
<CommandItem
key={issue.id}
value={`issue-${issue.id}-${issue.name}`}
onSelect={() => handleSelectIssue(issue)}
>
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{issue.name}</span>
</div>
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Issue preview */}

View File

@@ -55,6 +55,9 @@ interface MicrosoftFileSelectorProps {
showPreview?: boolean
onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void
planId?: string
workflowId?: string
credentialId?: string
isForeignCredential?: boolean
}
export function MicrosoftFileSelector({
@@ -68,10 +71,13 @@ export function MicrosoftFileSelector({
showPreview = true,
onFileInfoChange,
planId,
workflowId,
credentialId,
isForeignCredential = false,
}: MicrosoftFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<MicrosoftFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -112,23 +118,11 @@ export function MicrosoftFileSelector({
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
// If a credentialId prop is provided (collaborator case), do not auto-select
if (!credentialId && data.credentials.length > 0 && !selectedCredentialId) {
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) setSelectedCredentialId(defaultCred.id)
else if (data.credentials.length === 1) setSelectedCredentialId(data.credentials[0].id)
}
}
} catch (error) {
@@ -137,11 +131,18 @@ export function MicrosoftFileSelector({
setIsLoading(false)
setCredentialsLoaded(true)
}
}, [provider, getProviderId, selectedCredentialId])
}, [provider, getProviderId, selectedCredentialId, credentialId])
// Keep internal credential in sync with prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Fetch available files for the selected credential
const fetchAvailableFiles = useCallback(async () => {
if (!selectedCredentialId) return
if (!selectedCredentialId || isForeignCredential) return
setIsLoadingFiles(true)
try {
@@ -170,9 +171,13 @@ export function MicrosoftFileSelector({
const data = await response.json()
setAvailableFiles(data.files || [])
} else {
logger.error('Error fetching available files:', {
error: await response.text(),
})
const txt = await response.text()
if (response.status === 401 || response.status === 403) {
// Suppress noisy auth errors for collaborators; lists are intentionally gated
logger.info('Skipping list fetch (auth)', { status: response.status })
} else {
logger.warn('Non-OK list fetch', { status: response.status, txt })
}
setAvailableFiles([])
}
} catch (error) {
@@ -181,7 +186,7 @@ export function MicrosoftFileSelector({
} finally {
setIsLoadingFiles(false)
}
}, [selectedCredentialId, searchQuery, serviceId])
}, [selectedCredentialId, searchQuery, serviceId, isForeignCredential])
// Fetch a single file by ID when we have a selectedFileId but no metadata
const fetchFileById = useCallback(
@@ -190,49 +195,90 @@ export function MicrosoftFileSelector({
setIsLoadingSelectedFile(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
fileId: fileId,
})
// Route to correct endpoint based on service
let endpoint: string
if (serviceId === 'onedrive') {
endpoint = `/api/tools/onedrive/folder?${queryParams.toString()}`
} else if (serviceId === 'sharepoint') {
// Change from fileId to siteId for SharePoint
const sharepointParams = new URLSearchParams({
credentialId: selectedCredentialId,
siteId: fileId, // Use siteId instead of fileId
// Use owner-scoped token for OneDrive items (files/folders) and Excel
if (serviceId !== 'sharepoint') {
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
endpoint = `/api/tools/sharepoint/site?${sharepointParams.toString()}`
} else {
endpoint = `/api/auth/oauth/microsoft/file?${queryParams.toString()}`
if (!tokenRes.ok) {
const err = await tokenRes.text()
logger.error('Failed to get access token for Microsoft file fetch', { err })
return null
}
const { accessToken } = await tokenRes.json()
if (!accessToken) return null
const graphUrl =
`https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}?` +
new URLSearchParams({
$select:
'id,name,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy,file,folder',
}).toString()
const resp = await fetch(graphUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!resp.ok) {
const t = await resp.text()
// For 404/403, keep current selection; this often means the item moved or is shared differently.
if (resp.status !== 404 && resp.status !== 403) {
logger.warn('Graph error fetching file by ID', { status: resp.status, t })
}
return null
}
const file = await resp.json()
const fileInfo: MicrosoftFileInfo = {
id: file.id,
name: file.name,
mimeType:
file?.file?.mimeType || (file.folder ? 'application/vnd.ms-onedrive.folder' : ''),
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
}
setSelectedFile(fileInfo)
onFileInfoChange?.(fileInfo)
return fileInfo
}
const response = await fetch(endpoint)
if (response.ok) {
const data = await response.json()
if (data.file) {
setSelectedFile(data.file)
onFileInfoChange?.(data.file)
return data.file
}
} else {
const errorText = await response.text()
logger.error('Error fetching file by ID:', { error: errorText })
// If file not found or access denied, clear the selection
if (response.status === 404 || response.status === 403) {
logger.info('File not accessible, clearing selection')
setSelectedFileId('')
onChange('')
onFileInfoChange?.(null)
// SharePoint site: fetch via Graph sites endpoint for collaborator visibility
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
if (!tokenRes.ok) return null
const { accessToken: spToken } = await tokenRes.json()
if (!spToken) return null
const spResp = await fetch(
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(fileId)}?$select=id,displayName,webUrl`,
{
headers: { Authorization: `Bearer ${spToken}` },
}
)
if (!spResp.ok) return null
const site = await spResp.json()
const siteInfo: MicrosoftFileInfo = {
id: site.id,
name: site.displayName,
mimeType: 'sharepoint/site',
webViewLink: site.webUrl,
}
return null
setSelectedFile(siteInfo)
onFileInfoChange?.(siteInfo)
return siteInfo
} catch (error) {
logger.error('Error fetching file by ID:', { error })
return null
@@ -240,16 +286,22 @@ export function MicrosoftFileSelector({
setIsLoadingSelectedFile(false)
}
},
[selectedCredentialId, onFileInfoChange, serviceId]
[selectedCredentialId, onFileInfoChange, serviceId, workflowId, onChange]
)
// Fetch Microsoft Planner tasks when planId and credentials are available
const fetchPlannerTasks = useCallback(async () => {
if (!selectedCredentialId || !planId || serviceId !== 'microsoft-planner') {
if (
!selectedCredentialId ||
!planId ||
serviceId !== 'microsoft-planner' ||
isForeignCredential
) {
logger.info('Skipping task fetch - missing requirements:', {
selectedCredentialId: !!selectedCredentialId,
planId: !!planId,
serviceId,
isForeignCredential,
})
return
}
@@ -296,11 +348,17 @@ export function MicrosoftFileSelector({
setPlannerTasks(transformedTasks)
} else {
const errorText = await response.text()
logger.error('API response not ok:', {
status: response.status,
statusText: response.statusText,
errorText,
})
if (response.status === 401 || response.status === 403) {
logger.info('Planner list fetch unauthorized (expected for collaborator)', {
status: response.status,
})
} else {
logger.warn('Planner tasks fetch non-OK', {
status: response.status,
statusText: response.statusText,
errorText,
})
}
setPlannerTasks([])
}
} catch (error) {
@@ -309,7 +367,50 @@ export function MicrosoftFileSelector({
} finally {
setIsLoadingTasks(false)
}
}, [selectedCredentialId, planId, serviceId])
}, [selectedCredentialId, planId, serviceId, isForeignCredential])
// Fetch a single planner task by ID for collaborator preview
const fetchPlannerTaskById = useCallback(
async (taskId: string) => {
if (!selectedCredentialId || !taskId || serviceId !== 'microsoft-planner') return null
setIsLoadingTasks(true)
try {
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
if (!tokenRes.ok) return null
const { accessToken } = await tokenRes.json()
if (!accessToken) return null
const resp = await fetch(
`https://graph.microsoft.com/v1.0/planner/tasks/${encodeURIComponent(taskId)}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (!resp.ok) return null
const task = await resp.json()
const taskAsFileInfo: MicrosoftFileInfo = {
id: task.id,
name: task.title,
mimeType: 'planner/task',
webViewLink: `https://tasks.office.com/planner/task/${task.id}`,
createdTime: task.createdDateTime,
modifiedTime: task.createdDateTime,
}
setSelectedTask(task)
setSelectedFile(taskAsFileInfo)
onFileInfoChange?.(taskAsFileInfo)
return taskAsFileInfo
} catch {
return null
} finally {
setIsLoadingTasks(false)
}
},
[selectedCredentialId, workflowId, onFileInfoChange, serviceId]
)
// Fetch credentials on initial mount
useEffect(() => {
@@ -339,10 +440,15 @@ export function MicrosoftFileSelector({
// Fetch planner tasks when credentials and planId change
useEffect(() => {
if (serviceId === 'microsoft-planner' && selectedCredentialId && planId) {
if (
serviceId === 'microsoft-planner' &&
selectedCredentialId &&
planId &&
!isForeignCredential
) {
fetchPlannerTasks()
}
}, [selectedCredentialId, planId, serviceId, fetchPlannerTasks])
}, [selectedCredentialId, planId, serviceId, isForeignCredential, fetchPlannerTasks])
// Handle task selection for planner
const handleTaskSelect = (task: PlannerTask) => {
@@ -357,26 +463,23 @@ export function MicrosoftFileSelector({
modifiedTime: task.createdDateTime,
}
// Update internal state first to avoid race with list refetch
setSelectedFileId(taskId)
setSelectedFile(taskAsFileInfo)
setSelectedTask(task)
// Then propagate up
onChange(taskId, taskAsFileInfo)
onFileInfoChange?.(taskAsFileInfo)
setOpen(false)
setSearchQuery('')
}
// Keep internal selectedFileId in sync with the value prop
// Keep internal selectedFileId in sync with the value prop (do not clear selectedFile; we'll resolve new metadata below)
useEffect(() => {
if (value !== selectedFileId) {
const previousFileId = selectedFileId
setSelectedFileId(value)
// Only clear selected file info if we had a different file before (not initial load)
if (previousFileId && previousFileId !== value && selectedFile) {
setSelectedFile(null)
}
}
}, [value, selectedFileId, selectedFile])
}, [value, selectedFileId])
// Track previous credential ID to detect changes
const prevCredentialIdRef = useRef<string>('')
@@ -403,18 +506,19 @@ export function MicrosoftFileSelector({
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
// Fetch metadata when the external value doesn't match our current selectedFile
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedFile &&
!isLoadingSelectedFile &&
serviceId !== 'microsoft-planner' &&
serviceId !== 'sharepoint' &&
serviceId !== 'onedrive'
(!selectedFile || selectedFile.id !== value) &&
!isLoadingSelectedFile
) {
fetchFileById(value)
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
fetchFileById(value)
}
}
}, [
value,
@@ -423,9 +527,30 @@ export function MicrosoftFileSelector({
selectedFile,
isLoadingSelectedFile,
fetchFileById,
fetchPlannerTaskById,
serviceId,
])
// Resolve planner task selection for collaborators
useEffect(() => {
if (
value &&
selectedCredentialId &&
credentialsLoaded &&
!selectedTask &&
serviceId === 'microsoft-planner'
) {
void fetchPlannerTaskById(value)
}
}, [
value,
selectedCredentialId,
credentialsLoaded,
selectedTask,
serviceId,
fetchPlannerTaskById,
])
// Handle selecting a file from the available files
const handleFileSelect = (file: MicrosoftFileInfo) => {
setSelectedFileId(file.id)
@@ -602,6 +727,13 @@ export function MicrosoftFileSelector({
})
: availableFiles
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -620,10 +752,12 @@ export function MicrosoftFileSelector({
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || (serviceId === 'microsoft-planner' && !planId)}
disabled={
disabled || isForeignCredential || (serviceId === 'microsoft-planner' && !planId)
}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedFile ? (
{canShowPreview ? (
<>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
@@ -643,158 +777,162 @@ export function MicrosoftFileSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
)}
<Command>
<CommandInput placeholder={getSearchPlaceholder()} onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading || isLoadingFiles || isLoadingTasks ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : serviceId === 'microsoft-planner' && !planId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Plan ID required.</p>
<p className='text-muted-foreground text-xs'>
Please enter a Plan ID first to see tasks.
</p>
</div>
) : filteredTasks.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>{getEmptyStateText().title}</p>
<p className='text-muted-foreground text-xs'>
{getEmptyStateText().description}
</p>
</div>
) : null}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Available files/tasks - only show if we have credentials and items */}
{credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFileTypeTitleCase()}
</div>
{filteredTasks.map((item) => {
const isPlanner = serviceId === 'microsoft-planner'
const isPlannerTask = isPlanner && 'title' in item
const plannerTask = item as PlannerTask
const fileInfo = item as MicrosoftFileInfo
const displayName = isPlannerTask ? plannerTask.title : fileInfo.name
const dateField = isPlannerTask
? plannerTask.createdDateTime
: fileInfo.createdTime
return (
<CommandItem
key={item.id}
value={`file-${item.id}-${displayName}`}
onSelect={() =>
isPlannerTask
? handleTaskSelect(plannerTask)
: handleFileSelect(fileInfo)
}
>
<div className='flex items-center gap-2 overflow-hidden'>
{getFileIcon(
isPlannerTask
? {
...fileInfo,
id: plannerTask.id || '',
name: plannerTask.title,
mimeType: 'planner/task',
}
: fileInfo,
'sm'
)}
<div className='min-w-0 flex-1'>
<span className='truncate font-normal'>{displayName}</span>
{dateField && (
<div className='text-muted-foreground text-xs'>
Modified {new Date(dateField).toLocaleDateString()}
</div>
)}
</div>
</div>
{item.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
)
})}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
<Command>
<CommandInput placeholder={getSearchPlaceholder()} onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading || isLoadingFiles || isLoadingTasks ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : serviceId === 'microsoft-planner' && !planId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Plan ID required.</p>
<p className='text-muted-foreground text-xs'>
Please enter a Plan ID first to see tasks.
</p>
</div>
) : filteredTasks.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>{getEmptyStateText().title}</p>
<p className='text-muted-foreground text-xs'>
{getEmptyStateText().description}
</p>
</div>
) : null}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Available files/tasks - only show if we have credentials and items */}
{credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFileTypeTitleCase()}
</div>
{filteredTasks.map((item) => {
const isPlanner = serviceId === 'microsoft-planner'
const isPlannerTask = isPlanner && 'title' in item
const plannerTask = item as PlannerTask
const fileInfo = item as MicrosoftFileInfo
const displayName = isPlannerTask ? plannerTask.title : fileInfo.name
const dateField = isPlannerTask
? plannerTask.createdDateTime
: fileInfo.createdTime
return (
<CommandItem
key={item.id}
value={`file-${item.id}-${displayName}`}
onSelect={() =>
isPlannerTask
? handleTaskSelect(plannerTask)
: handleFileSelect(fileInfo)
}
>
<div className='flex items-center gap-2 overflow-hidden'>
{getFileIcon(
isPlannerTask
? {
...fileInfo,
id: plannerTask.id || '',
name: plannerTask.title,
mimeType: 'planner/task',
}
: fileInfo,
'sm'
)}
<div className='min-w-0 flex-1'>
<span className='truncate font-normal'>{displayName}</span>
{dateField && (
<div className='text-muted-foreground text-xs'>
Modified {new Date(dateField).toLocaleDateString()}
</div>
)}
</div>
</div>
{item.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
)
})}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -48,6 +48,7 @@ interface TeamsMessageSelectorProps {
selectionType?: 'team' | 'channel' | 'chat'
initialTeamId?: string
workflowId: string
isForeignCredential?: boolean
}
export function TeamsMessageSelector({
@@ -64,6 +65,7 @@ export function TeamsMessageSelector({
selectionType = 'team',
initialTeamId,
workflowId,
isForeignCredential = false,
}: TeamsMessageSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
@@ -324,6 +326,10 @@ export function TeamsMessageSelector({
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
if (disabled || isForeignCredential) {
setOpen(false)
return
}
setOpen(isOpen)
// Only fetch data when opening the dropdown
if (isOpen && selectedCredentialId) {
@@ -693,7 +699,7 @@ export function TeamsMessageSelector({
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled}
disabled={disabled || isForeignCredential}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedMessage ? (
@@ -715,120 +721,124 @@ export function TeamsMessageSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder={`Search ${selectionStage}s...`} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {selectionStage}s...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
{selectionStage === 'chat' && error.includes('teams') && (
<p className='mt-1 text-muted-foreground text-xs'>
There was an issue fetching chats. Please try again or connect a different
account.
</p>
)}
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Microsoft Teams account to{' '}
{selectionStage === 'chat'
? 'access your chats'
: selectionStage === 'channel'
? 'see your channels'
: 'continue'}
.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
<p className='text-muted-foreground text-xs'>
{selectionStage === 'team'
? 'Try a different account.'
: selectionStage === 'channel'
? selectedTeamId
? 'This team has no channels or you may not have access.'
: 'Please select a team first to see its channels.'
: 'Try a different account or check if you have any active chats.'}
</p>
</div>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</CommandEmpty>
</div>
)}
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => {
setSelectedCredentialId(cred.id)
setOpen(false)
}}
>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Display appropriate options based on selection stage */}
{renderSelectionOptions()}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span>Connect Microsoft Teams account</span>
<Command>
<CommandInput placeholder={`Search ${selectionStage}s...`} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {selectionStage}s...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
{selectionStage === 'chat' && error.includes('teams') && (
<p className='mt-1 text-muted-foreground text-xs'>
There was an issue fetching chats. Please try again or connect a
different account.
</p>
)}
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Microsoft Teams account to{' '}
{selectionStage === 'chat'
? 'access your chats'
: selectionStage === 'channel'
? 'see your channels'
: 'continue'}
.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
<p className='text-muted-foreground text-xs'>
{selectionStage === 'team'
? 'Try a different account.'
: selectionStage === 'channel'
? selectedTeamId
? 'This team has no channels or you may not have access.'
: 'Please select a team first to see its channels.'
: 'Try a different account or check if you have any active chats.'}
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => {
setSelectedCredentialId(cred.id)
setOpen(false)
}}
>
<div className='flex items-center gap-2'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Display appropriate options based on selection stage */}
{renderSelectionOptions()}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<MicrosoftTeamsIcon className='h-4 w-4' />
<span>Connect Microsoft Teams account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Selection preview */}

View File

@@ -1,32 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import {
type ConfluenceFileInfo,
ConfluenceFileSelector,
type DiscordChannelInfo,
DiscordChannelSelector,
type FileInfo,
type GoogleCalendarInfo,
GoogleCalendarSelector,
GoogleDrivePicker,
type JiraIssueInfo,
JiraIssueSelector,
type MicrosoftFileInfo,
MicrosoftFileSelector,
type TeamsMessageInfo,
TeamsMessageSelector,
WealthboxFileSelector,
type WealthboxItemInfo,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface FileSelectorInputProps {
blockId: string
@@ -34,6 +26,7 @@ interface FileSelectorInputProps {
disabled: boolean
isPreview?: boolean
previewValue?: any | null
previewContextValues?: Record<string, any>
}
export function FileSelectorInput({
@@ -42,57 +35,48 @@ export function FileSelectorInput({
disabled,
isPreview = false,
previewValue,
previewContextValues,
}: FileSelectorInputProps) {
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Central dependsOn gating for this selector instance
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Helper to coerce various preview value shapes into a string ID
const coerceToIdString = (val: unknown): string => {
if (!val) return ''
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
if (typeof val === 'object') {
const obj = val as Record<string, any>
return (obj.id ||
obj.fileId ||
obj.value ||
obj.documentId ||
obj.spreadsheetId ||
'') as string
}
return ''
}
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const [domainValue] = useSubBlockValue(blockId, 'domain')
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
const [planIdValue] = useSubBlockValue(blockId, 'planId')
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
const [operationValue] = useSubBlockValue(blockId, 'operation')
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
// Determine if the persisted credential belongs to the current viewer
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
useEffect(() => {
const cred = (getValue(blockId, 'credential') as string) || ''
if (!cred) {
setIsForeignCredential(false)
return
}
let aborted = false
;(async () => {
try {
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
if (aborted) return
if (!resp.ok) {
setIsForeignCredential(true)
return
}
const data = await resp.json()
// If credential not returned for this session user, it's foreign
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
} catch {
setIsForeignCredential(true)
}
})()
return () => {
aborted = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockId, getValue(blockId, 'credential')])
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || '',
(connectedCredential as string) || ''
)
// Get provider-specific values
const provider = subBlock.provider || 'google-drive'
@@ -107,117 +91,36 @@ export function FileSelectorInput({
const isWealthbox = provider === 'wealthbox'
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
const isMicrosoftPlanner = provider === 'microsoft-planner'
// For Confluence and Jira, we need the domain and credentials
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
const jiraCredential = isJira ? (getValue(blockId, 'credential') as string) || '' : ''
const domain =
isConfluence || isJira
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
: ''
const jiraCredential = isJira
? (isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
''
: ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
const botToken = isDiscord
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
: ''
const serverId = isDiscord
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
: ''
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Keep local selection in sync with store value (and preview)
useEffect(() => {
const effective = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof effective === 'string' && effective !== '') {
if (isJira) {
setSelectedIssueId(effective)
} else if (isDiscord) {
setSelectedChannelId(effective)
} else if (isMicrosoftTeams) {
setSelectedMessageId(effective)
} else if (isGoogleCalendar) {
setSelectedCalendarId(effective)
} else if (isWealthbox) {
setSelectedWealthboxItemId(effective)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(effective)
} else {
setSelectedFileId(effective)
}
} else {
// Clear when value becomes empty
if (isJira) {
setSelectedIssueId('')
} else if (isDiscord) {
setSelectedChannelId('')
} else if (isMicrosoftTeams) {
setSelectedMessageId('')
} else if (isGoogleCalendar) {
setSelectedCalendarId('')
} else if (isWealthbox) {
setSelectedWealthboxItemId('')
} else if (isMicrosoftSharePoint) {
setSelectedFileId('')
} else {
setSelectedFileId('')
}
}
}, [
isPreview,
previewValue,
storeValue,
isJira,
isDiscord,
isMicrosoftTeams,
isGoogleCalendar,
isWealthbox,
isMicrosoftSharePoint,
])
// Handle file selection
const handleFileChange = (fileId: string, info?: any) => {
setSelectedFileId(fileId)
setFileInfo(info || null)
setStoreValue(fileId)
}
// Handle issue selection
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
setSelectedIssueId(issueKey)
setIssueInfo(info || null)
setStoreValue(issueKey)
// Clear the fields when a new issue is selected
if (isJira) {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
// Also clear the manual issue key when cleared
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}
}
// Handle channel selection
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
setStoreValue(channelId)
}
// Handle calendar selection
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
setSelectedCalendarId(calendarId)
setCalendarInfo(info || null)
setStoreValue(calendarId)
}
// Handle Wealthbox item selection
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
setSelectedWealthboxItemId(itemId)
setWealthboxItemInfo(info || null)
setStoreValue(itemId)
}
// For Google Drive
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
// Render Google Calendar selector
if (isGoogleCalendar) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -230,25 +133,17 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedCalendarId(val)
setCalendarInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onCalendarInfoChange={setCalendarInfo}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Google Calendar credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -262,21 +157,18 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<DiscordChannelSelector
value={selectedChannelId}
onChange={handleChannelChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(channelId) => setStoreValue(channelId)}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={disabled || !botToken || !serverId}
disabled={finalDisabled}
showPreview={true}
/>
</div>
</TooltipTrigger>
{(!botToken || !serverId) && (
<TooltipContent side='top'>
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
@@ -284,7 +176,7 @@ export function FileSelectorInput({
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
@@ -296,9 +188,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
@@ -306,25 +196,21 @@ export function FileSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled || !domain}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side='top'>
<p>Please enter a Confluence domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
if (isJira) {
const credential = jiraCredential
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
@@ -336,161 +222,142 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedIssueId(val)
setIssueInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
onChange={(issueKey) => {
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
// Clear related fields when a new issue is selected
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
}
disabled={finalDisabled}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
credentialId={credential}
projectId={(getValue(blockId, 'projectId') as string) || ''}
projectId={(projectIdValue as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !credential ? (
<TooltipContent side='top'>
<p>Please select Jira credentials first</p>
</TooltipContent>
) : !(getValue(blockId, 'projectId') as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira project first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)
}
if (isMicrosoftExcel) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Excel credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft Word selector
// Microsoft Word selector
if (isMicrosoftWord) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-word'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Word document'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Word credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft OneDrive selector
// Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select OneDrive folder'}
disabled={disabled || !credential}
disabled={finalDisabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft SharePoint selector
// Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select SharePoint site'}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -504,27 +371,30 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Planner task selector
// Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (getValue(blockId, 'credential') as string) || ''
const planId = (getValue(blockId, 'planId') as string) || ''
const credential = (connectedCredential as string) || ''
const planId = (planIdValue as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(fileId) => setStoreValue(fileId)}
provider='microsoft-planner'
requiredScopes={subBlock.requiredScopes || []}
serviceId='microsoft-planner'
label={subBlock.placeholder || 'Select task'}
disabled={disabled || !credential || !planId}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -542,32 +412,22 @@ export function FileSelectorInput({
)
}
// Handle Microsoft Teams selector
// Microsoft Teams selector
if (isMicrosoftTeams) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
// Determine the selector type based on the subBlock ID
// Determine the selector type based on the subBlock ID / operation
let selectionType: 'team' | 'channel' | 'chat' = 'team'
if (subBlock.id === 'teamId') {
selectionType = 'team'
} else if (subBlock.id === 'channelId') {
selectionType = 'channel'
} else if (subBlock.id === 'chatId') {
selectionType = 'chat'
} else {
// Fallback: look at the operation to determine the selection type
const operation = (getValue(blockId, 'operation') as string) || ''
if (operation.includes('chat')) {
selectionType = 'chat'
} else if (operation.includes('channel')) {
selectionType = 'channel'
}
if (subBlock.id === 'teamId') selectionType = 'team'
else if (subBlock.id === 'channelId') selectionType = 'channel'
else if (subBlock.id === 'chatId') selectionType = 'chat'
else {
const operation = (operationValue as string) || ''
if (operation.includes('chat')) selectionType = 'chat'
else if (operation.includes('channel')) selectionType = 'channel'
}
// Get the teamId from workflow parameters for channel selector
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
const selectedTeamId = (teamIdValue as string) || ''
return (
<TooltipProvider>
@@ -580,10 +440,8 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(value, info) => {
setSelectedMessageId(value)
setMessageInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}
@@ -591,11 +449,11 @@ export function FileSelectorInput({
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
showPreview={true}
onMessageInfoChange={setMessageInfo}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -609,15 +467,11 @@ export function FileSelectorInput({
)
}
// Render Wealthbox selector
// Wealthbox selector
if (isWealthbox) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
// Only handle contacts now - both notes and tasks use short-input
const credential = (connectedCredential as string) || ''
if (subBlock.id === 'contactId') {
const itemType = 'contact'
return (
<TooltipProvider>
<Tooltip>
@@ -629,9 +483,7 @@ export function FileSelectorInput({
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedWealthboxItemId(val)
setWealthboxItemInfo(info || null)
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
@@ -640,7 +492,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setWealthboxItemInfo}
credentialId={credential}
itemType={itemType}
/>
@@ -655,35 +506,49 @@ export function FileSelectorInput({
</TooltipProvider>
)
}
// If it's noteId or taskId, we should not render the file selector since they now use short-input
// noteId or taskId now use short-input
return null
}
// Default to Google Drive picker
return (
<GoogleDrivePicker
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
onFileInfoChange={setFileInfo}
clientId={clientId}
apiKey={apiKey}
credentialId={(getValue(blockId, 'credential') as string) || ''}
workflowId={workflowIdFromUrl}
/>
)
{
const credential = ((isPreview && previewContextValues?.credential?.value) ||
(connectedCredential as string) ||
'') as string
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<GoogleDrivePicker
value={coerceToIdString(
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
)}
onChange={(val) => {
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
disabled={disabled || !credential}
serviceId={subBlock.serviceId}
mimeTypeFilter={subBlock.mimeType}
showPreview={true}
clientId={clientId}
apiKey={apiKey}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Google Drive credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
}

View File

@@ -5,9 +5,12 @@ import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface FolderSelectorInputProps {
blockId: string
@@ -25,12 +28,23 @@ export function FolderSelectorInput({
previewValue,
}: FolderSelectorInputProps) {
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'outlook',
(connectedCredential as string) || ''
)
// Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
// When gated/disabled, do not set defaults or write to store
if (finalDisabled) return
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
return
@@ -46,7 +60,15 @@ export function FolderSelectorInput({
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
}
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
}, [
blockId,
subBlock.id,
storeValue,
collaborativeSetSubblockValue,
isPreview,
previewValue,
finalDisabled,
])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
@@ -64,9 +86,12 @@ export function FolderSelectorInput({
provider={subBlock.provider || 'google-email'}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select folder'}
disabled={disabled}
disabled={finalDisabled}
serviceId={subBlock.serviceId}
onFolderInfoChange={setFolderInfo}
credentialId={(connectedCredential as string) || ''}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
)
}

View File

@@ -38,6 +38,9 @@ interface FolderSelectorProps {
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
isPreview?: boolean
previewValue?: any | null
credentialId?: string
workflowId?: string
isForeignCredential?: boolean
}
export function FolderSelector({
@@ -51,11 +54,16 @@ export function FolderSelector({
onFolderInfoChange,
isPreview = false,
previewValue,
credentialId,
workflowId,
isForeignCredential = false,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [folders, setFolders] = useState<FolderInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
credentialId || ''
)
const [selectedFolderId, setSelectedFolderId] = useState('')
const [selectedFolder, setSelectedFolder] = useState<FolderInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -72,6 +80,13 @@ export function FolderSelector({
}
}, [value, isPreview, previewValue])
// Keep internal credential in sync with prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
@@ -124,18 +139,43 @@ export function FolderSelector({
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
const fetchFolderById = useCallback(
async (folderId: string) => {
if (!selectedCredentialId || !folderId || provider === 'outlook') return null
if (!selectedCredentialId || !folderId) return null
setIsLoadingSelectedFolder(true)
try {
// Construct query parameters
if (provider === 'outlook') {
// Resolve Outlook folder name with owner-scoped token
const tokenRes = await fetch('/api/auth/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
})
if (!tokenRes.ok) return null
const { accessToken } = await tokenRes.json()
if (!accessToken) return null
const resp = await fetch(
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!resp.ok) return null
const folder = await resp.json()
const folderInfo: FolderInfo = {
id: folder.id,
name: folder.displayName,
type: 'folder',
messagesTotal: folder.totalItemCount,
messagesUnread: folder.unreadItemCount,
}
setSelectedFolder(folderInfo)
onFolderInfoChange?.(folderInfo)
return folderInfo
}
// Gmail label resolution
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
labelId: folderId,
})
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.label) {
@@ -156,7 +196,7 @@ export function FolderSelector({
setIsLoadingSelectedFolder(false)
}
},
[selectedCredentialId, onFolderInfoChange, provider]
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
)
// Fetch folders from Gmail or Outlook
@@ -178,6 +218,12 @@ export function FolderSelector({
// Determine the API endpoint based on provider
let apiEndpoint: string
if (provider === 'outlook') {
// Skip list fetch for collaborators; only show selected
if (isForeignCredential) {
setFolders([])
setIsLoading(false)
return
}
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
} else {
// Default to Gmail
@@ -206,9 +252,12 @@ export function FolderSelector({
}
}
} else {
logger.error('Error fetching folders:', {
error: await response.text(),
})
const text = await response.text()
if (response.status === 401 || response.status === 403) {
logger.info('Folder list fetch unauthorized (expected for collaborator)')
} else {
logger.warn('Error fetching folders', { status: response.status, text })
}
setFolders([])
}
} catch (error) {
@@ -218,36 +267,51 @@ export function FolderSelector({
setIsLoading(false)
}
},
[selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById, provider]
[
selectedCredentialId,
selectedFolderId,
onFolderInfoChange,
fetchFolderById,
provider,
isForeignCredential,
]
)
// Fetch credentials on initial mount
useEffect(() => {
if (disabled) return
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
}, [fetchCredentials, disabled])
// Fetch folders when credential is selected
useEffect(() => {
if (disabled) return
if (selectedCredentialId) {
fetchFolders()
}
}, [selectedCredentialId, fetchFolders])
}, [selectedCredentialId, fetchFolders, disabled])
// Keep internal selectedFolderId in sync with the value prop
useEffect(() => {
if (disabled) return
const currentValue = isPreview ? previewValue : value
if (currentValue !== selectedFolderId) {
setSelectedFolderId(currentValue || '')
}
}, [value, isPreview, previewValue])
}, [value, isPreview, previewValue, disabled])
// Fetch the selected folder metadata once credentials are ready (Gmail only)
// Fetch the selected folder metadata once credentials are ready or value changes
useEffect(() => {
const currentValue = isPreview ? previewValue : value
if (currentValue && selectedCredentialId && !selectedFolder && provider !== 'outlook') {
if (disabled) return
const currentValue = isPreview ? (previewValue as string) : (value as string)
if (
currentValue &&
selectedCredentialId &&
(!selectedFolder || selectedFolder.id !== currentValue)
) {
fetchFolderById(currentValue)
}
}, [
@@ -255,9 +319,9 @@ export function FolderSelector({
selectedCredentialId,
selectedFolder,
fetchFolderById,
provider,
isPreview,
previewValue,
disabled,
])
// Handle folder selection
@@ -317,7 +381,7 @@ export function FolderSelector({
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled}
disabled={disabled || isForeignCredential}
>
{selectedFolder ? (
<div className='flex items-center gap-2 overflow-hidden'>
@@ -333,114 +397,120 @@ export function FolderSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput
placeholder={`Search ${getFolderLabel()}...`}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {getFolderLabel()}...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName()} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</CommandEmpty>
</div>
)}
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Folders list */}
{folders.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
</div>
{folders.map((folder) => (
<CommandItem
key={folder.id}
value={`folder-${folder.id}-${folder.name}`}
onSelect={() => handleSelectFolder(folder)}
>
<div className='flex w-full items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{folder.name}</span>
{folder.id === selectedFolderId && <Check className='ml-auto h-4 w-4' />}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<span>Connect {getProviderName()} account</span>
<Command>
<CommandInput
placeholder={`Search ${getFolderLabel()}...`}
onValueChange={handleSearch}
/>
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading {getFolderLabel()}...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName()} account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Folders list */}
{folders.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
</div>
{folders.map((folder) => (
<CommandItem
key={folder.id}
value={`folder-${folder.id}-${folder.name}`}
onSelect={() => handleSelectFolder(folder)}
>
<div className='flex w-full items-center gap-2 overflow-hidden'>
{getFolderIcon('sm')}
<span className='truncate font-normal'>{folder.name}</span>
{folder.id === selectedFolderId && (
<Check className='ml-auto h-4 w-4' />
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<span>Connect {getProviderName()} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
</div>

View File

@@ -389,6 +389,8 @@ export function LongInput({
fontFamily: 'inherit',
lineHeight: 'inherit',
height: `${height}px`,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
/>
<div
@@ -397,7 +399,7 @@ export function LongInput({
style={{
fontFamily: 'inherit',
lineHeight: 'inherit',
width: textareaRef.current ? `${textareaRef.current.clientWidth}px` : '100%',
width: '100%',
height: `${height}px`,
overflow: 'hidden',
}}

View File

@@ -50,6 +50,7 @@ interface JiraProjectSelectorProps {
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
credentialId?: string
isForeignCredential?: boolean
workflowId?: string
}
export function JiraProjectSelector({
@@ -64,6 +65,8 @@ export function JiraProjectSelector({
showPreview = true,
onProjectInfoChange,
credentialId,
isForeignCredential = false,
workflowId,
}: JiraProjectSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
@@ -153,6 +156,7 @@ export function JiraProjectSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -238,6 +242,7 @@ export function JiraProjectSelector({
},
body: JSON.stringify({
credentialId: selectedCredentialId,
workflowId,
}),
})
@@ -334,16 +339,12 @@ export function JiraProjectSelector({
// Fetch the selected project metadata once credentials are ready or changed
useEffect(() => {
if (
value &&
selectedCredentialId &&
domain &&
domain.includes('.') &&
(!selectedProject || selectedProject.id !== value)
) {
fetchProjectInfo(value)
if (value && selectedCredentialId && domain && domain.includes('.')) {
if (!selectedProject || selectedProject.id !== value) {
fetchProjectInfo(value)
}
}
}, [value, selectedCredentialId, selectedProject, domain, fetchProjectInfo])
}, [value, selectedCredentialId, domain, fetchProjectInfo, selectedProject])
// Keep internal selectedProjectId in sync with the value prop
useEffect(() => {
@@ -352,6 +353,14 @@ export function JiraProjectSelector({
}
}, [value])
// Clear local preview when value is cleared remotely or via collaborator
useEffect(() => {
if (!value) {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
}, [value, onProjectInfoChange])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
@@ -386,6 +395,8 @@ export function JiraProjectSelector({
onProjectInfoChange?.(null)
}
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
return (
<>
<div className='space-y-2'>
@@ -396,9 +407,9 @@ export function JiraProjectSelector({
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId}
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{selectedProject ? (
{canShowPreview ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
@@ -417,130 +428,135 @@ export function JiraProjectSelector({
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
{!isForeignCredential && (
<PopoverContent className='w-[300px] p-0' align='start'>
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</CommandEmpty>
</div>
)}
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Projects list */}
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
>
<div className='flex items-center gap-2 overflow-hidden'>
{project.avatarUrl ? (
<img
src={project.avatarUrl}
alt={project.name}
className='h-4 w-4 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === selectedProjectId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
<Command>
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading projects...</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a Jira account to continue.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No projects found.</p>
<p className='text-muted-foreground text-xs'>
Try a different search or account.
</p>
</div>
)}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Projects list */}
{projects.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Projects
</div>
{projects.map((project) => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => handleSelectProject(project)}
>
<div className='flex items-center gap-2 overflow-hidden'>
{project.avatarUrl ? (
<img
src={project.avatarUrl}
alt={project.name}
className='h-4 w-4 rounded'
/>
) : (
<JiraIcon className='h-4 w-4' />
)}
<span className='truncate font-normal'>{project.name}</span>
</div>
{project.id === selectedProjectId && (
<Check className='ml-auto h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
<JiraIcon className='h-4 w-4' />
<span>Connect Jira account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
{/* Project preview */}
{showPreview && selectedProject && (
{canShowPreview && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button

View File

@@ -18,6 +18,8 @@ import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -43,20 +45,18 @@ export function ProjectSelectorInput({
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Local setters for related Jira fields to ensure immediate UI clearing
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
blockId,
'manualIssueKey'
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'jira',
(connectedCredential as string) || ''
)
// Reactive dependencies from store for Linear
const [linearCredential] = useSubBlockValue(blockId, 'credential')
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
// Get provider-specific values
const provider = subBlock.provider || 'jira'
@@ -70,32 +70,6 @@ export function ProjectSelectorInput({
const botToken = ''
// Verify Jira credential belongs to current user; if not, treat as absent
useEffect(() => {
const cred = (jiraCredential as string) || ''
if (!cred) {
setIsForeignCredential(false)
return
}
let aborted = false
;(async () => {
try {
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
if (aborted) return
if (!resp.ok) {
setIsForeignCredential(true)
return
}
const data = await resp.json()
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
} catch {
setIsForeignCredential(true)
}
})()
return () => {
aborted = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockId, jiraCredential])
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
@@ -117,27 +91,6 @@ export function ProjectSelectorInput({
setProjectInfo(info || null)
setStoreValue(projectId)
// Clear the issue-related fields when a new project is selected
if (provider === 'jira') {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
// Clear both the basic and advanced issue key fields to ensure UI resets
collaborativeSetSubblockValue(blockId, 'issueKey', '')
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
// Also clear locally for immediate UI feedback on this client
setIssueKeyValue('')
setManualIssueKeyValue('')
} else if (provider === 'discord') {
collaborativeSetSubblockValue(blockId, 'channelId', '')
} else if (provider === 'linear') {
if (subBlock.id === 'teamId') {
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
collaborativeSetSubblockValue(blockId, 'projectId', '')
} else if (subBlock.id === 'projectId') {
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
}
}
onProjectSelect?.(projectId)
}
@@ -185,7 +138,7 @@ export function ProjectSelectorInput({
}}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={disabled || !(linearCredential as string)}
disabled={finalDisabled}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
@@ -193,7 +146,7 @@ export function ProjectSelectorInput({
(() => {
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = disabled || !credential || !teamId
const isDisabled = finalDisabled
return (
<LinearProjectSelector
value={selectedProjectId}
@@ -235,23 +188,15 @@ export function ProjectSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled || !domain || !(jiraCredential as string)}
disabled={finalDisabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
) : !(jiraCredential as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira account first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)

View File

@@ -55,6 +55,7 @@ interface ToolInputProps {
isPreview?: boolean
previewValue?: any
disabled?: boolean
allowExpandInPreview?: boolean
}
interface StoredTool {
@@ -105,6 +106,7 @@ function FileSelectorSyncWrapper({
onChange,
uiComponent,
disabled,
previewContextValues,
}: {
blockId: string
paramId: string
@@ -112,6 +114,7 @@ function FileSelectorSyncWrapper({
onChange: (value: string) => void
uiComponent: any
disabled: boolean
previewContextValues?: Record<string, any>
}) {
return (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
@@ -128,6 +131,7 @@ function FileSelectorSyncWrapper({
placeholder: uiComponent.placeholder,
}}
disabled={disabled}
previewContextValues={previewContextValues}
/>
</GenericSyncWrapper>
)
@@ -398,6 +402,7 @@ export function ToolInput({
isPreview = false,
previewValue,
disabled = false,
allowExpandInPreview,
}: ToolInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
@@ -775,8 +780,19 @@ export function ToolInput({
)
}
// Local expansion overrides for preview/diff mode
const [previewExpanded, setPreviewExpanded] = useState<Record<number, boolean>>({})
const toggleToolExpansion = (toolIndex: number) => {
if (isPreview || disabled) return
if ((isPreview && !allowExpandInPreview) || disabled) return
if (isPreview) {
setPreviewExpanded((prev) => ({
...prev,
[toolIndex]: !(prev[toolIndex] ?? !!selectedTools[toolIndex]?.isExpanded),
}))
return
}
setStoreValue(
selectedTools.map((tool, index) =>
@@ -929,7 +945,8 @@ export function ToolInput({
param: ToolParameterConfig,
value: string,
onChange: (value: string) => void,
toolIndex?: number
toolIndex?: number,
currentToolParams?: Record<string, string>
) => {
// Create unique blockId for tool parameters to avoid conflicts with main block
const uniqueBlockId = toolIndex !== undefined ? `${blockId}-tool-${toolIndex}` : blockId
@@ -1076,6 +1093,7 @@ export function ToolInput({
onChange={onChange}
uiComponent={uiComponent}
disabled={disabled}
previewContextValues={currentToolParams as any}
/>
)
@@ -1363,6 +1381,9 @@ export function ToolInput({
const oauthConfig = !isCustomTool ? getToolOAuthConfig(currentToolId) : null
// Tools are always expandable so users can access the interface
const isExpandedForDisplay = isPreview
? (previewExpanded[toolIndex] ?? !!tool.isExpanded)
: !!tool.isExpanded
return (
<div
@@ -1458,29 +1479,27 @@ export function ToolInput({
</span>
<span
className={`font-medium text-xs ${
tool.usageControl === 'force'
? 'block text-muted-foreground'
: 'hidden'
tool.usageControl === 'force' ? 'block' : 'hidden'
}`}
>
Force
</span>
<span
className={`font-medium text-xs ${
tool.usageControl === 'none'
? 'block text-muted-foreground'
: 'hidden'
tool.usageControl === 'none' ? 'block' : 'hidden'
}`}
>
Deny
None
</span>
</Toggle>
</TooltipTrigger>
<TooltipContent side='bottom' className='max-w-[240px] p-2'>
<p className='text-xs'>
<TooltipContent className='max-w-[280px] p-2' side='top'>
<p className='text-muted-foreground text-xs'>
Control how the model uses this tool in its response.
{tool.usageControl === 'auto' && (
<span>
<span className='font-medium'>Auto:</span> Let the agent decide
{' '}
<span className='font-medium'>Auto:</span> Let the model decide
when to use the tool
</span>
)}
@@ -1511,7 +1530,7 @@ export function ToolInput({
</div>
</div>
{!isCustomTool && tool.isExpanded && (
{!isCustomTool && isExpandedForDisplay && (
<div className='space-y-3 overflow-visible p-3'>
{/* Operation dropdown for tools with multiple operations */}
{(() => {
@@ -1660,7 +1679,8 @@ export function ToolInput({
param,
tool.params[param.id] || '',
(value) => handleParamChange(toolIndex, param.id, value),
toolIndex
toolIndex,
tool.params
)
) : (
<ShortInput

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -46,18 +46,51 @@ export function TriggerModal({
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
const [isSaving, setIsSaving] = useState(false)
// Track if config has changed from initial values
// Snapshot initial values at open for stable dirty-checking across collaborators
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
const initialCredentialRef = useRef<string | null>(null)
// Capture initial credential on first detect
useEffect(() => {
if (initialCredentialRef.current !== null) return
const subBlockStore = useSubBlockStore.getState()
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
initialCredentialRef.current = cred
}, [blockId])
// Track if config has changed from initial snapshot
const hasConfigChanged = useMemo(() => {
return JSON.stringify(config) !== JSON.stringify(initialConfig)
}, [config, initialConfig])
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
}, [config])
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
let hasCredentialChanged = false
const [isDeleting, setIsDeleting] = useState(false)
const [webhookUrl, setWebhookUrl] = useState('')
const [generatedPath, setGeneratedPath] = useState('')
const [hasCredentials, setHasCredentials] = useState(false)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
const [dynamicOptions, setDynamicOptions] = useState<
Record<string, Array<{ id: string; name: string }>>
>({})
const lastCredentialIdRef = useRef<string | null>(null)
// Reset provider-dependent config fields when credentials change
const resetFieldsForCredentialChange = () => {
setConfig((prev) => {
const next = { ...prev }
if (triggerDef.provider === 'gmail') {
if (Array.isArray(next.labelIds)) next.labelIds = []
} else if (triggerDef.provider === 'outlook') {
if (Array.isArray(next.folderIds)) next.folderIds = []
} else if (triggerDef.provider === 'airtable') {
if (typeof next.baseId === 'string') next.baseId = ''
if (typeof next.tableId === 'string') next.tableId = ''
}
return next
})
}
// Initialize config with default values from trigger definition
useEffect(() => {
@@ -79,35 +112,71 @@ export function TriggerModal({
}
}, [triggerDef.configFields, initialConfig])
// Monitor credential selection
// Monitor credential selection across collaborators; clear options on change/clear
useEffect(() => {
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
// Check if credentials are selected by monitoring the sub-block store
const checkCredentials = () => {
const subBlockStore = useSubBlockStore.getState()
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
const hasCredential = Boolean(credentialValue)
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
| string
| null
const currentCredentialId = credentialValue || null
const hasCredential = Boolean(currentCredentialId)
setHasCredentials(hasCredential)
// If credential changed and it's a Gmail trigger, load labels
if (hasCredential && credentialValue !== selectedCredentialId) {
setSelectedCredentialId(credentialValue)
// If credential was cleared by another user, reset local state and dynamic options
if (!hasCredential) {
if (selectedCredentialId !== null) {
setSelectedCredentialId(null)
}
// Clear provider-specific dynamic options
setDynamicOptions({})
// Per requirements: only clear dependent selections on actual credential CHANGE,
// not when it becomes empty. So we do NOT reset fields here.
lastCredentialIdRef.current = null
return
}
// If credential changed, clear options immediately and load for new cred
const previousCredentialId = lastCredentialIdRef.current
// First detection (prev null → current non-null): do not clear selections
if (previousCredentialId === null) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
if (typeof currentCredentialId === 'string') {
if (triggerDef.provider === 'gmail') {
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
void loadOutlookFolders(currentCredentialId)
}
}
return
}
// Real change (prev non-null → different non-null): clear dependent selections
if (
typeof currentCredentialId === 'string' &&
currentCredentialId !== previousCredentialId
) {
setSelectedCredentialId(currentCredentialId)
lastCredentialIdRef.current = currentCredentialId
// Clear stale options before loading new ones
setDynamicOptions({})
// Clear any selected values that depend on the credential
resetFieldsForCredentialChange()
if (triggerDef.provider === 'gmail') {
loadGmailLabels(credentialValue)
void loadGmailLabels(currentCredentialId)
} else if (triggerDef.provider === 'outlook') {
loadOutlookFolders(credentialValue)
void loadOutlookFolders(currentCredentialId)
}
}
}
checkCredentials()
// Set up a subscription to monitor changes
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
return unsubscribe
}
// If credentials aren't required, set to true
setHasCredentials(true)
}, [
blockId,
@@ -176,18 +245,28 @@ export function TriggerModal({
let finalPath = triggerPath
// If no path exists, generate one automatically
if (!finalPath) {
// If no path exists and we haven't generated one yet, generate one
if (!finalPath && !generatedPath) {
// Use UUID format consistent with other webhooks
finalPath = crypto.randomUUID()
setGeneratedPath(finalPath)
const newPath = crypto.randomUUID()
setGeneratedPath(newPath)
finalPath = newPath
} else if (generatedPath && !triggerPath) {
// Use the already generated path
finalPath = generatedPath
}
if (finalPath) {
const baseUrl = window.location.origin
setWebhookUrl(`${baseUrl}/api/webhooks/trigger/${finalPath}`)
}
}, [triggerPath, triggerDef.provider, triggerDef.requiresCredentials, triggerDef.webhook])
}, [
triggerPath,
generatedPath,
triggerDef.provider,
triggerDef.requiresCredentials,
triggerDef.webhook,
])
const handleConfigChange = (fieldId: string, value: any) => {
setConfig((prev) => ({
@@ -357,10 +436,16 @@ export function TriggerModal({
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isConfigValid() || !hasConfigChanged}
disabled={
isSaving ||
!isConfigValid() ||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
}
className={cn(
'h-10',
isConfigValid() && hasConfigChanged ? 'bg-primary hover:bg-primary/90' : '',
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: '',
isSaving &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20'
)}

View File

@@ -172,6 +172,11 @@ export function TriggerConfig({
// Map trigger ID to webhook provider name
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
// Include selected credential from the modal (if any)
const selectedCredentialId =
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
null
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
// Gmail polling service requires a webhook database entry to find the configuration
@@ -185,7 +190,10 @@ export function TriggerConfig({
blockId,
path: '', // Empty path - API will generate dummy path for Gmail
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})
@@ -225,7 +233,10 @@ export function TriggerConfig({
blockId,
path,
provider: webhookProvider,
providerConfig: config,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
}),
})

View File

@@ -0,0 +1,54 @@
'use client'
import { useMemo } from 'react'
import type { SubBlockConfig } from '@/blocks/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Centralized dependsOn gating for sub-block components.
* - Computes dependency values from the active workflow/block
* - Returns a stable disabled flag to pass to inputs and to guard effects
*/
export function useDependsOnGate(
blockId: string,
subBlock: SubBlockConfig,
opts?: { disabled?: boolean; isPreview?: boolean }
) {
const disabledProp = opts?.disabled ?? false
const isPreview = opts?.isPreview ?? false
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
// Use only explicit dependsOn from block config. No inference.
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
const dependencyValues = useSubBlockStore((state) => {
if (dependsOn.length === 0) return [] as any[]
if (!activeWorkflowId) return dependsOn.map(() => null)
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = (workflowValues as any)[blockId] || {}
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
}) as any[]
const depsSatisfied = useMemo(() => {
if (dependsOn.length === 0) return true
return dependencyValues.every((v) =>
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
)
}, [dependencyValues, dependsOn])
// Block everything except the credential field itself until dependencies are set
const blocked =
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
const finalDisabled = disabledProp || isPreview || blocked
return {
dependsOn,
dependencyValues,
depsSatisfied,
blocked,
finalDisabled,
}
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useMemo, useState } from 'react'
export function useForeignCredential(
provider: string | undefined,
credentialId: string | undefined
) {
const [isForeign, setIsForeign] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
useEffect(() => {
let cancelled = false
async function check() {
setLoading(true)
setError(null)
try {
if (!normalizedCredentialId) {
if (!cancelled) setIsForeign(false)
return
}
const res = await fetch(
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
)
if (!res.ok) {
if (!cancelled) setIsForeign(true)
return
}
const data = await res.json()
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
if (!cancelled) setIsForeign(!isOwn)
} catch (e) {
if (!cancelled) {
setIsForeign(true)
setError((e as Error).message)
}
} finally {
if (!cancelled) setLoading(false)
}
}
void check()
return () => {
cancelled = true
}
}, [normalizedProvider, normalizedCredentialId])
return { isForeignCredential: isForeign, loading, error }
}

View File

@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
// This ensures our selector recomputes when the active workflow changes.
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store - always call this hook unconditionally
// Get value from subblock store, keyed by active workflow id
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
const storeValue = useSubBlockStore(
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]
)
)
// Check if we're in diff mode and get diff value if available
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
[activeWorkflowId || '']: {
...state.workflowValues[activeWorkflowId || ''],
[blockId]: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
blockId
],
...state.workflowValues[activeWorkflowId || '']?.[blockId],
[subBlockId]: newValue,
},
},
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isShowingDiff,
activeWorkflowId,
]
)

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { AlertTriangle, Info } from 'lucide-react'
import { Label, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
import { cn } from '@/lib/utils'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import {
ChannelSelectorInput,
CheckboxList,
@@ -43,7 +44,8 @@ interface SubBlockProps {
isPreview?: boolean
subBlockValues?: Record<string, any>
disabled?: boolean
fieldDiffStatus?: 'changed' | 'unchanged'
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean
}
export function SubBlock({
@@ -54,6 +56,7 @@ export function SubBlock({
subBlockValues,
disabled = false,
fieldDiffStatus,
allowExpandInPreview,
}: SubBlockProps) {
const [isValidJson, setIsValidJson] = useState(true)
@@ -211,7 +214,8 @@ export function SubBlock({
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
disabled={allowExpandInPreview ? false : isDisabled}
allowExpandInPreview={allowExpandInPreview}
/>
)
case 'checkbox-list':
@@ -355,6 +359,7 @@ export function SubBlock({
disabled={isDisabled}
isPreview={isPreview}
previewValue={previewValue}
previewContextValues={subBlockValues}
/>
)
case 'project-selector':

View File

@@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, validateName } from '@/lib/utils'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -76,12 +77,16 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
: (currentBlock?.enabled ?? true)
// Get diff status from the block itself (set by diff engine)
const diffStatus =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined
const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.is_diff
: undefined
// Get field-level diff information
const fieldDiff =
currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diffs : undefined
// Get field-level diff information for this specific block from the diff store
const diffAnalysisForFields = useWorkflowDiffStore((state) => state.diffAnalysis)
const fieldDiff = currentWorkflow.isDiffMode
? diffAnalysisForFields?.field_diffs?.[id]
: undefined
// Debug: Log diff status for this block
useEffect(() => {
@@ -151,6 +156,24 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
const blockAdvancedMode = useWorkflowStore((state) => state.blocks[id]?.advancedMode ?? false)
const blockTriggerMode = useWorkflowStore((state) => state.blocks[id]?.triggerMode ?? false)
// Local UI state for diff mode controls
const [diffIsWide, setDiffIsWide] = useState<boolean>(isWide)
const [diffAdvancedMode, setDiffAdvancedMode] = useState<boolean>(blockAdvancedMode)
const [diffTriggerMode, setDiffTriggerMode] = useState<boolean>(blockTriggerMode)
useEffect(() => {
if (currentWorkflow.isDiffMode) {
setDiffIsWide(isWide)
setDiffAdvancedMode(blockAdvancedMode)
setDiffTriggerMode(blockTriggerMode)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkflow.isDiffMode, id])
const displayIsWide = currentWorkflow.isDiffMode ? diffIsWide : isWide
const displayAdvancedMode = currentWorkflow.isDiffMode ? diffAdvancedMode : blockAdvancedMode
const displayTriggerMode = currentWorkflow.isDiffMode ? diffTriggerMode : blockTriggerMode
// Collaborative workflow actions
const {
collaborativeUpdateBlockName,
@@ -414,6 +437,8 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
const isAdvancedMode = useWorkflowStore.getState().blocks[blockId]?.advancedMode ?? false
const isTriggerMode = useWorkflowStore.getState().blocks[blockId]?.triggerMode ?? false
const effectiveAdvanced = currentWorkflow.isDiffMode ? displayAdvancedMode : isAdvancedMode
const effectiveTrigger = currentWorkflow.isDiffMode ? displayTriggerMode : isTriggerMode
// Filter visible blocks and those that meet their conditions
const visibleSubBlocks = subBlocks.filter((block) => {
@@ -423,18 +448,18 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
if (block.type === ('trigger-config' as SubBlockType)) {
// Show trigger-config blocks when in trigger mode OR for pure trigger blocks
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'
return isTriggerMode || isPureTriggerBlock
return effectiveTrigger || isPureTriggerBlock
}
if (isTriggerMode && block.type !== ('trigger-config' as SubBlockType)) {
if (effectiveTrigger && block.type !== ('trigger-config' as SubBlockType)) {
// In trigger mode, hide all non-trigger-config blocks
return false
}
// Filter by mode if specified
if (block.mode) {
if (block.mode === 'basic' && isAdvancedMode) return false
if (block.mode === 'advanced' && !isAdvancedMode) return false
if (block.mode === 'basic' && effectiveAdvanced) return false
if (block.mode === 'advanced' && !effectiveAdvanced) return false
}
// If there's no condition, the block should be shown
@@ -562,7 +587,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
className={cn(
'relative cursor-default select-none shadow-md',
'transition-block-bg transition-ring',
isWide ? 'w-[480px]' : 'w-[320px]',
displayIsWide ? 'w-[480px]' : 'w-[320px]',
!isEnabled && 'shadow-sm',
isActive && 'animate-pulse-ring ring-2 ring-blue-500',
isPending && 'ring-2 ring-amber-500',
@@ -658,7 +683,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
onClick={handleNameClick}
title={name}
style={{
maxWidth: !isEnabled ? (isWide ? '200px' : '140px') : '180px',
maxWidth: !isEnabled ? (displayIsWide ? '200px' : '140px') : '180px',
}}
>
{name}
@@ -758,26 +783,30 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
variant='ghost'
size='sm'
onClick={() => {
if (userPermissions.canEdit) {
if (currentWorkflow.isDiffMode) {
setDiffAdvancedMode((prev) => !prev)
} else if (userPermissions.canEdit) {
collaborativeToggleBlockAdvancedMode(id)
}
}}
className={cn(
'h-7 p-1 text-gray-500',
blockAdvancedMode && 'text-[var(--brand-primary-hex)]',
!userPermissions.canEdit && 'cursor-not-allowed opacity-50'
displayAdvancedMode && 'text-[var(--brand-primary-hex)]',
!userPermissions.canEdit &&
!currentWorkflow.isDiffMode &&
'cursor-not-allowed opacity-50'
)}
disabled={!userPermissions.canEdit}
disabled={!userPermissions.canEdit && !currentWorkflow.isDiffMode}
>
<Code className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top'>
{!userPermissions.canEdit
{!userPermissions.canEdit && !currentWorkflow.isDiffMode
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Read-only mode'
: blockAdvancedMode
: displayAdvancedMode
? 'Switch to Basic Mode'
: 'Switch to Advanced Mode'}
</TooltipContent>
@@ -791,27 +820,31 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
variant='ghost'
size='sm'
onClick={() => {
if (userPermissions.canEdit) {
if (currentWorkflow.isDiffMode) {
setDiffTriggerMode((prev) => !prev)
} else if (userPermissions.canEdit) {
// Toggle trigger mode using collaborative function
collaborativeToggleBlockTriggerMode(id)
}
}}
className={cn(
'h-7 p-1 text-gray-500',
blockTriggerMode && 'text-[#22C55E]',
!userPermissions.canEdit && 'cursor-not-allowed opacity-50'
displayTriggerMode && 'text-[#22C55E]',
!userPermissions.canEdit &&
!currentWorkflow.isDiffMode &&
'cursor-not-allowed opacity-50'
)}
disabled={!userPermissions.canEdit}
disabled={!userPermissions.canEdit && !currentWorkflow.isDiffMode}
>
<Zap className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top'>
{!userPermissions.canEdit
{!userPermissions.canEdit && !currentWorkflow.isDiffMode
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Read-only mode'
: blockTriggerMode
: displayTriggerMode
? 'Switch to Action Mode'
: 'Switch to Trigger Mode'}
</TooltipContent>
@@ -892,17 +925,21 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
variant='ghost'
size='sm'
onClick={() => {
if (userPermissions.canEdit) {
if (currentWorkflow.isDiffMode) {
setDiffIsWide((prev) => !prev)
} else if (userPermissions.canEdit) {
collaborativeToggleBlockWide(id)
}
}}
className={cn(
'h-7 p-1 text-gray-500',
!userPermissions.canEdit && 'cursor-not-allowed opacity-50'
!userPermissions.canEdit &&
!currentWorkflow.isDiffMode &&
'cursor-not-allowed opacity-50'
)}
disabled={!userPermissions.canEdit}
disabled={!userPermissions.canEdit && !currentWorkflow.isDiffMode}
>
{isWide ? (
{displayIsWide ? (
<RectangleHorizontal className='h-5 w-5' />
) : (
<RectangleVertical className='h-5 w-5' />
@@ -910,11 +947,11 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
</Button>
</TooltipTrigger>
<TooltipContent side='top'>
{!userPermissions.canEdit
{!userPermissions.canEdit && !currentWorkflow.isDiffMode
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Read-only mode'
: isWide
: displayIsWide
? 'Narrow Block'
: 'Expand Block'}
</TooltipContent>
@@ -942,8 +979,13 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
blockId={id}
config={subBlock}
isConnecting={isConnecting}
isPreview={data.isPreview}
subBlockValues={data.subBlockValues}
isPreview={data.isPreview || currentWorkflow.isDiffMode}
subBlockValues={
data.subBlockValues ||
(currentWorkflow.isDiffMode && currentBlock
? (currentBlock as any).subBlocks
: undefined)
}
disabled={!userPermissions.canEdit}
fieldDiffStatus={
fieldDiff?.changed_fields?.includes(subBlock.id)
@@ -952,6 +994,7 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
? 'unchanged'
: undefined
}
allowExpandInPreview={currentWorkflow.isDiffMode}
/>
</div>
))}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useCurrentWorkflow } from '../../hooks'
@@ -114,7 +115,7 @@ export const WorkflowEdge = ({
}, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff])
// Determine edge diff status
let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | null = null
let edgeDiffStatus: EdgeDiffStatus = null
// Only attempt to determine diff status if all required data is available
if (diffAnalysis?.edge_diff && edgeIdentifier && isDiffReady) {

View File

@@ -807,7 +807,7 @@ export function useWorkflowExecution() {
// Continue execution until there are no more pending blocks
let iterationCount = 0
const maxIterations = 100 // Safety to prevent infinite loops
const maxIterations = 500 // Safety to prevent infinite loops
while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) {
logger.info(

View File

@@ -14,7 +14,8 @@ const isContainerType = (blockType: string): boolean => {
blockType === 'loop' ||
blockType === 'parallel' ||
blockType === 'loopNode' ||
blockType === 'parallelNode'
blockType === 'parallelNode' ||
blockType === 'subflowNode'
)
}
@@ -325,7 +326,10 @@ export const updateNodeParent = (
} else if (currentParentId) {
const absolutePosition = getNodeAbsolutePosition(nodeId, getNodes)
// First set the absolute position so the node visually stays in place
updateBlockPosition(nodeId, absolutePosition)
// Then clear the parent relationship in the store (empty string removes parentId/extent)
updateParentId(nodeId, '', 'parent')
}
resizeLoopNodes()

View File

@@ -18,8 +18,7 @@ import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/compone
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -48,8 +47,7 @@ const logger = createLogger('Workflow')
// Define custom node and edge types
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
loopNode: LoopNodeComponent,
parallelNode: ParallelNodeComponent,
subflowNode: SubflowNodeComponent,
}
const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge }
@@ -343,6 +341,35 @@ const WorkflowContent = React.memo(() => {
}
}, [debouncedAutoLayout])
// Listen for explicit remove-from-subflow actions from ActionBar
useEffect(() => {
const handleRemoveFromSubflow = (event: Event) => {
const customEvent = event as CustomEvent<{ blockId: string }>
const { blockId } = customEvent.detail || ({} as any)
if (!blockId) return
try {
// Remove parent-child relationship while preserving absolute position
updateNodeParent(blockId, null)
// Remove all edges connected to this block
const connectedEdges = edgesForDisplay.filter(
(e) => e.source === blockId || e.target === blockId
)
connectedEdges.forEach((edge) => {
removeEdge(edge.id)
})
} catch (err) {
logger.error('Failed to remove from subflow', { err })
}
}
window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
return () =>
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [getNodes, updateNodeParent, removeEdge, edgesForDisplay])
// Handle drops
const findClosestOutput = useCallback(
(newNodePosition: { x: number; y: number }): BlockData | null => {
@@ -451,7 +478,7 @@ const WorkflowContent = React.memo(() => {
{
width: 500,
height: 300,
type: type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
},
undefined,
undefined,
@@ -571,7 +598,7 @@ const WorkflowContent = React.memo(() => {
addBlock(id, data.type, name, relativePosition, {
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
parentId: containerInfo.loopId,
extent: 'parent',
})
@@ -607,7 +634,7 @@ const WorkflowContent = React.memo(() => {
{
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
type: 'subflowNode',
},
undefined,
undefined,
@@ -657,10 +684,12 @@ const WorkflowContent = React.memo(() => {
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
const containerType = containerNode?.type
if (containerType === 'loopNode' || containerType === 'parallelNode') {
if (containerType === 'subflowNode') {
// Connect from the container's start node to the new block
const startSourceHandle =
containerType === 'loopNode' ? 'loop-start-source' : 'parallel-start-source'
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
addEdge({
id: crypto.randomUUID(),
@@ -781,9 +810,15 @@ const WorkflowContent = React.memo(() => {
if (containerElement) {
// Determine the type of container node for appropriate styling
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
if (containerNode?.type === 'loopNode') {
if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (containerNode?.type === 'parallelNode') {
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
@@ -918,31 +953,11 @@ const WorkflowContent = React.memo(() => {
}
// Handle container nodes differently
if (block.type === 'loop') {
if (block.type === 'loop' || block.type === 'parallel') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
type: 'loopNode',
position: block.position,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle',
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
},
})
return
}
// Handle parallel nodes
if (block.type === 'parallel') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
type: 'parallelNode',
type: 'subflowNode',
position: block.position,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
@@ -952,6 +967,7 @@ const WorkflowContent = React.memo(() => {
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
kind: block.type === 'loop' ? 'loop' : 'parallel',
},
})
return
@@ -1191,13 +1207,13 @@ const WorkflowContent = React.memo(() => {
const intersectingNodes = getNodes()
.filter((n) => {
// Only consider container nodes that aren't the dragged node
if ((n.type !== 'loopNode' && n.type !== 'parallelNode') || n.id === node.id) return false
if (n.type !== 'subflowNode' || n.id === node.id) return false
// Skip if this container is already the parent of the node being dragged
if (n.id === currentParentId) return false
// Skip self-nesting: prevent a container from becoming its own descendant
if (node.type === 'loopNode' || node.type === 'parallelNode') {
if (node.type === 'subflowNode') {
// Get the full hierarchy of the potential parent
const hierarchy = getNodeHierarchyWrapper(n.id)
@@ -1212,14 +1228,14 @@ const WorkflowContent = React.memo(() => {
// Get dimensions based on node type
const nodeWidth =
node.type === 'loopNode' || node.type === 'parallelNode'
node.type === 'subflowNode'
? node.data?.width || 500
: node.type === 'condition'
? 250
: 350
const nodeHeight =
node.type === 'loopNode' || node.type === 'parallelNode'
node.type === 'subflowNode'
? node.data?.height || 300
: node.type === 'condition'
? 150
@@ -1286,9 +1302,15 @@ const WorkflowContent = React.memo(() => {
)
if (containerElement) {
// Apply appropriate class based on container type
if (bestContainerMatch.container.type === 'loopNode') {
if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'loop'
) {
containerElement.classList.add('loop-node-drag-over')
} else if (bestContainerMatch.container.type === 'parallelNode') {
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
}
document.body.style.cursor = 'copy'
@@ -1356,7 +1378,7 @@ const WorkflowContent = React.memo(() => {
}
// If we're dragging a container node, do additional checks to prevent circular references
if ((node.type === 'loopNode' || node.type === 'parallelNode') && potentialParentId) {
if (node.type === 'subflowNode' && potentialParentId) {
// Get the hierarchy of the potential parent container
const parentHierarchy = getNodeHierarchyWrapper(potentialParentId)

View File

@@ -586,7 +586,7 @@ export function SearchModal({
className='bg-white/50 dark:bg-black/50'
style={{ backdropFilter: 'blur(1.5px)' }}
/>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[8px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[10px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<VisuallyHidden.Root>
<DialogTitle>Search</DialogTitle>
</VisuallyHidden.Root>

View File

@@ -2,11 +2,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { logger } from '@sentry/nextjs'
import { Folder, Plus, Upload } from 'lucide-react'
import { Download, Folder, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { isDev } from '@/lib/environment'
import { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -353,13 +352,13 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
</button>
{/* Import Workflow */}
{userPermissions.canEdit && !isDev && (
{userPermissions.canEdit && (
<button
className={cn(menuItemClassName, isImporting && 'cursor-not-allowed opacity-50')}
onClick={handleImportWorkflow}
disabled={isImporting}
>
<Upload className={iconClassName} />
<Download className={iconClassName} />
<span className={textClassName}>
{isImporting ? 'Importing...' : 'Import workflow'}
</span>

View File

@@ -29,9 +29,7 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('HelpModal')
// Define form schema
const formSchema = z.object({
email: z.string().email('Please enter a valid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other'], {
@@ -77,17 +75,35 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
subject: '',
message: '',
type: 'bug', // Set default value to 'bug'
},
mode: 'onChange',
mode: 'onSubmit',
})
// Reset state when modal opens/closes
useEffect(() => {
if (open) {
// Reset states when modal opens
setSubmitStatus(null)
setErrorMessage('')
setImageError(null)
setImages([])
setIsDragging(false)
setIsProcessing(false)
// Reset form to default values
reset({
subject: '',
message: '',
type: 'bug',
})
}
}, [open, reset])
// Listen for the custom event to open the help modal
useEffect(() => {
const handleOpenHelp = (event: CustomEvent) => {
const handleOpenHelp = () => {
onOpenChange(true)
}
@@ -268,8 +284,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
// Create FormData to handle file uploads
const formData = new FormData()
// Add form fields
formData.append('email', data.email)
// Add form fields (email will be retrieved server-side from session)
formData.append('subject', data.subject)
formData.append('message', data.message)
formData.append('type', data.type)
@@ -377,19 +392,6 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
)}
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
placeholder='your.email@example.com'
{...register('email')}
className={`h-9 rounded-[8px] ${errors.email ? 'border-red-500' : ''}`}
/>
{errors.email && (
<p className='mt-1 text-red-500 text-sm'>{errors.email.message}</p>
)}
</div>
<div className='space-y-2'>
<Label htmlFor='subject'>Subject</Label>
<Input
@@ -408,7 +410,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
<Textarea
id='message'
placeholder='Please provide details about your request...'
rows={5}
rows={6}
{...register('message')}
className={`rounded-[8px] ${errors.message ? 'border-red-500' : ''}`}
/>
@@ -426,9 +428,10 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex items-center gap-4 ${
isDragging ? 'rounded-md bg-primary/5 p-2' : ''
className={`cursor-pointer rounded-lg border-2 border-muted-foreground/25 border-dashed p-6 text-center transition-colors hover:bg-muted/50 ${
isDragging ? 'border-primary bg-primary/5' : ''
}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
@@ -438,17 +441,12 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
className='hidden'
multiple
/>
<Button
type='button'
variant='outline'
onClick={() => fileInputRef.current?.click()}
className='flex h-9 items-center justify-center gap-2 rounded-[8px]'
>
<Upload className='h-4 w-4' />
Upload Images
</Button>
<p className='text-muted-foreground text-xs'>
Drop images here or click to upload. Max 20MB per image.
<Upload className='mx-auto mb-2 h-8 w-8 text-muted-foreground' />
<p className='text-sm'>
{isDragging ? 'Drop images here!' : 'Drop images here or click to browse'}
</p>
<p className='mt-1 text-muted-foreground text-xs'>
JPEG, PNG, WebP, GIF (max 20MB each)
</p>
</div>
{imageError && <p className='mt-1 text-red-500 text-sm'>{imageError}</p>}
@@ -494,18 +492,13 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
{/* Overlay Footer */}
<div className='absolute inset-x-0 bottom-0 bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<Button
variant='outline'
onClick={handleClose}
type='button'
className='h-9 rounded-[8px]'
>
<Button variant='outline' onClick={handleClose} type='button'>
Cancel
</Button>
<Button
type='submit'
disabled={isSubmitting || isProcessing}
className='h-9 rounded-[8px]'
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>

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