mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
15 Commits
v0.5.55
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8099e824aa | ||
|
|
210bf41ffe | ||
|
|
b7a3a4a37f | ||
|
|
4622b05674 | ||
|
|
64b382eb49 | ||
|
|
1dbd16115f | ||
|
|
38e827b61a | ||
|
|
1f5e8a41f8 | ||
|
|
796f73ee01 | ||
|
|
d3d6012d5c | ||
|
|
860610b4c2 | ||
|
|
05bbf34265 | ||
|
|
753600ed60 | ||
|
|
4da43d937c | ||
|
|
9502227fd4 |
@@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<linearGradient id='bedrock_gradient' x1='80%' x2='20%' y1='20%' y2='80%'>
|
||||
<stop offset='0%' stopColor='#6350FB' />
|
||||
<stop offset='50%' stopColor='#3D8FFF' />
|
||||
<stop offset='100%' stopColor='#9AD8F8' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d='M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z'
|
||||
fill='url(#bedrock_gradient)'
|
||||
fillRule='nonzero'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,40 +49,40 @@ Die Modellaufschlüsselung zeigt:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**Gehostete Modelle** - Sim stellt API-Schlüssel mit einem 2-fachen Preismultiplikator bereit:
|
||||
**Hosted Models** - Sim bietet API-Schlüssel mit einem 1,4-fachen Preismultiplikator für Agent-Blöcke:
|
||||
|
||||
**OpenAI**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ |
|
||||
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ |
|
||||
| GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ |
|
||||
| GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
|
||||
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ |
|
||||
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ |
|
||||
| o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ |
|
||||
| o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
|
||||
| o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ |
|
||||
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ |
|
||||
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
|
||||
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
|
||||
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ |
|
||||
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*Der 2x-Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
|
||||
*Der 1,4-fache Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent.
|
||||
The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent. Each route you define creates a separate output port, allowing you to connect different paths to different downstream blocks.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/router.png"
|
||||
alt="Router Block with Multiple Paths"
|
||||
alt="Router Block with Multiple Route Ports"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
@@ -32,21 +32,23 @@ The Router block uses AI to intelligently route workflows based on content analy
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Content/Prompt
|
||||
### Context
|
||||
|
||||
The content or prompt that the Router will analyze to make routing decisions. This can be:
|
||||
The context that the Router will analyze to make routing decisions. This is the input data that gets evaluated against your route descriptions. It can be:
|
||||
|
||||
- A direct user query or input
|
||||
- Output from a previous block
|
||||
- A system-generated message
|
||||
- Any text content that needs intelligent routing
|
||||
|
||||
### Target Blocks
|
||||
### Routes
|
||||
|
||||
The possible destination blocks that the Router can select from. The Router will automatically detect connected blocks, but you can also:
|
||||
Define the possible paths that the Router can take. Each route consists of:
|
||||
|
||||
- Customize the descriptions of target blocks to improve routing accuracy
|
||||
- Specify routing criteria for each target block
|
||||
- Exclude certain blocks from being considered as routing targets
|
||||
- **Route Title**: A name for the route (e.g., "Sales", "Support", "Technical")
|
||||
- **Route Description**: A clear description of when this route should be selected (e.g., "Route here when the query is about pricing, purchasing, or sales inquiries")
|
||||
|
||||
Each route you add creates a **separate output port** on the Router block. Connect each port to the appropriate downstream block for that route.
|
||||
|
||||
### Model Selection
|
||||
|
||||
@@ -66,8 +68,9 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<router.prompt>`**: Summary of the routing prompt
|
||||
- **`<router.selected_path>`**: Chosen destination block
|
||||
- **`<router.context>`**: The context that was analyzed
|
||||
- **`<router.selectedRoute>`**: The ID of the selected route
|
||||
- **`<router.selected_path>`**: Details of the chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics
|
||||
- **`<router.cost>`**: Estimated routing cost
|
||||
- **`<router.model>`**: Model used for decision-making
|
||||
@@ -75,26 +78,36 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
## Example Use Cases
|
||||
|
||||
**Customer Support Triage** - Route tickets to specialized departments
|
||||
|
||||
```
|
||||
Input (Ticket) → Router → Agent (Engineering) or Agent (Finance)
|
||||
Input (Ticket) → Router
|
||||
├── [Sales Route] → Agent (Sales Team)
|
||||
├── [Technical Route] → Agent (Engineering)
|
||||
└── [Billing Route] → Agent (Finance)
|
||||
```
|
||||
|
||||
**Content Classification** - Classify and route user-generated content
|
||||
|
||||
```
|
||||
Input (Feedback) → Router → Workflow (Product) or Workflow (Technical)
|
||||
Input (Feedback) → Router
|
||||
├── [Product Feedback] → Workflow (Product Team)
|
||||
└── [Bug Report] → Workflow (Technical Team)
|
||||
```
|
||||
|
||||
**Lead Qualification** - Route leads based on qualification criteria
|
||||
```
|
||||
Input (Lead) → Router → Agent (Enterprise Sales) or Workflow (Self-serve)
|
||||
```
|
||||
|
||||
```
|
||||
Input (Lead) → Router
|
||||
├── [Enterprise] → Agent (Enterprise Sales)
|
||||
└── [Self-serve] → Workflow (Automated Onboarding)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Provide clear target descriptions**: Help the Router understand when to select each destination with specific, detailed descriptions
|
||||
- **Use specific routing criteria**: Define clear conditions and examples for each path to improve accuracy
|
||||
- **Implement fallback paths**: Connect a default destination for when no specific path is appropriate
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine criteria based on actual usage patterns
|
||||
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions
|
||||
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
|
||||
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
|
||||
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
|
||||
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.
|
||||
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions.
|
||||
|
||||
@@ -48,40 +48,40 @@ The model breakdown shows:
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**Hosted Models** - Sim provides API keys with a 2x pricing multiplier:
|
||||
**Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks:
|
||||
|
||||
**OpenAI**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
|
||||
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
|
||||
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
|
||||
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*The 2x multiplier covers infrastructure and API management costs.*
|
||||
*The 1.4x multiplier covers infrastructure and API management costs.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -48,7 +48,7 @@ Integrate Google Drive into the workflow. Can create, upload, and list files.
|
||||
|
||||
### `google_drive_upload`
|
||||
|
||||
Upload a file to Google Drive
|
||||
Upload a file to Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -65,11 +65,11 @@ Upload a file to Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | Uploaded file metadata including ID, name, and links |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -83,11 +83,11 @@ Create a new folder in Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | json | Created folder metadata including ID, name, and parent information |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive (exports Google Workspace files automatically)
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -96,16 +96,17 @@ Download a file from Google Drive (exports Google Workspace files automatically)
|
||||
| `fileId` | string | Yes | The ID of the file to download |
|
||||
| `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) |
|
||||
| `fileName` | string | No | Optional filename override |
|
||||
| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
| `file` | object | Downloaded file stored in execution files |
|
||||
|
||||
### `google_drive_list`
|
||||
|
||||
List files and folders in Google Drive
|
||||
List files and folders in Google Drive with complete metadata
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -121,7 +122,7 @@ List files and folders in Google Drive
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | json | Array of file metadata objects from the specified folder |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ Create a webhook to receive recording events
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
|
||||
| `hookUrl` | string | Yes | Webhook endpoint URL \(must respond 2xx\) |
|
||||
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
|
||||
| `filterBeforeDatetime` | string | No | Filter: recordings before this date |
|
||||
| `filterAfterDatetime` | string | No | Filter: recordings after this date |
|
||||
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
|
||||
@@ -178,6 +179,7 @@ Create a webhook to receive recording events
|
||||
| `id` | string | Hook UUID |
|
||||
| `enabled` | boolean | Whether hook is active |
|
||||
| `hook_url` | string | The webhook URL |
|
||||
| `hook_type` | string | Type of hook: recording_added or upload_status |
|
||||
| `filter` | object | Applied filters |
|
||||
| `include` | object | Included fields |
|
||||
| `inserted_at` | string | ISO8601 creation timestamp |
|
||||
|
||||
@@ -851,24 +851,6 @@ List all status updates for a project in Linear
|
||||
| --------- | ---- | ----------- |
|
||||
| `updates` | array | Array of project updates |
|
||||
|
||||
### `linear_create_project_link`
|
||||
|
||||
Add an external link to a project in Linear
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Project ID to add link to |
|
||||
| `url` | string | Yes | URL of the external link |
|
||||
| `label` | string | No | Link label/title |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `link` | object | The created project link |
|
||||
|
||||
### `linear_list_notifications`
|
||||
|
||||
List notifications for the current user in Linear
|
||||
@@ -1246,7 +1228,6 @@ Create a new project label in Linear
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | The project for this label |
|
||||
| `name` | string | Yes | Project label name |
|
||||
| `color` | string | No | Label color \(hex code\) |
|
||||
| `description` | string | No | Label description |
|
||||
@@ -1424,12 +1405,12 @@ Create a new project status in Linear
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | The project to create the status for |
|
||||
| `name` | string | Yes | Project status name |
|
||||
| `type` | string | Yes | Status type: "backlog", "planned", "started", "paused", "completed", or "canceled" |
|
||||
| `color` | string | Yes | Status color \(hex code\) |
|
||||
| `position` | number | Yes | Position in status list \(e.g. 0, 1, 2...\) |
|
||||
| `description` | string | No | Status description |
|
||||
| `indefinite` | boolean | No | Whether the status is indefinite |
|
||||
| `position` | number | No | Position in status list |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -79,30 +79,6 @@ Capture multiple events at once in PostHog. Use this for bulk event ingestion to
|
||||
| `status` | string | Status message indicating whether the batch was captured successfully |
|
||||
| `eventsProcessed` | number | Number of events processed in the batch |
|
||||
|
||||
### `posthog_list_events`
|
||||
|
||||
List events in PostHog. Note: This endpoint is deprecated but kept for backwards compatibility. For production use, prefer the Query endpoint with HogQL.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
|
||||
| `region` | string | No | PostHog region: us \(default\) or eu |
|
||||
| `projectId` | string | Yes | PostHog Project ID |
|
||||
| `limit` | number | No | Number of events to return \(default: 100, max: 100\) |
|
||||
| `offset` | number | No | Number of events to skip for pagination |
|
||||
| `event` | string | No | Filter by specific event name |
|
||||
| `distinctId` | string | No | Filter by specific distinct_id |
|
||||
| `before` | string | No | ISO 8601 timestamp - only return events before this time |
|
||||
| `after` | string | No | ISO 8601 timestamp - only return events after this time |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | List of events with their properties and metadata |
|
||||
|
||||
### `posthog_list_persons`
|
||||
|
||||
List persons (users) in PostHog. Returns user profiles with their properties and distinct IDs.
|
||||
|
||||
@@ -53,6 +53,9 @@ Send a chat completion request to any supported LLM provider
|
||||
| `vertexProject` | string | No | Google Cloud project ID for Vertex AI |
|
||||
| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) |
|
||||
| `vertexCredential` | string | No | Google Cloud OAuth credential ID for Vertex AI |
|
||||
| `bedrockAccessKeyId` | string | No | AWS Access Key ID for Bedrock |
|
||||
| `bedrockSecretKey` | string | No | AWS Secret Access Key for Bedrock |
|
||||
| `bedrockRegion` | string | No | AWS region for Bedrock \(defaults to us-east-1\) |
|
||||
|
||||
#### Output
|
||||
|
||||
|
||||
@@ -49,40 +49,40 @@ El desglose del modelo muestra:
|
||||
|
||||
<Tabs items={['Modelos alojados', 'Trae tu propia clave API']}>
|
||||
<Tab>
|
||||
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precio de 2x:
|
||||
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precios de 1.4x para bloques de agente:
|
||||
|
||||
**OpenAI**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
|
||||
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
|
||||
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
|
||||
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*El multiplicador 2x cubre los costos de infraestructura y gestión de API.*
|
||||
*El multiplicador de 1.4x cubre los costos de infraestructura y gestión de API.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -49,40 +49,40 @@ La répartition des modèles montre :
|
||||
|
||||
<Tabs items={['Modèles hébergés', 'Apportez votre propre clé API']}>
|
||||
<Tab>
|
||||
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 2x :
|
||||
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 1,4x pour les blocs Agent :
|
||||
|
||||
**OpenAI**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ |
|
||||
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ |
|
||||
| GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ |
|
||||
| GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
|
||||
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ |
|
||||
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ |
|
||||
| o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ |
|
||||
| o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
|
||||
| o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ |
|
||||
| GPT-5.1 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
|
||||
| GPT-5 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
|
||||
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,35 $ / 2,80 $ |
|
||||
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,07 $ / 0,56 $ |
|
||||
| GPT-4o | 2,50 $ / 10,00 $ | 3,50 $ / 14,00 $ |
|
||||
| GPT-4.1 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ |
|
||||
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,56 $ / 2,24 $ |
|
||||
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,14 $ / 0,56 $ |
|
||||
| o1 | 15,00 $ / 60,00 $ | 21,00 $ / 84,00 $ |
|
||||
| o3 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ |
|
||||
| o4 Mini | 1,10 $ / 4,40 $ | 1,54 $ / 6,16 $ |
|
||||
|
||||
**Anthropic**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ |
|
||||
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ |
|
||||
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
|
||||
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
|
||||
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ |
|
||||
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 7,00 $ / 35,00 $ |
|
||||
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 21,00 $ / 105,00 $ |
|
||||
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ |
|
||||
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ |
|
||||
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 1,40 $ / 7,00 $ |
|
||||
|
||||
**Google**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ |
|
||||
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
|
||||
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ |
|
||||
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 2,80 $ / 16,80 $ |
|
||||
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
|
||||
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,42 $ / 3,50 $ |
|
||||
|
||||
*Le multiplicateur 2x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
*Le multiplicateur de 1,4x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
|
||||
|
||||
## 料金オプション
|
||||
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tabs items={['ホステッドモデル', '独自のAPIキーを使用']}>
|
||||
<Tab>
|
||||
**ホステッドモデル** - Simは2倍の価格乗数でAPIキーを提供します:
|
||||
**ホステッドモデル** - Simは、エージェントブロック用に1.4倍の価格乗数を適用したAPIキーを提供します:
|
||||
|
||||
**OpenAI**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
|
||||
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
|
||||
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
|
||||
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*2倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。*
|
||||
*1.4倍の乗数は、インフラストラクチャとAPI管理のコストをカバーします。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost
|
||||
|
||||
## 定价选项
|
||||
|
||||
<Tabs items={[ '托管模型', '自带 API 密钥' ]}>
|
||||
<Tabs items={['托管模型', '自带 API Key']}>
|
||||
<Tab>
|
||||
**托管模型** - Sim 提供 API 密钥,价格为基础价格的 2 倍:
|
||||
**托管模型** - Sim 为 Agent 模块提供 API Key,价格乘以 1.4 倍:
|
||||
|
||||
**OpenAI**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
|
||||
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
|
||||
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
|
||||
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
|
||||
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
|
||||
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
|
||||
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
|
||||
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
|
||||
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
|
||||
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
|
||||
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
|
||||
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
|
||||
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
|
||||
|
||||
**Anthropic**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
|
||||
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
|
||||
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
|
||||
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
|
||||
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
|
||||
|
||||
**Google**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
|
||||
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
|
||||
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
|
||||
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
|
||||
|
||||
*2 倍系数涵盖了基础设施和 API 管理成本。*
|
||||
*1.4 倍的系数涵盖了基础设施和 API 管理成本。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -4581,11 +4581,11 @@ checksums:
|
||||
content/10: d19c8c67f52eb08b6a49c0969a9c8b86
|
||||
content/11: 4024a36e0d9479ff3191fb9cd2b2e365
|
||||
content/12: 0396a1e5d9548207f56e6b6cae85a542
|
||||
content/13: 4bfdeac5ad21c75209dcdfde85aa52b0
|
||||
content/14: 35df9a16b866dbe4bb9fc1d7aee42711
|
||||
content/15: 135c044066cea8cc0e22f06d67754ec5
|
||||
content/16: 6882b91e30548d7d331388c26cf2e948
|
||||
content/17: 29aed7061148ae46fa6ec8bcbc857c3d
|
||||
content/13: 68f90237f86be125224c56a2643904a3
|
||||
content/14: e854781f0fbf6f397a3ac682e892a993
|
||||
content/15: 2340c44af715fb8ca58f43151515aae1
|
||||
content/16: fc7ae93bff492d80f4b6f16e762e05fa
|
||||
content/17: 8a46692d5df3fed9f94d59dfc3fb7e0a
|
||||
content/18: e0571c88ea5bcd4305a6f5772dcbed98
|
||||
content/19: 83fc31418ff454a5e06b290e3708ef32
|
||||
content/20: 4392b5939a6d5774fb080cad1ee1dbb8
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 11 KiB |
@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
|
||||
</ul>
|
||||
<p>We will respond to your request within a reasonable timeframe.</p>
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import posthog from 'posthog-js'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
|
||||
@@ -35,12 +36,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [data, setData] = useState<AppSession>(null)
|
||||
const [isPending, setIsPending] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
const loadSession = useCallback(async (bypassCache = false) => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
const res = await client.getSession()
|
||||
const res = bypassCache
|
||||
? await client.getSession({ query: { disableCookieCache: true } })
|
||||
: await client.getSession()
|
||||
setData(res?.data ?? null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
|
||||
@@ -50,8 +54,25 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSession()
|
||||
}, [loadSession])
|
||||
// Check if user was redirected after plan upgrade
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const wasUpgraded = params.get('upgraded') === 'true'
|
||||
|
||||
if (wasUpgraded) {
|
||||
params.delete('upgraded')
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}
|
||||
|
||||
loadSession(wasUpgraded).then(() => {
|
||||
if (wasUpgraded) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
}
|
||||
})
|
||||
}, [loadSession, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || typeof posthog.identify !== 'function') {
|
||||
|
||||
@@ -42,6 +42,40 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow selection box styling
|
||||
* Uses brand-secondary color for selection highlighting
|
||||
*/
|
||||
.react-flow__selection {
|
||||
background: rgba(51, 180, 255, 0.08) !important;
|
||||
border: 1px solid var(--brand-secondary) !important;
|
||||
}
|
||||
|
||||
.react-flow__nodesselection-rect,
|
||||
.react-flow__nodesselection {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > div > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__node.selected > div > div::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1.75px var(--brand-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
|
||||
@@ -253,7 +253,7 @@ export async function POST(
|
||||
userId: deployment.userId,
|
||||
workspaceId,
|
||||
isDeployed: workflowRecord?.isDeployed ?? false,
|
||||
variables: workflowRecord?.variables || {},
|
||||
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @deprecated This route is not currently in use
|
||||
* @remarks Kept for reference - may be removed in future cleanup
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('UpdateChatTitleAPI')
|
||||
|
||||
const UpdateTitleSchema = z.object({
|
||||
chatId: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = UpdateTitleSchema.parse(body)
|
||||
|
||||
// Update the chat title
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: parsed.title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, parsed.chatId))
|
||||
|
||||
logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error updating chat title:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update chat title' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
// Get user session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.email) {
|
||||
logger.warn(`[${requestId}] Unauthorized help request attempt`)
|
||||
@@ -30,20 +29,20 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
// Extract form fields
|
||||
const subject = formData.get('subject') as string
|
||||
const message = formData.get('message') as string
|
||||
const type = formData.get('type') as string
|
||||
const workflowId = formData.get('workflowId') as string | null
|
||||
const workspaceId = formData.get('workspaceId') as string
|
||||
const userAgent = formData.get('userAgent') as string | null
|
||||
|
||||
logger.info(`[${requestId}] Processing help request`, {
|
||||
type,
|
||||
email: `${email.substring(0, 3)}***`, // Log partial email for privacy
|
||||
})
|
||||
|
||||
// Validate the form data
|
||||
const validationResult = helpFormSchema.safeParse({
|
||||
subject,
|
||||
message,
|
||||
@@ -60,7 +59,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Extract images
|
||||
const images: { filename: string; content: Buffer; contentType: string }[] = []
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
@@ -81,10 +79,14 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.debug(`[${requestId}] Help request includes ${images.length} images`)
|
||||
|
||||
// Prepare email content
|
||||
const userId = session.user.id
|
||||
let emailText = `
|
||||
Type: ${type}
|
||||
From: ${email}
|
||||
User ID: ${userId}
|
||||
Workspace ID: ${workspaceId ?? 'N/A'}
|
||||
Workflow ID: ${workflowId ?? 'N/A'}
|
||||
Browser: ${userAgent ?? 'N/A'}
|
||||
|
||||
${message}
|
||||
`
|
||||
@@ -115,7 +117,6 @@ ${message}
|
||||
|
||||
logger.info(`[${requestId}] Help request email sent successfully`)
|
||||
|
||||
// Send confirmation email to the user
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
|
||||
44
apps/sim/app/api/mcp/workflow-servers/validate/route.ts
Normal file
44
apps/sim/app/api/mcp/workflow-servers/validate/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('ValidateMcpWorkflowsAPI')
|
||||
|
||||
/**
|
||||
* POST /api/mcp/workflow-servers/validate
|
||||
* Validates if workflows have valid start blocks for MCP usage
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { workflowIds } = body
|
||||
|
||||
if (!Array.isArray(workflowIds) || workflowIds.length === 0) {
|
||||
return NextResponse.json({ error: 'workflowIds must be a non-empty array' }, { status: 400 })
|
||||
}
|
||||
|
||||
const results: Record<string, boolean> = {}
|
||||
|
||||
for (const workflowId of workflowIds) {
|
||||
try {
|
||||
const state = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
results[workflowId] = hasValidStartBlockInState(state)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to validate workflow ${workflowId}:`, error)
|
||||
results[workflowId] = false
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: results })
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate workflows for MCP:', error)
|
||||
return NextResponse.json({ error: 'Failed to validate workflows' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
extractRequiredCredentials,
|
||||
sanitizeCredentials,
|
||||
} from '@/lib/workflows/credentials/credential-extractor'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
const currentState: Partial<WorkflowState> = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
type RegenerateStateInput,
|
||||
regenerateWorkflowStateIds,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('TemplateUseAPI')
|
||||
|
||||
@@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
|
||||
// When connecting to template (edit mode), keep original IDs
|
||||
// When using template (copy mode), regenerate all IDs to avoid conflicts
|
||||
const templateState = templateData.state as RegenerateStateInput
|
||||
const workflowState = connectToTemplate
|
||||
? templateData.state
|
||||
: regenerateWorkflowStateIds(templateData.state)
|
||||
? templateState
|
||||
: regenerateWorkflowStateIds(templateState)
|
||||
|
||||
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
|
||||
// Ensure variables in state are remapped for the new workflow as well
|
||||
|
||||
@@ -243,7 +243,7 @@ export interface WorkflowExportState {
|
||||
color?: string
|
||||
exportedAt?: string
|
||||
}
|
||||
variables?: WorkflowVariable[]
|
||||
variables?: Record<string, WorkflowVariable>
|
||||
}
|
||||
|
||||
export interface WorkflowExportPayload {
|
||||
@@ -317,36 +317,44 @@ export interface WorkspaceImportResponse {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse workflow variables from database JSON format to array format.
|
||||
* Handles both array and Record<string, Variable> formats.
|
||||
* Parse workflow variables from database JSON format to Record format.
|
||||
* Handles both legacy Array and current Record<string, Variable> formats.
|
||||
*/
|
||||
export function parseWorkflowVariables(
|
||||
dbVariables: DbWorkflow['variables']
|
||||
): WorkflowVariable[] | undefined {
|
||||
): Record<string, WorkflowVariable> | undefined {
|
||||
if (!dbVariables) return undefined
|
||||
|
||||
try {
|
||||
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
|
||||
|
||||
// Handle legacy Array format by converting to Record
|
||||
if (Array.isArray(varsObj)) {
|
||||
return varsObj.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}))
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const v of varsObj) {
|
||||
result[v.id] = {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Already Record format - normalize and return
|
||||
if (typeof varsObj === 'object' && varsObj !== null) {
|
||||
return Object.values(varsObj).map((v: unknown) => {
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const [key, v] of Object.entries(varsObj)) {
|
||||
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
|
||||
return {
|
||||
result[key] = {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
value: variable.value,
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// pass
|
||||
|
||||
@@ -74,8 +74,6 @@ export async function POST(
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: true,
|
||||
deployedAt: new Date(),
|
||||
deploymentStatuses: deployedState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
@@ -88,7 +86,6 @@ export async function POST(
|
||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
// Sync MCP tools with the reverted version's parameter schema
|
||||
await syncMcpToolsForWorkflow({
|
||||
workflowId: id,
|
||||
requestId,
|
||||
|
||||
@@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => {
|
||||
update: { results: [{}] },
|
||||
})
|
||||
|
||||
const variables = [
|
||||
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
|
||||
]
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
@@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const variables = [
|
||||
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
|
||||
]
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
@@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Invalid data - missing required fields
|
||||
const invalidData = { variables: [{ name: 'test' }] }
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
|
||||
@@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
|
||||
const VariableSchema = z.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
|
||||
value: z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.record(z.unknown()),
|
||||
z.array(z.unknown()),
|
||||
]),
|
||||
})
|
||||
|
||||
const VariablesSchema = z.object({
|
||||
variables: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
workflowId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]),
|
||||
})
|
||||
),
|
||||
variables: z.record(z.string(), VariableSchema),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
const { variables } = VariablesSchema.parse(body)
|
||||
|
||||
// Format variables for storage
|
||||
const variablesRecord: Record<string, Variable> = {}
|
||||
variables.forEach((variable) => {
|
||||
variablesRecord[variable.id] = variable
|
||||
})
|
||||
|
||||
// Replace variables completely with the incoming ones
|
||||
// Variables are already in Record format - use directly
|
||||
// The frontend is the source of truth for what variables should exist
|
||||
const updatedVariables = variablesRecord
|
||||
|
||||
// Update workflow with variables
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables: updatedVariables,
|
||||
variables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
@@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
headers,
|
||||
}
|
||||
)
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workflow variables fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +332,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -106,8 +106,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
@@ -204,7 +202,6 @@ function TemplateCardInner({
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
|
||||
@@ -95,7 +95,12 @@ export function ChunkContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -100,7 +100,12 @@ export function DocumentContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -99,7 +99,12 @@ export function KnowledgeBaseContextMenu({
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -43,7 +43,12 @@ export function KnowledgeListContextMenu({
|
||||
disableAdd = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SnapshotContextMenu } from './snapshot-context-menu'
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
|
||||
interface SnapshotContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
menuRef: RefObject<HTMLDivElement | null>
|
||||
onClose: () => void
|
||||
onCopy: () => void
|
||||
onSearch?: () => void
|
||||
wrapText?: boolean
|
||||
onToggleWrap?: () => void
|
||||
/** When true, only shows Copy option (for subblock values) */
|
||||
copyOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu for execution snapshot sidebar.
|
||||
* Provides copy, search, and display options.
|
||||
* Uses createPortal to render outside any transformed containers (like modals).
|
||||
*/
|
||||
export function SnapshotContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onCopy,
|
||||
onSearch,
|
||||
wrapText,
|
||||
onToggleWrap,
|
||||
copyOnly = false,
|
||||
}: SnapshotContextMenuProps) {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</PopoverItem>
|
||||
|
||||
{!copyOnly && onSearch && (
|
||||
<>
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onSearch()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!copyOnly && onToggleWrap && (
|
||||
<>
|
||||
<PopoverDivider />
|
||||
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
getLeftmostBlockId,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useExecutionSnapshot } from '@/hooks/queries/logs'
|
||||
@@ -60,6 +71,46 @@ export function ExecutionSnapshot({
|
||||
}: ExecutionSnapshotProps) {
|
||||
const { data, isLoading, error } = useExecutionSnapshot(executionId)
|
||||
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
|
||||
const autoSelectedForExecutionRef = useRef<string | null>(null)
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false)
|
||||
setContextMenuBlockId(null)
|
||||
}, [])
|
||||
|
||||
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuBlockId(null)
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(blockId: string, mousePosition: { x: number; y: number }) => {
|
||||
setContextMenuBlockId(blockId)
|
||||
setMenuPosition(mousePosition)
|
||||
setIsMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCopyExecutionId = useCallback(() => {
|
||||
navigator.clipboard.writeText(executionId)
|
||||
closeMenu()
|
||||
}, [executionId, closeMenu])
|
||||
|
||||
const handleOpenDetails = useCallback(() => {
|
||||
if (contextMenuBlockId) {
|
||||
setPinnedBlockId(contextMenuBlockId)
|
||||
}
|
||||
closeMenu()
|
||||
}, [contextMenuBlockId, closeMenu])
|
||||
|
||||
const blockExecutions = useMemo(() => {
|
||||
if (!traceSpans || !Array.isArray(traceSpans)) return {}
|
||||
@@ -97,12 +148,21 @@ export function ExecutionSnapshot({
|
||||
return blockExecutionMap
|
||||
}, [traceSpans])
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedBlockId(null)
|
||||
}, [executionId])
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
|
||||
// Auto-select the leftmost block once when data loads for a new executionId
|
||||
useEffect(() => {
|
||||
if (
|
||||
workflowState &&
|
||||
!isMigratedWorkflowState(workflowState) &&
|
||||
autoSelectedForExecutionRef.current !== executionId
|
||||
) {
|
||||
autoSelectedForExecutionRef.current = executionId
|
||||
const leftmostId = getLeftmostBlockId(workflowState)
|
||||
setPinnedBlockId(leftmostId)
|
||||
}
|
||||
}, [executionId, workflowState])
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -169,22 +229,26 @@ export function ExecutionSnapshot({
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn(
|
||||
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
|
||||
'flex overflow-hidden',
|
||||
!isModal && 'rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='h-full flex-1'>
|
||||
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
|
||||
setPinnedBlockId(blockId)
|
||||
}}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={() => setPinnedBlockId(null)}
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
selectedBlockId={pinnedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||
@@ -193,32 +257,74 @@ export function ExecutionSnapshot({
|
||||
executionData={blockExecutions[pinnedBlockId]}
|
||||
allBlockExecutions={blockExecutions}
|
||||
workflowBlocks={workflowState.blocks}
|
||||
workflowVariables={workflowState.variables}
|
||||
loops={workflowState.loops}
|
||||
parallels={workflowState.parallels}
|
||||
isExecutionMode
|
||||
onClose={() => setPinnedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canvasContextMenu =
|
||||
typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<Popover
|
||||
open={isMenuOpen}
|
||||
onOpenChange={closeMenu}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${menuPosition.x}px`,
|
||||
top: `${menuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{contextMenuBlockId && (
|
||||
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
|
||||
)}
|
||||
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPinnedBlockId(null)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
<>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPinnedBlockId(null)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return renderContent()
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDown, Code } from '@/components/emcn'
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
Code,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
interface TraceSpansProps {
|
||||
@@ -370,7 +384,7 @@ function SpanContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders input/output section with collapsible content
|
||||
* Renders input/output section with collapsible content, context menu, and search
|
||||
*/
|
||||
function InputOutputSection({
|
||||
label,
|
||||
@@ -391,14 +405,63 @@ function InputOutputSection({
|
||||
}) {
|
||||
const sectionKey = `${spanId}-${sectionType}`
|
||||
const isExpanded = expandedSections.has(sectionKey)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Context menu state
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Code viewer features
|
||||
const {
|
||||
wrapText,
|
||||
toggleWrapText,
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch,
|
||||
closeSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef,
|
||||
} = useCodeViewerFeatures({ contentRef })
|
||||
|
||||
const jsonString = useMemo(() => {
|
||||
if (!data) return ''
|
||||
return JSON.stringify(data, null, 2)
|
||||
}, [data])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(jsonString)
|
||||
closeContextMenu()
|
||||
}, [jsonString, closeContextMenu])
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
activateSearch()
|
||||
closeContextMenu()
|
||||
}, [activateSearch, closeContextMenu])
|
||||
|
||||
const handleToggleWrap = useCallback(() => {
|
||||
toggleWrapText()
|
||||
closeContextMenu()
|
||||
}, [toggleWrapText, closeContextMenu])
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center justify-between'
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
@@ -433,12 +496,101 @@ function InputOutputSection({
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
|
||||
wrapText
|
||||
/>
|
||||
<>
|
||||
<div ref={contentRef} onContextMenu={handleContextMenu}>
|
||||
<Code.Viewer
|
||||
code={jsonString}
|
||||
language='json'
|
||||
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
|
||||
wrapText={wrapText}
|
||||
searchQuery={isSearchActive ? searchQuery : undefined}
|
||||
currentMatchIndex={currentMatchIndex}
|
||||
onMatchCountChange={handleMatchCountChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Overlay */}
|
||||
{isSearchActive && (
|
||||
<div
|
||||
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder='Search...'
|
||||
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-[45px] text-center text-[11px]',
|
||||
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
|
||||
)}
|
||||
>
|
||||
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
|
||||
</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToPreviousMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Previous match'
|
||||
>
|
||||
<ArrowUp className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={goToNextMatch}
|
||||
disabled={matchCount === 0}
|
||||
aria-label='Next match'
|
||||
>
|
||||
<ArrowDown className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1'
|
||||
onClick={closeSearch}
|
||||
aria-label='Close search'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
|
||||
{typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<Popover
|
||||
open={isContextMenuOpen}
|
||||
onOpenChange={closeContextMenu}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
|
||||
<PopoverDivider />
|
||||
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
|
||||
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
|
||||
Wrap Text
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -47,7 +47,12 @@ export function LogRowContextMenu({
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -87,7 +92,7 @@ export function LogRowContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Preview
|
||||
Open Snapshot
|
||||
</PopoverItem>
|
||||
|
||||
{/* Filter actions */}
|
||||
|
||||
@@ -109,8 +109,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
|
||||
lastUpdate: input.lastUpdate,
|
||||
metadata: input.metadata,
|
||||
variables: input.variables,
|
||||
isDeployed: input.isDeployed,
|
||||
deployedAt: input.deployedAt,
|
||||
deploymentStatuses: input.deploymentStatuses,
|
||||
needsRedeployment: input.needsRedeployment,
|
||||
dragStartPosition: input.dragStartPosition ?? null,
|
||||
@@ -210,7 +208,6 @@ function TemplateCardInner({
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Template data structure with support for both new and legacy fields
|
||||
* Template data structure
|
||||
*/
|
||||
export interface Template {
|
||||
/** Unique identifier for the template */
|
||||
@@ -59,16 +59,6 @@ export interface Template {
|
||||
isStarred: boolean
|
||||
/** Whether the current user is a super user */
|
||||
isSuperUser?: boolean
|
||||
/** @deprecated Legacy field - use creator.referenceId instead */
|
||||
userId?: string
|
||||
/** @deprecated Legacy field - use details.tagline instead */
|
||||
description?: string | null
|
||||
/** @deprecated Legacy field - use creator.name instead */
|
||||
author?: string
|
||||
/** @deprecated Legacy field - use creator.referenceType instead */
|
||||
authorType?: 'user' | 'organization'
|
||||
/** @deprecated Legacy field - use creator.referenceId when referenceType is 'organization' */
|
||||
organizationId?: string | null
|
||||
/** Display color for the template card */
|
||||
color?: string
|
||||
/** Display icon for the template card */
|
||||
@@ -107,7 +97,6 @@ export default function Templates({
|
||||
|
||||
/**
|
||||
* Filter templates based on active tab and search query
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
@@ -115,7 +104,7 @@ export default function Templates({
|
||||
return templates.filter((template) => {
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
? template.creator?.referenceId === currentUserId || template.isStarred
|
||||
: activeTab === 'gallery'
|
||||
? template.status === 'approved'
|
||||
: template.status === 'pending'
|
||||
@@ -124,13 +113,7 @@ export default function Templates({
|
||||
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.details?.tagline,
|
||||
template.author,
|
||||
template.creator?.name,
|
||||
]
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -141,7 +124,6 @@ export default function Templates({
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
@@ -235,25 +217,20 @@ export default function Templates({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template) => {
|
||||
const author = template.author || template.creator?.name || 'Unknown'
|
||||
const authorImageUrl = template.creator?.profileImageUrl || null
|
||||
|
||||
return (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
author={author}
|
||||
authorImageUrl={authorImageUrl}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
isVerified={template.creator?.verified || false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.name}
|
||||
author={template.creator?.name || 'Unknown'}
|
||||
authorImageUrl={template.creator?.profileImageUrl || null}
|
||||
usageCount={template.views.toString()}
|
||||
stars={template.stars}
|
||||
state={template.state}
|
||||
isStarred={template.isStarred}
|
||||
isVerified={template.creator?.verified || false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
useFloatBoundarySync,
|
||||
useFloatDrag,
|
||||
useFloatResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float'
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { getChatPosition, useChatStore } from '@/stores/chat/store'
|
||||
@@ -726,7 +726,9 @@ export function Chat() {
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
if (!isStreaming && !isExecuting) {
|
||||
handleSendMessage()
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (promptHistory.length > 0) {
|
||||
@@ -749,7 +751,7 @@ export function Chat() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendMessage, promptHistory, historyIndex]
|
||||
[handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1061,7 +1063,7 @@ export function Chat() {
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
|
||||
className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
disabled={!activeWorkflowId || isExecuting}
|
||||
disabled={!activeWorkflowId}
|
||||
/>
|
||||
|
||||
{/* Buttons positioned absolutely on the right */}
|
||||
@@ -1091,7 +1093,8 @@ export function Chat() {
|
||||
disabled={
|
||||
(!chatMessage.trim() && chatFiles.length === 0) ||
|
||||
!activeWorkflowId ||
|
||||
isExecuting
|
||||
isExecuting ||
|
||||
isStreaming
|
||||
}
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] rounded-full p-0 transition-colors',
|
||||
|
||||
@@ -56,7 +56,7 @@ export function BlockContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
@@ -118,7 +118,7 @@ export function BlockContextMenu({
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!allNoteBlocks && (
|
||||
{!allNoteBlocks && !isSubflow && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function PaneContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -109,12 +109,7 @@ export const DiffControls = memo(function DiffControls() {
|
||||
loops: rawState.loops || {},
|
||||
parallels: rawState.parallels || {},
|
||||
lastSaved: rawState.lastSaved || Date.now(),
|
||||
isDeployed: rawState.isDeployed || false,
|
||||
deploymentStatuses: rawState.deploymentStatuses || {},
|
||||
// Only include deployedAt if it's a valid date, never include null/undefined
|
||||
...(rawState.deployedAt && rawState.deployedAt instanceof Date
|
||||
? { deployedAt: rawState.deployedAt }
|
||||
: {}),
|
||||
}
|
||||
|
||||
logger.info('Prepared complete workflow state for checkpoint', {
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const LIMIT_INCREMENTS = [0, 50, 100] as const
|
||||
|
||||
function roundUpToNearest50(value: number): number {
|
||||
@@ -15,7 +17,7 @@ function roundUpToNearest50(value: number): number {
|
||||
}
|
||||
|
||||
export function UsageLimitActions() {
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
|
||||
const subscription = subscriptionData?.data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { Skeleton } from '@/components/ui'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
BlockDetailsSidebar,
|
||||
getLeftmostBlockId,
|
||||
WorkflowPreview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
|
||||
@@ -57,6 +58,7 @@ export function GeneralDeploy({
|
||||
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
|
||||
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
|
||||
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
|
||||
const hasAutoSelectedRef = useRef(false)
|
||||
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
|
||||
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
|
||||
|
||||
@@ -131,6 +133,19 @@ export function GeneralDeploy({
|
||||
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
|
||||
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
|
||||
|
||||
// Auto-select the leftmost block once when expanded preview opens
|
||||
useEffect(() => {
|
||||
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
|
||||
hasAutoSelectedRef.current = true
|
||||
const leftmostId = getLeftmostBlockId(workflowToShow)
|
||||
setExpandedSelectedBlockId(leftmostId)
|
||||
}
|
||||
// Reset when modal closes
|
||||
if (!showExpandedPreview) {
|
||||
hasAutoSelectedRef.current = false
|
||||
}
|
||||
}, [showExpandedPreview, workflowToShow])
|
||||
|
||||
if (showLoadingSkeleton) {
|
||||
return (
|
||||
<div className='space-y-[12px]'>
|
||||
@@ -186,7 +201,7 @@ export function GeneralDeploy({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
className='relative h-[260px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
onWheelCapture={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
e.stopPropagation()
|
||||
@@ -194,28 +209,28 @@ export function GeneralDeploy({
|
||||
>
|
||||
{workflowToShow ? (
|
||||
<>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setShowExpandedPreview(true)}
|
||||
className='absolute top-[8px] right-[8px] z-10'
|
||||
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>See preview</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
) : (
|
||||
@@ -316,21 +331,23 @@ export function GeneralDeploy({
|
||||
<div className='h-full flex-1'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
onNodeClick={(blockId) => {
|
||||
setExpandedSelectedBlockId(
|
||||
expandedSelectedBlockId === blockId ? null : blockId
|
||||
)
|
||||
setExpandedSelectedBlockId(blockId)
|
||||
}}
|
||||
cursorStyle='pointer'
|
||||
onPaneClick={() => setExpandedSelectedBlockId(null)}
|
||||
selectedBlockId={expandedSelectedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||
workflowVariables={workflowToShow.variables}
|
||||
loops={workflowToShow.loops}
|
||||
parallels={workflowToShow.parallels}
|
||||
onClose={() => setExpandedSelectedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -147,6 +147,8 @@ export function McpDeploy({
|
||||
})
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
@@ -173,7 +175,7 @@ export function McpDeploy({
|
||||
[]
|
||||
)
|
||||
|
||||
const selectedServerIds = useMemo(() => {
|
||||
const actualServerIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
@@ -184,6 +186,21 @@ export function McpDeploy({
|
||||
return ids
|
||||
}, [servers, serverToolsMap])
|
||||
|
||||
const [pendingSelectedServerIds, setPendingSelectedServerIds] = useState<string[] | null>(null)
|
||||
|
||||
const selectedServerIds = pendingSelectedServerIds ?? actualServerIds
|
||||
|
||||
useEffect(() => {
|
||||
if (isSavingRef.current) return
|
||||
if (pendingSelectedServerIds !== null) {
|
||||
const pendingSet = new Set(pendingSelectedServerIds)
|
||||
const actualSet = new Set(actualServerIds)
|
||||
if (pendingSet.size === actualSet.size && [...pendingSet].every((id) => actualSet.has(id))) {
|
||||
setPendingSelectedServerIds(null)
|
||||
}
|
||||
}
|
||||
}, [actualServerIds, pendingSelectedServerIds])
|
||||
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -241,7 +258,17 @@ export function McpDeploy({
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
|
||||
const hasDeployedTools = selectedServerIds.length > 0
|
||||
|
||||
const hasServerSelectionChanges = useMemo(() => {
|
||||
if (pendingSelectedServerIds === null) return false
|
||||
const pendingSet = new Set(pendingSelectedServerIds)
|
||||
const actualSet = new Set(actualServerIds)
|
||||
if (pendingSet.size !== actualSet.size) return true
|
||||
return ![...pendingSet].every((id) => actualSet.has(id))
|
||||
}, [pendingSelectedServerIds, actualServerIds])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (hasServerSelectionChanges && selectedServerIds.length > 0) return true
|
||||
if (!savedValues || !hasDeployedTools) return false
|
||||
if (toolName !== savedValues.toolName) return true
|
||||
if (toolDescription !== savedValues.toolDescription) return true
|
||||
@@ -251,7 +278,15 @@ export function McpDeploy({
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues])
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions,
|
||||
hasDeployedTools,
|
||||
savedValues,
|
||||
hasServerSelectionChanges,
|
||||
selectedServerIds.length,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
|
||||
@@ -262,45 +297,121 @@ export function McpDeploy({
|
||||
}, [servers.length, onHasServersChange])
|
||||
|
||||
/**
|
||||
* Save tool configuration to all deployed servers
|
||||
* Save tool configuration to all selected servers.
|
||||
* This handles both adding to new servers and updating existing tools.
|
||||
*/
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!toolName.trim()) return
|
||||
if (selectedServerIds.length === 0) return
|
||||
|
||||
const toolsToUpdate: Array<{ serverId: string; toolId: string }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id })
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsToUpdate.length === 0) return
|
||||
|
||||
isSavingRef.current = true
|
||||
onSubmittingChange?.(true)
|
||||
try {
|
||||
for (const { serverId, toolId } of toolsToUpdate) {
|
||||
await updateToolMutation.mutateAsync({
|
||||
setSaveError(null)
|
||||
|
||||
const actualSet = new Set(actualServerIds)
|
||||
const toAdd = selectedServerIds.filter((id) => !actualSet.has(id))
|
||||
const toRemove = actualServerIds.filter((id) => !selectedServerIds.includes(id))
|
||||
const toUpdate = selectedServerIds.filter((id) => actualSet.has(id))
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
for (const serverId of toAdd) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
onAddedToServer?.()
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to add to "${serverName}"`)
|
||||
logger.error(`Failed to add tool to server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
|
||||
}
|
||||
|
||||
for (const serverId of toRemove) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (toolInfo?.tool) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[serverId]
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to remove from "${serverName}"`)
|
||||
logger.error(`Failed to remove tool from server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const serverId of toUpdate) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (toolInfo?.tool) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to update "${serverName}"`)
|
||||
logger.error(`Failed to update tool on server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setSaveError(errors.join('. '))
|
||||
} else {
|
||||
refetchServers()
|
||||
setPendingSelectedServerIds(null)
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
onSubmittingChange?.(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tool configuration:', error)
|
||||
onSubmittingChange?.(false)
|
||||
}
|
||||
|
||||
isSavingRef.current = false
|
||||
onSubmittingChange?.(false)
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
@@ -309,9 +420,16 @@ export function McpDeploy({
|
||||
servers,
|
||||
serverToolsMap,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
selectedServerIds,
|
||||
actualServerIds,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
updateToolMutation,
|
||||
refetchServers,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
onAddedToServer,
|
||||
])
|
||||
|
||||
const serverOptions: ComboboxOption[] = useMemo(() => {
|
||||
@@ -321,83 +439,13 @@ export function McpDeploy({
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const handleServerSelectionChange = useCallback(
|
||||
async (newSelectedIds: string[]) => {
|
||||
if (!toolName.trim()) return
|
||||
|
||||
const currentIds = new Set(selectedServerIds)
|
||||
const newIds = new Set(newSelectedIds)
|
||||
|
||||
const toAdd = newSelectedIds.filter((id) => !currentIds.has(id))
|
||||
const toRemove = selectedServerIds.filter((id) => !newIds.has(id))
|
||||
|
||||
for (const serverId of toAdd) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
refetchServers()
|
||||
onAddedToServer?.()
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const serverId of toRemove) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (toolInfo?.tool) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[serverId]
|
||||
return next
|
||||
})
|
||||
refetchServers()
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedServerIds,
|
||||
serverToolsMap,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
refetchServers,
|
||||
onAddedToServer,
|
||||
]
|
||||
)
|
||||
/**
|
||||
* Handle server selection change - only updates local state.
|
||||
* Actual add/remove operations happen when user clicks Save.
|
||||
*/
|
||||
const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => {
|
||||
setPendingSelectedServerIds(newSelectedIds)
|
||||
}, [])
|
||||
|
||||
const selectedServersLabel = useMemo(() => {
|
||||
const count = selectedServerIds.length
|
||||
@@ -563,11 +611,7 @@ export function McpDeploy({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
)}
|
||||
{saveError && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{saveError}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,7 +488,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={false}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
@@ -529,7 +528,6 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
|
||||
<WorkflowPreview
|
||||
key={`template-preview-${existingTemplate.id}`}
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { ExternalLink, Users } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn/components'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
@@ -26,6 +27,7 @@ import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
blockId: string
|
||||
@@ -54,7 +56,7 @@ export function CredentialSelector({
|
||||
const supportsCredentialSets = subBlock.supportsCredentialSets || false
|
||||
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
|
||||
@@ -19,7 +19,9 @@ export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps)
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: !isPreview,
|
||||
|
||||
@@ -673,7 +673,7 @@ function WorkflowInputMapperSyncWrapper({
|
||||
|
||||
if (!workflowId) {
|
||||
return (
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
|
||||
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
Select a workflow to configure its inputs
|
||||
</div>
|
||||
)
|
||||
@@ -681,15 +681,15 @@ function WorkflowInputMapperSyncWrapper({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center rounded-md border border-gray-600/50 bg-gray-900/20 p-8'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
|
||||
<div className='flex items-center justify-center rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-8'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputFields.length === 0) {
|
||||
return (
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
|
||||
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
|
||||
This workflow has no custom input fields
|
||||
</div>
|
||||
)
|
||||
@@ -902,7 +902,22 @@ export function ToolInput({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const selectedTools: StoredTool[] =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value[0] !== null &&
|
||||
typeof value[0]?.type === 'string'
|
||||
? (value as StoredTool[])
|
||||
: []
|
||||
|
||||
const hasReferenceOnlyCustomTools = selectedTools.some(
|
||||
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
|
||||
)
|
||||
const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools
|
||||
const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '')
|
||||
|
||||
const {
|
||||
mcpTools,
|
||||
@@ -918,24 +933,15 @@ export function ToolInput({
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
const hasRefreshedRef = useRef(false)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const selectedTools: StoredTool[] =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value[0] !== null &&
|
||||
typeof value[0]?.type === 'string'
|
||||
? (value as StoredTool[])
|
||||
: []
|
||||
|
||||
const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp')
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreview) return
|
||||
if (hasMcpTools && !hasRefreshedRef.current) {
|
||||
hasRefreshedRef.current = true
|
||||
forceRefreshMcpTools(workspaceId)
|
||||
}
|
||||
}, [hasMcpTools, forceRefreshMcpTools, workspaceId])
|
||||
}, [hasMcpTools, forceRefreshMcpTools, workspaceId, isPreview])
|
||||
|
||||
/**
|
||||
* Returns issue info for an MCP tool.
|
||||
|
||||
@@ -43,10 +43,12 @@ export function TriggerSave({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
|
||||
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as
|
||||
| string
|
||||
| null
|
||||
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
|
||||
state.getValue(blockId, 'testUrlExpiresAt')
|
||||
)
|
||||
) as string | null
|
||||
|
||||
const isTestUrlExpired = useMemo(() => {
|
||||
if (!storedTestUrlExpiresAt) return true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
useEditorBlockProperties,
|
||||
useEditorSubblockLayout,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
@@ -58,9 +60,8 @@ export function Editor() {
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties
|
||||
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
|
||||
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
@@ -176,8 +177,9 @@ export function Editor() {
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
if (blockConfig?.docsLink) {
|
||||
window.open(blockConfig.docsLink, '_blank', 'noopener,noreferrer')
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +197,10 @@ export function Editor() {
|
||||
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
|
||||
<div
|
||||
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px]'
|
||||
style={{ background: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
|
||||
style={{ background: isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={isSubflow ? subflowIcon : blockConfig?.icon}
|
||||
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
@@ -295,7 +297,7 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && !isSubflow && blockConfig?.docsLink && (
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
// Create icon container
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.cssText = `
|
||||
width: 24px;
|
||||
@@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
// Clone the actual icon if provided
|
||||
if (info.iconElement) {
|
||||
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
|
||||
clonedIcon.style.width = '16px'
|
||||
@@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
iconContainer.appendChild(clonedIcon)
|
||||
}
|
||||
|
||||
// Create text element
|
||||
const text = document.createElement('span')
|
||||
text.textContent = info.name
|
||||
text.style.cssText = `
|
||||
color: #FFFFFF;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { createDragPreview, type DragItemInfo } from './drag-preview'
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface ToolbarItemContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when add to canvas is clicked
|
||||
*/
|
||||
onAddToCanvas: () => void
|
||||
/**
|
||||
* Callback when view documentation is clicked
|
||||
*/
|
||||
onViewDocumentation?: () => void
|
||||
/**
|
||||
* Whether the view documentation option should be shown
|
||||
*/
|
||||
showViewDocumentation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for toolbar items (triggers and blocks).
|
||||
* Displays options to add to canvas and view documentation.
|
||||
*/
|
||||
export function ToolbarItemContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onAddToCanvas,
|
||||
onViewDocumentation,
|
||||
showViewDocumentation = false,
|
||||
}: ToolbarItemContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onAddToCanvas()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add to canvas
|
||||
</PopoverItem>
|
||||
{showViewDocumentation && onViewDocumentation && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewDocumentation()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View documentation
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getTriggersForSidebar,
|
||||
hasTriggerCapability,
|
||||
} from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
|
||||
import {
|
||||
calculateTriggerHeights,
|
||||
useToolbarItemInteractions,
|
||||
@@ -34,6 +35,7 @@ interface BlockItem {
|
||||
config?: BlockConfig
|
||||
icon?: any
|
||||
bgColor?: string
|
||||
docsLink?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +100,7 @@ function getBlocks() {
|
||||
type: LoopTool.type,
|
||||
icon: LoopTool.icon,
|
||||
bgColor: LoopTool.bgColor,
|
||||
docsLink: LoopTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -106,6 +109,7 @@ function getBlocks() {
|
||||
type: ParallelTool.type,
|
||||
icon: ParallelTool.icon,
|
||||
bgColor: ParallelTool.bgColor,
|
||||
docsLink: ParallelTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -178,6 +182,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
// Toggle animation state
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
|
||||
// Context menu state
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [activeItemInfo, setActiveItemInfo] = useState<{
|
||||
type: string
|
||||
isTrigger: boolean
|
||||
docsLink?: string
|
||||
} | null>(null)
|
||||
|
||||
// Toolbar store
|
||||
const { toolbarTriggersHeight, setToolbarTriggersHeight, preSearchHeight, setPreSearchHeight } =
|
||||
useToolbarStore()
|
||||
@@ -338,6 +352,68 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle context menu for toolbar items
|
||||
*/
|
||||
const handleItemContextMenu = useCallback(
|
||||
(e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setActiveItemInfo({ type, isTrigger, docsLink })
|
||||
setIsContextMenuOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Close context menu and clear active item state
|
||||
*/
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setIsContextMenuOpen(false)
|
||||
setActiveItemInfo(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle add to canvas from context menu
|
||||
*/
|
||||
const handleContextMenuAddToCanvas = useCallback(() => {
|
||||
if (activeItemInfo) {
|
||||
handleItemClick(activeItemInfo.type, activeItemInfo.isTrigger)
|
||||
}
|
||||
}, [activeItemInfo, handleItemClick])
|
||||
|
||||
/**
|
||||
* Handle view documentation from context menu
|
||||
*/
|
||||
const handleViewDocumentation = useCallback(() => {
|
||||
if (activeItemInfo?.docsLink) {
|
||||
window.open(activeItemInfo.docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}, [activeItemInfo])
|
||||
|
||||
/**
|
||||
* Handle clicks outside the context menu to close it
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isContextMenuOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [isContextMenuOpen, closeContextMenu])
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation with ArrowUp / ArrowDown when the toolbar tab
|
||||
* is active and search is open (e.g. after Mod+F). Navigation order:
|
||||
@@ -553,6 +629,9 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
})
|
||||
}}
|
||||
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
|
||||
onContextMenu={(e) =>
|
||||
handleItemContextMenu(e, trigger.type, isTriggerCapable, trigger.docsLink)
|
||||
}
|
||||
className={clsx(
|
||||
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
|
||||
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
|
||||
@@ -642,6 +721,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
}}
|
||||
onClick={() => handleItemClick(block.type, false)}
|
||||
onContextMenu={(e) =>
|
||||
handleItemContextMenu(
|
||||
e,
|
||||
block.type,
|
||||
false,
|
||||
block.docsLink ?? block.config?.docsLink
|
||||
)
|
||||
}
|
||||
className={clsx(
|
||||
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
|
||||
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
|
||||
@@ -685,6 +772,17 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Item Context Menu */}
|
||||
<ToolbarItemContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onAddToCanvas={handleContextMenuAddToCanvas}
|
||||
onViewDocumentation={handleViewDocumentation}
|
||||
showViewDocumentation={Boolean(activeItemInfo?.docsLink)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
/**
|
||||
* Simplified hook that uses React Query for usage limits.
|
||||
* Provides usage exceeded status from existing subscription data.
|
||||
@@ -12,7 +15,7 @@ export function useUsageLimits(options?: {
|
||||
}) {
|
||||
// For now, we only support user context via React Query
|
||||
// Organization context should use useOrganizationBilling directly
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
|
||||
const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false
|
||||
|
||||
|
||||
@@ -9,4 +9,5 @@ export const LoopTool = {
|
||||
name: 'Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
docsLink: 'https://docs.sim.ai/blocks/loop',
|
||||
} as const
|
||||
|
||||
@@ -9,4 +9,5 @@ export const ParallelTool = {
|
||||
name: 'Parallel',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
docsLink: 'https://docs.sim.ai/blocks/parallel',
|
||||
} as const
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface SubflowNodeData {
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
isPreview?: boolean
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
}
|
||||
@@ -123,15 +125,17 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor) - blue ring
|
||||
* 1. Focused (selected in editor) or preview selected - blue ring
|
||||
* 2. Diff status (version comparison) - green/orange ring
|
||||
*/
|
||||
const hasRing = isFocused || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const hasRing = isFocused || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
isFocused && 'ring-[var(--brand-secondary)]',
|
||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ interface LogRowContextMenuProps {
|
||||
onFilterByBlock: (blockId: string) => void
|
||||
onFilterByStatus: (status: 'error' | 'info') => void
|
||||
onFilterByRunId: (runId: string) => void
|
||||
onCopyRunId: (runId: string) => void
|
||||
onClearFilters: () => void
|
||||
onClearConsole: () => void
|
||||
hasActiveFilters: boolean
|
||||
@@ -50,6 +51,7 @@ export function LogRowContextMenu({
|
||||
onFilterByBlock,
|
||||
onFilterByStatus,
|
||||
onFilterByRunId,
|
||||
onCopyRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
hasActiveFilters,
|
||||
@@ -64,7 +66,7 @@ export function LogRowContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
@@ -79,18 +81,18 @@ export function LogRowContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Clear filters at top when active */}
|
||||
{hasActiveFilters && (
|
||||
{/* Copy actions */}
|
||||
{entry && hasRunId && (
|
||||
<>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearFilters()
|
||||
onCopyRunId(entry.executionId!)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
Copy Run ID
|
||||
</PopoverItem>
|
||||
{entry && <PopoverDivider />}
|
||||
<PopoverDivider />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -129,6 +131,18 @@ export function LogRowContextMenu({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onClearFilters()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{(entry || hasActiveFilters) && <PopoverDivider />}
|
||||
<PopoverItem
|
||||
|
||||
@@ -52,7 +52,7 @@ export function OutputContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
@@ -337,27 +338,34 @@ export function Terminal() {
|
||||
const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
|
||||
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
|
||||
|
||||
// Output panel search state
|
||||
const [isOutputSearchActive, setIsOutputSearchActive] = useState(false)
|
||||
const [outputSearchQuery, setOutputSearchQuery] = useState('')
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||
const outputSearchInputRef = useRef<HTMLInputElement>(null)
|
||||
const outputContentRef = useRef<HTMLDivElement>(null)
|
||||
const {
|
||||
isSearchActive: isOutputSearchActive,
|
||||
searchQuery: outputSearchQuery,
|
||||
setSearchQuery: setOutputSearchQuery,
|
||||
matchCount,
|
||||
currentMatchIndex,
|
||||
activateSearch: activateOutputSearch,
|
||||
closeSearch: closeOutputSearch,
|
||||
goToNextMatch,
|
||||
goToPreviousMatch,
|
||||
handleMatchCountChange,
|
||||
searchInputRef: outputSearchInputRef,
|
||||
} = useCodeViewerFeatures({
|
||||
contentRef: outputContentRef,
|
||||
externalWrapText: wrapText,
|
||||
onWrapTextChange: setWrapText,
|
||||
})
|
||||
|
||||
// Training controls state
|
||||
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
|
||||
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
|
||||
const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore()
|
||||
|
||||
// Playground state
|
||||
const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false)
|
||||
|
||||
// Terminal resize hooks
|
||||
const { handleMouseDown } = useTerminalResize()
|
||||
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
|
||||
|
||||
// Terminal filters hook
|
||||
const {
|
||||
filters,
|
||||
sortConfig,
|
||||
@@ -370,12 +378,10 @@ export function Terminal() {
|
||||
hasActiveFilters,
|
||||
} = useTerminalFilters()
|
||||
|
||||
// Context menu state
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [contextMenuEntry, setContextMenuEntry] = useState<ConsoleEntry | null>(null)
|
||||
const [storedSelectionText, setStoredSelectionText] = useState('')
|
||||
|
||||
// Context menu hooks
|
||||
const {
|
||||
isOpen: isLogRowMenuOpen,
|
||||
position: logRowMenuPosition,
|
||||
@@ -577,44 +583,6 @@ export function Terminal() {
|
||||
}
|
||||
}, [activeWorkflowId, clearWorkflowConsole])
|
||||
|
||||
const activateOutputSearch = useCallback(() => {
|
||||
setIsOutputSearchActive(true)
|
||||
setTimeout(() => {
|
||||
outputSearchInputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
const closeOutputSearch = useCallback(() => {
|
||||
setIsOutputSearchActive(false)
|
||||
setOutputSearchQuery('')
|
||||
setMatchCount(0)
|
||||
setCurrentMatchIndex(0)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Navigates to the next match in the search results.
|
||||
*/
|
||||
const goToNextMatch = useCallback(() => {
|
||||
if (matchCount === 0) return
|
||||
setCurrentMatchIndex((prev) => (prev + 1) % matchCount)
|
||||
}, [matchCount])
|
||||
|
||||
/**
|
||||
* Navigates to the previous match in the search results.
|
||||
*/
|
||||
const goToPreviousMatch = useCallback(() => {
|
||||
if (matchCount === 0) return
|
||||
setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount)
|
||||
}, [matchCount])
|
||||
|
||||
/**
|
||||
* Handles match count change from Code.Viewer.
|
||||
*/
|
||||
const handleMatchCountChange = useCallback((count: number) => {
|
||||
setMatchCount(count)
|
||||
setCurrentMatchIndex(0)
|
||||
}, [])
|
||||
|
||||
const handleClearConsole = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -683,6 +651,14 @@ export function Terminal() {
|
||||
[toggleRunId, closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleCopyRunId = useCallback(
|
||||
(runId: string) => {
|
||||
navigator.clipboard.writeText(runId)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleClearConsoleFromMenu = useCallback(() => {
|
||||
clearCurrentWorkflowConsole()
|
||||
}, [clearCurrentWorkflowConsole])
|
||||
@@ -885,66 +861,20 @@ export function Terminal() {
|
||||
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
|
||||
|
||||
/**
|
||||
* Handle Escape to close search or unselect entry
|
||||
* Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && !isOutputSearchActive && selectedEntry) {
|
||||
e.preventDefault()
|
||||
// First close search if active
|
||||
if (isOutputSearchActive) {
|
||||
closeOutputSearch()
|
||||
return
|
||||
}
|
||||
// Then unselect entry
|
||||
if (selectedEntry) {
|
||||
setSelectedEntry(null)
|
||||
setAutoSelectEnabled(true)
|
||||
}
|
||||
setSelectedEntry(null)
|
||||
setAutoSelectEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEntry, isOutputSearchActive, closeOutputSearch])
|
||||
|
||||
/**
|
||||
* Handle Enter/Shift+Enter for search navigation when search input is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOutputSearchActive) return
|
||||
|
||||
const isSearchInputFocused = document.activeElement === outputSearchInputRef.current
|
||||
|
||||
if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
goToPreviousMatch()
|
||||
} else {
|
||||
goToNextMatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOutputSearchActive, matchCount, goToNextMatch, goToPreviousMatch])
|
||||
|
||||
/**
|
||||
* Scroll to current match when it changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isOutputSearchActive || matchCount === 0 || !outputContentRef.current) return
|
||||
|
||||
// Find all match elements and scroll to the current one
|
||||
const matchElements = outputContentRef.current.querySelectorAll('[data-search-match]')
|
||||
const currentElement = matchElements[currentMatchIndex]
|
||||
|
||||
if (currentElement) {
|
||||
currentElement.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
}, [currentMatchIndex, isOutputSearchActive, matchCount])
|
||||
}, [selectedEntry, isOutputSearchActive])
|
||||
|
||||
/**
|
||||
* Adjust output panel width when sidebar or panel width changes.
|
||||
@@ -1414,25 +1344,16 @@ export function Terminal() {
|
||||
</div>
|
||||
|
||||
{/* Run ID */}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.RUN_ID,
|
||||
COLUMN_BASE_CLASS,
|
||||
'truncate font-medium font-mono text-[12px]'
|
||||
)}
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(entry.executionId)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
{entry.executionId && (
|
||||
<Tooltip.Content>
|
||||
<span className='font-mono text-[11px]'>{entry.executionId}</span>
|
||||
</Tooltip.Content>
|
||||
<span
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.RUN_ID,
|
||||
COLUMN_BASE_CLASS,
|
||||
'truncate font-medium font-mono text-[12px]'
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(entry.executionId)}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span
|
||||
@@ -1489,9 +1410,7 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
!showInput &&
|
||||
hasInputData &&
|
||||
'!text-[var(--text-primary)] dark:!text-[var(--text-primary)]'
|
||||
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1509,7 +1428,7 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
showInput && '!text-[var(--text-primary)]'
|
||||
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1839,6 +1758,7 @@ export function Terminal() {
|
||||
onFilterByBlock={handleFilterByBlock}
|
||||
onFilterByStatus={handleFilterByStatus}
|
||||
onFilterByRunId={handleFilterByRunId}
|
||||
onCopyRunId={handleCopyRunId}
|
||||
onClearFilters={() => {
|
||||
clearFilters()
|
||||
closeLogRowMenu()
|
||||
|
||||
@@ -34,8 +34,8 @@ export const ActionBar = memo(
|
||||
const {
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeToggleBlockHandles,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
@@ -121,7 +121,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockEnabled(blockId)
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
@@ -161,29 +161,6 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockId } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
>
|
||||
<LogOut className='h-[11px] w-[11px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isNoteBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -192,7 +169,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
@@ -211,6 +188,29 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
>
|
||||
<LogOut className='h-[11px] w-[11px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -54,9 +54,11 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return undefined
|
||||
return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider?.value as
|
||||
| string
|
||||
| undefined
|
||||
const value = state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider
|
||||
if (typeof value === 'object' && value !== null && 'value' in value) {
|
||||
return (value as { value?: unknown }).value as string | undefined
|
||||
}
|
||||
return value as string | undefined
|
||||
},
|
||||
[activeWorkflowId, blockId]
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface WorkflowBlockProps {
|
||||
isActive?: boolean
|
||||
isPending?: boolean
|
||||
isPreview?: boolean
|
||||
/** Whether this block is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function shouldSkipBlockRender(
|
||||
prevProps.data.isActive === nextProps.data.isActive &&
|
||||
prevProps.data.isPending === nextProps.data.isPending &&
|
||||
prevProps.data.isPreview === nextProps.data.isPreview &&
|
||||
prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected &&
|
||||
prevProps.data.config === nextProps.data.config &&
|
||||
prevProps.data.subBlockValues === nextProps.data.subBlockValues &&
|
||||
prevProps.data.blockState === nextProps.data.blockState &&
|
||||
|
||||
@@ -624,7 +624,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
if (!activeWorkflowId) return
|
||||
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
|
||||
if (!current) return
|
||||
const cred = current.credential?.value as string | undefined
|
||||
const credValue = current.credential
|
||||
const cred =
|
||||
typeof credValue === 'object' && credValue !== null && 'value' in credValue
|
||||
? ((credValue as { value?: unknown }).value as string | undefined)
|
||||
: (credValue as string | undefined)
|
||||
if (prevCredRef.current !== cred) {
|
||||
prevCredRef.current = cred
|
||||
const keys = Object.keys(current)
|
||||
|
||||
@@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({
|
||||
})
|
||||
|
||||
const isSelected = data?.isSelected ?? false
|
||||
const isInsideLoop = data?.isInsideLoop ?? false
|
||||
const parentLoopId = data?.parentLoopId
|
||||
|
||||
// Combined store subscription to reduce subscription overhead
|
||||
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
|
||||
useShallow((state) => ({
|
||||
diffAnalysis: state.diffAnalysis,
|
||||
@@ -98,7 +95,8 @@ const WorkflowEdgeComponent = ({
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
color = 'var(--border-success)'
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
@@ -120,34 +118,18 @@ const WorkflowEdgeComponent = ({
|
||||
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
|
||||
opacity,
|
||||
}
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
data-testid='workflow-edge'
|
||||
style={edgeStyle}
|
||||
interactionWidth={30}
|
||||
data-edge-id={id}
|
||||
data-parent-loop-id={parentLoopId}
|
||||
data-is-selected={isSelected ? 'true' : 'false'}
|
||||
data-is-inside-loop={isInsideLoop ? 'true' : 'false'}
|
||||
/>
|
||||
{/* Animate dash offset for edge movement effect */}
|
||||
<animate
|
||||
attributeName='stroke-dashoffset'
|
||||
from={edgeDiffStatus === 'deleted' ? '15' : '10'}
|
||||
to='0'
|
||||
dur={edgeDiffStatus === 'deleted' ? '2s' : '1s'}
|
||||
repeatCount='indefinite'
|
||||
/>
|
||||
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
|
||||
|
||||
{isSelected && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className='nodrag nopan group flex h-[22px] w-[22px] cursor-pointer items-center justify-center transition-colors'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
zIndex: 100,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
export {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
computeParentUpdateEntries,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
selectNodesDeferred,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
|
||||
export { useAutoLayout } from './use-auto-layout'
|
||||
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
|
||||
export { useBlockVisual } from './use-block-visual'
|
||||
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
|
||||
@@ -21,14 +21,15 @@ interface UseBlockVisualProps {
|
||||
|
||||
/**
|
||||
* Provides visual state and interaction handlers for workflow blocks.
|
||||
* Computes ring styling based on execution, focus, diff, and run path states.
|
||||
* In preview mode, all interactive and execution-related visual states are disabled.
|
||||
* Computes ring styling based on execution, diff, deletion, and run path states.
|
||||
* In preview mode, uses isPreviewSelected for selection highlighting.
|
||||
*
|
||||
* @param props - The hook properties
|
||||
* @returns Visual state, click handler, and ring styling for the block
|
||||
*/
|
||||
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
||||
const isPreview = data.isPreview ?? false
|
||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -40,14 +41,13 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
isDeletedBlock,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
const isActive = isPreview ? false : blockIsActive
|
||||
// In preview mode, use isPreviewSelected for selection state
|
||||
const isActive = isPreview ? isPreviewSelected : blockIsActive
|
||||
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = isPreview ? false : currentBlockId === blockId
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isPreview) {
|
||||
@@ -60,12 +60,12 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
getBlockRingStyles({
|
||||
isActive,
|
||||
isPending: isPreview ? false : isPending,
|
||||
isFocused,
|
||||
isDeletedBlock: isPreview ? false : isDeletedBlock,
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
isPreviewSelection: isPreview && isPreviewSelected,
|
||||
}),
|
||||
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
|
||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface CurrentWorkflow {
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
lastSaved?: number
|
||||
isDeployed?: boolean
|
||||
deployedAt?: Date
|
||||
deploymentStatuses?: Record<string, DeploymentStatus>
|
||||
needsRedeployment?: boolean
|
||||
|
||||
@@ -50,8 +48,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
|
||||
loops: state.loops,
|
||||
parallels: state.parallels,
|
||||
lastSaved: state.lastSaved,
|
||||
isDeployed: state.isDeployed,
|
||||
deployedAt: state.deployedAt,
|
||||
deploymentStatuses: state.deploymentStatuses,
|
||||
needsRedeployment: state.needsRedeployment,
|
||||
}))
|
||||
@@ -82,8 +78,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
|
||||
loops: activeWorkflow.loops || {},
|
||||
parallels: activeWorkflow.parallels || {},
|
||||
lastSaved: activeWorkflow.lastSaved,
|
||||
isDeployed: activeWorkflow.isDeployed,
|
||||
deployedAt: activeWorkflow.deployedAt,
|
||||
deploymentStatuses: activeWorkflow.deploymentStatuses,
|
||||
needsRedeployment: activeWorkflow.needsRedeployment,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('NodeUtilities')
|
||||
|
||||
@@ -208,28 +209,30 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
* to the content area bounds (after header and padding).
|
||||
* @param nodeId ID of the node being repositioned
|
||||
* @param newParentId ID of the new parent
|
||||
* @param skipClamping If true, returns raw relative position without clamping to container bounds
|
||||
* @returns Relative position coordinates {x, y} within the parent
|
||||
*/
|
||||
const calculateRelativePosition = useCallback(
|
||||
(nodeId: string, newParentId: string): { x: number; y: number } => {
|
||||
(nodeId: string, newParentId: string, skipClamping?: boolean): { x: number; y: number } => {
|
||||
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
|
||||
const parentAbsPos = getNodeAbsolutePosition(newParentId)
|
||||
const parentNode = getNodes().find((n) => n.id === newParentId)
|
||||
|
||||
// Calculate raw relative position (relative to parent origin)
|
||||
const rawPosition = {
|
||||
x: nodeAbsPos.x - parentAbsPos.x,
|
||||
y: nodeAbsPos.y - parentAbsPos.y,
|
||||
}
|
||||
|
||||
// Get container and block dimensions
|
||||
if (skipClamping) {
|
||||
return rawPosition
|
||||
}
|
||||
|
||||
const parentNode = getNodes().find((n) => n.id === newParentId)
|
||||
const containerDimensions = {
|
||||
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = getBlockDimensions(nodeId)
|
||||
|
||||
// Clamp position to keep block inside content area
|
||||
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
|
||||
},
|
||||
[getNodeAbsolutePosition, getNodes, getBlockDimensions]
|
||||
@@ -298,12 +301,12 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const calculateLoopDimensions = useCallback(
|
||||
(nodeId: string): { width: number; height: number } => {
|
||||
// Check both React Flow's node.parentId AND blocks store's data.parentId
|
||||
// This ensures we catch children even if React Flow hasn't re-rendered yet
|
||||
const childNodes = getNodes().filter(
|
||||
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const childBlockIds = Object.keys(currentBlocks).filter(
|
||||
(id) => currentBlocks[id]?.data?.parentId === nodeId
|
||||
)
|
||||
if (childNodes.length === 0) {
|
||||
|
||||
if (childBlockIds.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
@@ -313,30 +316,28 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
// Use block position from store if available (more up-to-date)
|
||||
const block = blocks[node.id]
|
||||
const position = block?.position || node.position
|
||||
maxRight = Math.max(maxRight, position.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
|
||||
})
|
||||
for (const childId of childBlockIds) {
|
||||
const child = currentBlocks[childId]
|
||||
if (!child?.position) continue
|
||||
|
||||
const { width: childWidth, height: childHeight } = getBlockDimensions(childId)
|
||||
|
||||
maxRight = Math.max(maxRight, child.position.x + childWidth)
|
||||
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
[getNodes, getBlockDimensions, blocks]
|
||||
[getBlockDimensions]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -345,29 +346,27 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const resizeLoopNodes = useCallback(
|
||||
(updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => {
|
||||
const containerNodes = getNodes()
|
||||
.filter((node) => node.type && isContainerType(node.type))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
depth: getNodeDepth(node.id),
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const containerBlocks = Object.entries(currentBlocks)
|
||||
.filter(([, block]) => block?.type && isContainerType(block.type))
|
||||
.map(([id, block]) => ({
|
||||
id,
|
||||
block,
|
||||
depth: getNodeDepth(id),
|
||||
}))
|
||||
// Sort by depth descending - process innermost containers first
|
||||
// so their dimensions are correct when outer containers calculate sizes
|
||||
.sort((a, b) => b.depth - a.depth)
|
||||
|
||||
containerNodes.forEach((node) => {
|
||||
const dimensions = calculateLoopDimensions(node.id)
|
||||
// Get current dimensions from the blocks store rather than React Flow's potentially stale state
|
||||
const currentWidth = blocks[node.id]?.data?.width
|
||||
const currentHeight = blocks[node.id]?.data?.height
|
||||
for (const { id, block } of containerBlocks) {
|
||||
const dimensions = calculateLoopDimensions(id)
|
||||
const currentWidth = block?.data?.width
|
||||
const currentHeight = block?.data?.height
|
||||
|
||||
// Only update if dimensions actually changed to avoid unnecessary re-renders
|
||||
if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) {
|
||||
updateNodeDimensions(node.id, dimensions)
|
||||
updateNodeDimensions(id, dimensions)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks]
|
||||
[isContainerType, getNodeDepth, calculateLoopDimensions]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,7 +117,6 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
|
||||
const cleanedWorkflowState = {
|
||||
...stateToSave,
|
||||
deployedAt: stateToSave.deployedAt ? new Date(stateToSave.deployedAt) : undefined,
|
||||
loops: stateToSave.loops || {},
|
||||
parallels: stateToSave.parallels || {},
|
||||
edges: (stateToSave.edges || []).map((edge: any) => {
|
||||
|
||||
@@ -7,66 +7,64 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined
|
||||
export interface BlockRingOptions {
|
||||
isActive: boolean
|
||||
isPending: boolean
|
||||
isFocused: boolean
|
||||
isDeletedBlock: boolean
|
||||
diffStatus: BlockDiffStatus
|
||||
runPathStatus: BlockRunPathStatus
|
||||
isPreviewSelection?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives visual ring visibility and class names for workflow blocks
|
||||
* based on execution, focus, diff, deletion, and run-path states.
|
||||
* based on execution, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||
options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock ||
|
||||
!!runPathStatus
|
||||
|
||||
const ringClassName = cn(
|
||||
// Preview selection: static blue ring (standard thickness, no animation)
|
||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||
// Executing block: pulsing success ring with prominent thickness
|
||||
isActive && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
isActive &&
|
||||
!isPreviewSelection &&
|
||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Focused (selected) state: brand ring
|
||||
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
|
||||
// Deleted state (highest priority after active/pending/focused)
|
||||
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--border-success)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'error' &&
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Edge, Node } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Checks if the currently focused element is an editable input.
|
||||
* Returns true if the user is typing in an input, textarea, or contenteditable element.
|
||||
*/
|
||||
export function isInEditableElement(): boolean {
|
||||
const activeElement = document.activeElement
|
||||
return (
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable') === true
|
||||
)
|
||||
}
|
||||
|
||||
interface TriggerValidationResult {
|
||||
isValid: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that pasting/duplicating trigger blocks won't violate constraints.
|
||||
* Returns validation result with error message if invalid.
|
||||
*/
|
||||
export function validateTriggerPaste(
|
||||
blocksToAdd: Array<{ type: string }>,
|
||||
existingBlocks: Record<string, BlockState>,
|
||||
action: 'paste' | 'duplicate'
|
||||
): TriggerValidationResult {
|
||||
for (const block of blocksToAdd) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type)
|
||||
if (issue) {
|
||||
const actionText = action === 'paste' ? 'paste' : 'duplicate'
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? `Cannot ${actionText} trigger blocks when a legacy Start block exists.`
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears drag highlight classes and resets cursor state.
|
||||
* Used when drag operations end or are cancelled.
|
||||
*/
|
||||
export function clearDragHighlights(): void {
|
||||
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects nodes by their IDs after paste/duplicate operations.
|
||||
* Defers selection to next animation frame to allow displayNodes to sync from store first.
|
||||
* This is necessary because the component uses controlled state (nodes={displayNodes})
|
||||
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
|
||||
*/
|
||||
export function selectNodesDeferred(
|
||||
nodeIds: string[],
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
): void {
|
||||
const idsSet = new Set(nodeIds)
|
||||
requestAnimationFrame(() => {
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selected: idsSet.has(node.id),
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface BlockData {
|
||||
height?: number
|
||||
data?: {
|
||||
parentId?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the final position for a node, clamping it to parent container if needed.
|
||||
* Returns the clamped position suitable for persistence.
|
||||
*/
|
||||
export function getClampedPositionForNode(
|
||||
nodeId: string,
|
||||
nodePosition: { x: number; y: number },
|
||||
blocks: Record<string, BlockData>,
|
||||
allNodes: Node[]
|
||||
): { x: number; y: number } {
|
||||
const currentBlock = blocks[nodeId]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
|
||||
if (!currentParentId) {
|
||||
return nodePosition
|
||||
}
|
||||
|
||||
const parentNode = allNodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) {
|
||||
return nodePosition
|
||||
}
|
||||
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
|
||||
return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes position updates for multiple nodes, clamping each to its parent container.
|
||||
* Used for batch position updates after multi-node drag or selection drag.
|
||||
*/
|
||||
export function computeClampedPositionUpdates(
|
||||
nodes: Node[],
|
||||
blocks: Record<string, BlockData>,
|
||||
allNodes: Node[]
|
||||
): Array<{ id: string; position: { x: number; y: number } }> {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
position: getClampedPositionForNode(node.id, node.position, blocks, allNodes),
|
||||
}))
|
||||
}
|
||||
|
||||
interface ParentUpdateEntry {
|
||||
blockId: string
|
||||
newParentId: string
|
||||
affectedEdges: Edge[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes parent update entries for nodes being moved into a subflow.
|
||||
* Only includes "boundary edges" - edges that cross the selection boundary
|
||||
* (one end inside selection, one end outside). Edges between nodes in the
|
||||
* selection are preserved.
|
||||
*/
|
||||
export function computeParentUpdateEntries(
|
||||
validNodes: Node[],
|
||||
allEdges: Edge[],
|
||||
targetParentId: string
|
||||
): ParentUpdateEntry[] {
|
||||
const movingNodeIds = new Set(validNodes.map((n) => n.id))
|
||||
|
||||
// Find edges that cross the boundary (one end inside selection, one end outside)
|
||||
// Edges between nodes in the selection should stay intact
|
||||
const boundaryEdges = allEdges.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
// Only remove if exactly one end is in the selection (crosses boundary)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
|
||||
// Build updates for all valid nodes
|
||||
return validNodes.map((n) => {
|
||||
// Only include boundary edges connected to this specific node
|
||||
const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: targetParentId,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,19 @@ import { Handle, type NodeProps, Position } from 'reactflow'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { getBlock } from '@/blocks'
|
||||
|
||||
/** Execution status for blocks in preview mode */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewBlockData {
|
||||
type: string
|
||||
name: string
|
||||
isTrigger?: boolean
|
||||
horizontalHandles?: boolean
|
||||
enabled?: boolean
|
||||
/** Whether this block is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
/** Execution status for highlighting error/success states */
|
||||
executionStatus?: ExecutionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,18 +28,20 @@ interface WorkflowPreviewBlockData {
|
||||
* Used in template cards and other preview contexts for performance.
|
||||
*/
|
||||
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
|
||||
const { type, name, isTrigger = false, horizontalHandles = false, enabled = true } = data
|
||||
const {
|
||||
type,
|
||||
name,
|
||||
isTrigger = false,
|
||||
horizontalHandles = false,
|
||||
enabled = true,
|
||||
isPreviewSelected = false,
|
||||
executionStatus,
|
||||
} = data
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
if (!blockConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
const IconComponent = blockConfig.icon
|
||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
|
||||
const visibleSubBlocks = useMemo(() => {
|
||||
if (!blockConfig.subBlocks) return []
|
||||
if (!blockConfig?.subBlocks) return []
|
||||
|
||||
return blockConfig.subBlocks.filter((subBlock) => {
|
||||
if (subBlock.hidden) return false
|
||||
@@ -41,7 +50,14 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
if (subBlock.mode === 'advanced') return false
|
||||
return true
|
||||
})
|
||||
}, [blockConfig.subBlocks])
|
||||
}, [blockConfig?.subBlocks])
|
||||
|
||||
if (!blockConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
const IconComponent = blockConfig.icon
|
||||
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
|
||||
|
||||
const hasSubBlocks = visibleSubBlocks.length > 0
|
||||
const showErrorRow = !isStarterOrTrigger
|
||||
@@ -49,8 +65,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
|
||||
const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
|
||||
|
||||
const hasError = executionStatus === 'error'
|
||||
const hasSuccess = executionStatus === 'success'
|
||||
|
||||
return (
|
||||
<div className='relative w-[250px] select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'>
|
||||
{/* Selection ring overlay (takes priority over execution rings) */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
)}
|
||||
{/* Success ring overlay (only shown if not selected) */}
|
||||
{!isPreviewSelected && hasSuccess && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-tertiary-2)]' />
|
||||
)}
|
||||
{/* Error ring overlay (only shown if not selected) */}
|
||||
{!isPreviewSelected && hasError && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--text-error)]' />
|
||||
)}
|
||||
|
||||
{/* Target handle - not shown for triggers/starters */}
|
||||
{!isStarterOrTrigger && (
|
||||
<Handle
|
||||
@@ -128,4 +160,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner)
|
||||
function shouldSkipPreviewBlockRender(
|
||||
prevProps: NodeProps<WorkflowPreviewBlockData>,
|
||||
nextProps: NodeProps<WorkflowPreviewBlockData>
|
||||
): boolean {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.type === nextProps.data.type &&
|
||||
prevProps.data.name === nextProps.data.name &&
|
||||
prevProps.data.isTrigger === nextProps.data.isTrigger &&
|
||||
prevProps.data.horizontalHandles === nextProps.data.horizontalHandles &&
|
||||
prevProps.data.enabled === nextProps.data.enabled &&
|
||||
prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected &&
|
||||
prevProps.data.executionStatus === nextProps.data.executionStatus
|
||||
)
|
||||
}
|
||||
|
||||
export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
|
||||
|
||||
@@ -10,6 +10,8 @@ interface WorkflowPreviewSubflowData {
|
||||
width?: number
|
||||
height?: number
|
||||
kind: 'loop' | 'parallel'
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +21,7 @@ interface WorkflowPreviewSubflowData {
|
||||
* Used in template cards and other preview contexts for performance.
|
||||
*/
|
||||
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
|
||||
const { name, width = 500, height = 300, kind } = data
|
||||
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
|
||||
|
||||
const isLoop = kind === 'loop'
|
||||
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
|
||||
@@ -42,6 +44,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{/* Selection ring overlay */}
|
||||
{isPreviewSelected && (
|
||||
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
|
||||
)}
|
||||
|
||||
{/* Target handle on left (input to the subflow) */}
|
||||
<Handle
|
||||
type='target'
|
||||
@@ -55,29 +62,37 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header - matches actual subflow header */}
|
||||
<div className='flex items-center gap-[10px] rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
{/* Header - matches actual subflow header structure */}
|
||||
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||
style={{ backgroundColor: blockIconBg }}
|
||||
>
|
||||
<BlockIcon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
<span className='font-medium text-[16px]' title={blockName}>
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Start handle inside - connects to first block in subflow */}
|
||||
<div className='absolute top-[56px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
|
||||
<span className='font-medium text-[14px] text-white'>Start</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
{/* Content area - matches workflow structure */}
|
||||
<div
|
||||
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
{/* Subflow Start - connects to first block in subflow */}
|
||||
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
|
||||
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
|
||||
<Handle
|
||||
type='source'
|
||||
position={Position.Right}
|
||||
id={startHandleId}
|
||||
className={rightHandleClass}
|
||||
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End source handle on right (output from the subflow) */}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { BlockDetailsSidebar } from './components/block-details-sidebar'
|
||||
export { WorkflowPreview } from './preview'
|
||||
export { getLeftmostBlockId, WorkflowPreview } from './preview'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import ReactFlow, {
|
||||
ConnectionLineType,
|
||||
type Edge,
|
||||
@@ -14,23 +14,114 @@ import 'reactflow/dist/style.css'
|
||||
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
|
||||
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowPreview')
|
||||
|
||||
/**
|
||||
* Gets block dimensions for preview purposes.
|
||||
* For containers, uses stored dimensions or defaults.
|
||||
* For regular blocks, uses stored height or estimates based on type.
|
||||
*/
|
||||
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {
|
||||
width: block.data?.width
|
||||
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height
|
||||
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
|
||||
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.height) {
|
||||
return {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
return estimateBlockDimensions(block.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions and sizes.
|
||||
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
|
||||
*/
|
||||
function calculateContainerDimensions(
|
||||
containerId: string,
|
||||
blocks: Record<string, BlockState>
|
||||
): { width: number; height: number } {
|
||||
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
|
||||
|
||||
if (childBlocks.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childBlocks) {
|
||||
if (!child?.position) continue
|
||||
|
||||
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
|
||||
|
||||
maxRight = Math.max(maxRight, child.position.x + childWidth)
|
||||
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the leftmost block ID from a workflow state.
|
||||
* Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
|
||||
*/
|
||||
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
|
||||
if (!workflowState?.blocks) return null
|
||||
|
||||
let leftmostId: string | null = null
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
|
||||
if (!block || block.type === 'loop' || block.type === 'parallel') continue
|
||||
const x = block.position?.x ?? Number.POSITIVE_INFINITY
|
||||
if (x < minX) {
|
||||
minX = x
|
||||
leftmostId = blockId
|
||||
}
|
||||
}
|
||||
|
||||
return leftmostId
|
||||
}
|
||||
|
||||
/** Execution status for edges/nodes in the preview */
|
||||
type ExecutionStatus = 'success' | 'error' | 'not-executed'
|
||||
|
||||
interface WorkflowPreviewProps {
|
||||
workflowState: WorkflowState
|
||||
showSubBlocks?: boolean
|
||||
className?: string
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
@@ -39,12 +130,18 @@ interface WorkflowPreviewProps {
|
||||
defaultZoom?: number
|
||||
fitPadding?: number
|
||||
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when a node is right-clicked */
|
||||
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
|
||||
/** Callback when the canvas (empty area) is clicked */
|
||||
onPaneClick?: () => void
|
||||
/** Use lightweight blocks for better performance in template cards */
|
||||
lightweight?: boolean
|
||||
/** Cursor style to show when hovering the canvas */
|
||||
cursorStyle?: 'default' | 'pointer' | 'grab'
|
||||
/** Map of executed block IDs to their status for highlighting the execution path */
|
||||
executedBlocks?: Record<string, { status: string }>
|
||||
/** Currently selected block ID for highlighting */
|
||||
selectedBlockId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,44 +170,49 @@ const edgeTypes: EdgeTypes = {
|
||||
}
|
||||
|
||||
interface FitViewOnChangeProps {
|
||||
nodes: Node[]
|
||||
nodeIds: string
|
||||
fitPadding: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper component that calls fitView when nodes change.
|
||||
* Helper component that calls fitView when the set of nodes changes.
|
||||
* Only triggers on actual node additions/removals, not on selection changes.
|
||||
* Must be rendered inside ReactFlowProvider.
|
||||
*/
|
||||
function FitViewOnChange({ nodes, fitPadding }: FitViewOnChangeProps) {
|
||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const hasFittedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0) {
|
||||
if (nodeIds.length > 0 && !hasFittedRef.current) {
|
||||
hasFittedRef.current = true
|
||||
// Small delay to ensure nodes are rendered before fitting
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [nodes, fitPadding, fitView])
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function WorkflowPreview({
|
||||
workflowState,
|
||||
showSubBlocks = true,
|
||||
className,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
isPannable = false,
|
||||
isPannable = true,
|
||||
defaultPosition,
|
||||
defaultZoom = 0.8,
|
||||
fitPadding = 0.25,
|
||||
onNodeClick,
|
||||
onNodeContextMenu,
|
||||
onPaneClick,
|
||||
lightweight = false,
|
||||
cursorStyle = 'grab',
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
}: WorkflowPreviewProps) {
|
||||
const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes
|
||||
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
|
||||
@@ -184,6 +286,8 @@ export function WorkflowPreview({
|
||||
|
||||
if (lightweight) {
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
@@ -191,31 +295,56 @@ export function WorkflowPreview({
|
||||
draggable: false,
|
||||
data: {
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
kind: block.type as 'loop' | 'parallel',
|
||||
isPreviewSelected: isSelected,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
let lightweightExecutionStatus: ExecutionStatus | undefined
|
||||
if (executedBlocks) {
|
||||
const blockExecution = executedBlocks[blockId]
|
||||
if (blockExecution) {
|
||||
if (blockExecution.status === 'error') {
|
||||
lightweightExecutionStatus = 'error'
|
||||
} else if (blockExecution.status === 'success') {
|
||||
lightweightExecutionStatus = 'success'
|
||||
} else {
|
||||
lightweightExecutionStatus = 'not-executed'
|
||||
}
|
||||
} else {
|
||||
lightweightExecutionStatus = 'not-executed'
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'workflowBlock',
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
// Blocks inside subflows need higher z-index to appear above the container
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
isTrigger: block.triggerMode === true,
|
||||
horizontalHandles: block.horizontalHandles ?? false,
|
||||
enabled: block.enabled ?? true,
|
||||
isPreviewSelected: isSelected,
|
||||
executionStatus: lightweightExecutionStatus,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (block.type === 'loop') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
@@ -226,10 +355,11 @@ export function WorkflowPreview({
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
state: 'valid',
|
||||
isPreview: true,
|
||||
isPreviewSelected: isSelected,
|
||||
kind: 'loop',
|
||||
},
|
||||
})
|
||||
@@ -237,6 +367,8 @@ export function WorkflowPreview({
|
||||
}
|
||||
|
||||
if (block.type === 'parallel') {
|
||||
const isSelected = selectedBlockId === blockId
|
||||
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: 'subflowNode',
|
||||
@@ -247,10 +379,11 @@ export function WorkflowPreview({
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
state: 'valid',
|
||||
isPreview: true,
|
||||
isPreviewSelected: isSelected,
|
||||
kind: 'parallel',
|
||||
},
|
||||
})
|
||||
@@ -281,15 +414,15 @@ export function WorkflowPreview({
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = selectedBlockId === blockId
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
type: nodeType,
|
||||
position: absolutePosition,
|
||||
draggable: false,
|
||||
className:
|
||||
executionStatus && executionStatus !== 'not-executed'
|
||||
? `execution-${executionStatus}`
|
||||
: undefined,
|
||||
// Blocks inside subflows need higher z-index to appear above the container
|
||||
zIndex: block.data?.parentId ? 10 : undefined,
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig,
|
||||
@@ -297,6 +430,7 @@ export function WorkflowPreview({
|
||||
blockState: block,
|
||||
canEdit: false,
|
||||
isPreview: true,
|
||||
isPreviewSelected: isSelected,
|
||||
subBlockValues: block.subBlocks ?? {},
|
||||
executionStatus,
|
||||
},
|
||||
@@ -308,11 +442,11 @@ export function WorkflowPreview({
|
||||
blocksStructure,
|
||||
loopsStructure,
|
||||
parallelsStructure,
|
||||
showSubBlocks,
|
||||
workflowState.blocks,
|
||||
isValidWorkflowState,
|
||||
lightweight,
|
||||
executedBlocks,
|
||||
selectedBlockId,
|
||||
])
|
||||
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
@@ -325,9 +459,8 @@ export function WorkflowPreview({
|
||||
const targetExecuted = executedBlocks[edge.target]
|
||||
|
||||
if (sourceExecuted && targetExecuted) {
|
||||
if (targetExecuted.status === 'error') {
|
||||
executionStatus = 'error'
|
||||
} else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') {
|
||||
// Edge is success if source succeeded and target was executed (even if target errored)
|
||||
if (sourceExecuted.status === 'success') {
|
||||
executionStatus = 'success'
|
||||
} else {
|
||||
executionStatus = 'not-executed'
|
||||
@@ -344,6 +477,8 @@ export function WorkflowPreview({
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: executionStatus ? { executionStatus } : undefined,
|
||||
// Raise executed edges above default edges
|
||||
zIndex: executionStatus === 'success' ? 10 : 0,
|
||||
}
|
||||
})
|
||||
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
|
||||
@@ -368,20 +503,19 @@ export function WorkflowPreview({
|
||||
<ReactFlowProvider>
|
||||
<div
|
||||
style={{ height, width, backgroundColor: 'var(--bg)' }}
|
||||
className={cn('preview-mode', className)}
|
||||
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
|
||||
>
|
||||
<style>{`
|
||||
${cursorStyle ? `.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }` : ''}
|
||||
/* Canvas cursor - grab on the flow container and pane */
|
||||
.preview-mode .react-flow { cursor: ${cursorStyle}; }
|
||||
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
|
||||
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
|
||||
|
||||
/* Execution status styling for nodes */
|
||||
.preview-mode .react-flow__node.execution-success {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 4px var(--border-success);
|
||||
}
|
||||
.preview-mode .react-flow__node.execution-error {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 4px var(--text-error);
|
||||
}
|
||||
/* Node cursor - pointer on nodes when onNodeClick is provided */
|
||||
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
|
||||
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
|
||||
`}</style>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -391,7 +525,7 @@ export function WorkflowPreview({
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitView
|
||||
fitViewOptions={{ padding: fitPadding }}
|
||||
panOnScroll={false}
|
||||
panOnScroll={isPannable}
|
||||
panOnDrag={isPannable}
|
||||
zoomOnScroll={false}
|
||||
draggable={false}
|
||||
@@ -414,8 +548,18 @@ export function WorkflowPreview({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNodeContextMenu={
|
||||
onNodeContextMenu
|
||||
? (event, node) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPaneClick={onPaneClick}
|
||||
/>
|
||||
<FitViewOnChange nodes={nodes} fitPadding={fitPadding} />
|
||||
<FitViewOnChange nodeIds={blocksStructure.ids} fitPadding={fitPadding} />
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
|
||||
@@ -57,9 +57,11 @@ interface ImageWithPreview extends File {
|
||||
interface HelpModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflowId?: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -370,18 +372,20 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
// Prepare form data with images
|
||||
const formData = new FormData()
|
||||
formData.append('subject', data.subject)
|
||||
formData.append('message', data.message)
|
||||
formData.append('type', data.type)
|
||||
formData.append('workspaceId', workspaceId)
|
||||
formData.append('userAgent', navigator.userAgent)
|
||||
if (workflowId) {
|
||||
formData.append('workflowId', workflowId)
|
||||
}
|
||||
|
||||
// Attach all images to form data
|
||||
images.forEach((image, index) => {
|
||||
formData.append(`image_${index}`, image)
|
||||
})
|
||||
|
||||
// Submit to API
|
||||
const response = await fetch('/api/help', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
@@ -392,11 +396,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
throw new Error(errorData.error || 'Failed to submit help request')
|
||||
}
|
||||
|
||||
// Handle success
|
||||
setSubmitStatus('success')
|
||||
reset()
|
||||
|
||||
// Clean up resources
|
||||
images.forEach((image) => URL.revokeObjectURL(image.preview))
|
||||
setImages([])
|
||||
} catch (error) {
|
||||
@@ -406,7 +408,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[images, reset]
|
||||
[images, reset, workflowId, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
export { SearchModal } from './search-modal/search-modal'
|
||||
export { SettingsModal } from './settings-modal/settings-modal'
|
||||
export { UsageIndicator } from './usage-indicator/usage-indicator'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface NavItemContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when open in new tab is clicked
|
||||
*/
|
||||
onOpenInNewTab: () => void
|
||||
/**
|
||||
* Callback when copy link is clicked
|
||||
*/
|
||||
onCopyLink: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for sidebar navigation items.
|
||||
* Displays navigation-appropriate options (open in new tab, copy link) in a popover at the right-click position.
|
||||
*/
|
||||
export function NavItemContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onCopyLink,
|
||||
}: NavItemContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyLink()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy link
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import { USAGE_THRESHOLDS } from '@/lib/billing/client/usage-visualization'
|
||||
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getUserRole } from '@/lib/workspaces/organization/utils'
|
||||
@@ -191,7 +192,13 @@ export function Subscription() {
|
||||
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
const isOrgPlan =
|
||||
subscriptionData?.data?.plan === 'team' || subscriptionData?.data?.plan === 'enterprise'
|
||||
const isLoading =
|
||||
isSubscriptionLoading ||
|
||||
isUsageLimitLoading ||
|
||||
isWorkspaceLoading ||
|
||||
(isOrgPlan && isOrgBillingLoading)
|
||||
|
||||
const subscription = {
|
||||
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
|
||||
@@ -204,7 +211,7 @@ export function Subscription() {
|
||||
subscriptionData?.data?.status === 'active',
|
||||
plan: subscriptionData?.data?.plan || 'free',
|
||||
status: subscriptionData?.data?.status || 'inactive',
|
||||
seats: organizationBillingData?.totalSeats ?? 0,
|
||||
seats: getEffectiveSeats(subscriptionData?.data),
|
||||
}
|
||||
|
||||
const usage = {
|
||||
@@ -445,16 +452,10 @@ export function Subscription() {
|
||||
? `${subscription.seats} seats`
|
||||
: undefined
|
||||
}
|
||||
current={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? (organizationBillingData?.totalCurrentUsage ?? usage.current)
|
||||
: usage.current
|
||||
}
|
||||
current={usage.current}
|
||||
limit={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalUsageLimit ||
|
||||
organizationBillingData?.minimumBillingAmount ||
|
||||
usage.limit
|
||||
? organizationBillingData?.data?.totalUsageLimit
|
||||
: !subscription.isFree &&
|
||||
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
|
||||
? usage.current // placeholder; rightContent will render UsageLimit
|
||||
@@ -468,19 +469,31 @@ export function Subscription() {
|
||||
<UsageLimit
|
||||
ref={usageLimitRef}
|
||||
currentLimit={
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.totalUsageLimit || usage.limit
|
||||
(subscription.isTeam || subscription.isEnterprise) &&
|
||||
isTeamAdmin &&
|
||||
organizationBillingData?.data
|
||||
? organizationBillingData.data.totalUsageLimit
|
||||
: usageLimitData.currentLimit || usage.limit
|
||||
}
|
||||
currentUsage={usage.current}
|
||||
canEdit={permissions.canEditUsageLimit}
|
||||
minimumLimit={
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.minimumBillingAmount || (subscription.isPro ? 20 : 40)
|
||||
(subscription.isTeam || subscription.isEnterprise) &&
|
||||
isTeamAdmin &&
|
||||
organizationBillingData?.data
|
||||
? organizationBillingData.data.minimumBillingAmount
|
||||
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
|
||||
}
|
||||
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
|
||||
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
|
||||
context={
|
||||
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
||||
? 'organization'
|
||||
: 'user'
|
||||
}
|
||||
organizationId={
|
||||
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
|
||||
? activeOrgId
|
||||
: undefined
|
||||
}
|
||||
onLimitUpdated={() => {
|
||||
logger.info('Usage limit updated')
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, Clipboard, Plus, Search } from 'lucide-react'
|
||||
import { Check, Clipboard, Plus, Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
@@ -36,13 +36,108 @@ import { FormField, McpServerSkeleton } from '../mcp/components'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
interface WorkflowTagSelectProps {
|
||||
workflows: { id: string; name: string }[]
|
||||
selectedIds: string[]
|
||||
onSelectionChange: (ids: string[]) => void
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select workflow selector using Combobox.
|
||||
* Shows selected workflows as removable badges inside the trigger.
|
||||
*/
|
||||
function WorkflowTagSelect({
|
||||
workflows,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: WorkflowTagSelectProps) {
|
||||
const options: ComboboxOption[] = useMemo(() => {
|
||||
return workflows.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id,
|
||||
}))
|
||||
}, [workflows])
|
||||
|
||||
const selectedWorkflows = useMemo(() => {
|
||||
return workflows.filter((w) => selectedIds.includes(w.id))
|
||||
}, [workflows, selectedIds])
|
||||
|
||||
const validSelectedIds = useMemo(() => {
|
||||
const workflowIds = new Set(workflows.map((w) => w.id))
|
||||
return selectedIds.filter((id) => workflowIds.has(id))
|
||||
}, [workflows, selectedIds])
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelectionChange(selectedIds.filter((i) => i !== id))
|
||||
}
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (selectedWorkflows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-[4px] overflow-hidden'>
|
||||
{selectedWorkflows.slice(0, 2).map((w) => (
|
||||
<Badge
|
||||
key={w.id}
|
||||
variant='outline'
|
||||
className='pointer-events-auto cursor-pointer gap-[4px] rounded-[6px] px-[8px] py-[2px] text-[11px]'
|
||||
onMouseDown={(e) => handleRemove(e, w.id)}
|
||||
>
|
||||
{w.name}
|
||||
<X className='h-3 w-3' />
|
||||
</Badge>
|
||||
))}
|
||||
{selectedWorkflows.length > 2 && (
|
||||
<Badge variant='outline' className='rounded-[6px] px-[8px] py-[2px] text-[11px]'>
|
||||
+{selectedWorkflows.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [selectedWorkflows, selectedIds])
|
||||
|
||||
const isEmpty = workflows.length === 0
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-[34px] w-full rounded-[6px]' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
multiSelect
|
||||
multiSelectValues={validSelectedIds}
|
||||
onMultiSelectChange={onSelectionChange}
|
||||
placeholder={isEmpty ? 'No deployed workflows available' : 'Select deployed workflows...'}
|
||||
overlayContent={overlayContent}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
disabled={disabled || isEmpty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerDetailViewProps {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
onBack: () => void
|
||||
onToolsChanged?: () => void
|
||||
}
|
||||
|
||||
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
|
||||
function ServerDetailView({
|
||||
workspaceId,
|
||||
serverId,
|
||||
onBack,
|
||||
onToolsChanged,
|
||||
}: ServerDetailViewProps) {
|
||||
const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId)
|
||||
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
|
||||
useDeployedWorkflows(workspaceId)
|
||||
@@ -81,6 +176,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
toolId: toolToDelete.id,
|
||||
})
|
||||
setToolToDelete(null)
|
||||
onToolsChanged?.()
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete tool:', err)
|
||||
}
|
||||
@@ -97,6 +193,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
setShowAddWorkflow(false)
|
||||
setSelectedWorkflowId(null)
|
||||
refetch()
|
||||
onToolsChanged?.()
|
||||
} catch (err) {
|
||||
logger.error('Failed to add workflow:', err)
|
||||
}
|
||||
@@ -120,6 +217,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
return availableWorkflows.find((w) => w.id === selectedWorkflowId)
|
||||
}, [availableWorkflows, selectedWorkflowId])
|
||||
|
||||
const selectedWorkflowInvalid = selectedWorkflow && selectedWorkflow.hasStartBlock !== true
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
@@ -178,6 +277,17 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Authentication Header
|
||||
</span>
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<code className='font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
X-API-Key: {'<your-api-key>'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -407,7 +517,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{addToolMutation.isError && (
|
||||
{selectedWorkflowInvalid && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
Workflow must have a Start block to be used as an MCP tool
|
||||
</p>
|
||||
)}
|
||||
{addToolMutation.isError && !selectedWorkflowInvalid && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
{addToolMutation.error?.message || 'Failed to add workflow'}
|
||||
</p>
|
||||
@@ -428,7 +543,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleAddWorkflow}
|
||||
disabled={!selectedWorkflowId || addToolMutation.isPending}
|
||||
disabled={!selectedWorkflowId || selectedWorkflowInvalid || addToolMutation.isPending}
|
||||
>
|
||||
{addToolMutation.isPending ? 'Adding...' : 'Add Workflow'}
|
||||
</Button>
|
||||
@@ -439,24 +554,44 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
)
|
||||
}
|
||||
|
||||
interface WorkflowMcpServersProps {
|
||||
resetKey?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Servers settings component.
|
||||
* Allows users to create and manage MCP servers that expose workflows as tools.
|
||||
*/
|
||||
export function WorkflowMcpServers() {
|
||||
export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch: refetchServers,
|
||||
} = useWorkflowMcpServers(workspaceId)
|
||||
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
|
||||
useDeployedWorkflows(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '' })
|
||||
const [selectedWorkflowIds, setSelectedWorkflowIds] = useState<string[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey !== undefined) {
|
||||
setSelectedServerId(null)
|
||||
}
|
||||
}, [resetKey])
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!searchTerm.trim()) return servers
|
||||
@@ -464,23 +599,64 @@ export function WorkflowMcpServers() {
|
||||
return servers.filter((server) => server.name.toLowerCase().includes(search))
|
||||
}, [servers, searchTerm])
|
||||
|
||||
const invalidWorkflows = useMemo(() => {
|
||||
return selectedWorkflowIds
|
||||
.map((id) => deployedWorkflows.find((w) => w.id === id))
|
||||
.filter((w) => w && w.hasStartBlock !== true)
|
||||
.map((w) => w!.name)
|
||||
}, [selectedWorkflowIds, deployedWorkflows])
|
||||
|
||||
const hasInvalidWorkflows = invalidWorkflows.length > 0
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '' })
|
||||
setSelectedWorkflowIds([])
|
||||
setShowAddForm(false)
|
||||
setCreateError(null)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
setCreateError(null)
|
||||
|
||||
let server: WorkflowMcpServer | undefined
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
server = await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
})
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
logger.error('Failed to create server:', err)
|
||||
setCreateError(err instanceof Error ? err.message : 'Failed to create server')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedWorkflowIds.length > 0 && server?.id) {
|
||||
const workflowErrors: string[] = []
|
||||
|
||||
for (const workflowId of selectedWorkflowIds) {
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
workflowId,
|
||||
})
|
||||
} catch (err) {
|
||||
const workflowName =
|
||||
deployedWorkflows.find((w) => w.id === workflowId)?.name || workflowId
|
||||
workflowErrors.push(workflowName)
|
||||
logger.error(`Failed to add workflow ${workflowId} to server:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowErrors.length > 0) {
|
||||
setCreateError(`Server created but failed to add workflows: ${workflowErrors.join(', ')}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
@@ -516,6 +692,7 @@ export function WorkflowMcpServers() {
|
||||
workspaceId={workspaceId}
|
||||
serverId={selectedServerId}
|
||||
onBack={() => setSelectedServerId(null)}
|
||||
onToolsChanged={refetchServers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -544,7 +721,11 @@ export function WorkflowMcpServers() {
|
||||
|
||||
{shouldShowForm && !isLoading && (
|
||||
<div className='rounded-[8px] border p-[10px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)] leading-relaxed'>
|
||||
Create an MCP server to expose your deployed workflows as tools.
|
||||
</p>
|
||||
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
@@ -554,16 +735,44 @@ export function WorkflowMcpServers() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
|
||||
<p className='ml-[112px] text-[12px] text-[var(--text-secondary)]'>
|
||||
Select deployed workflows to add to this MCP server. Each workflow will be available
|
||||
as a tool.
|
||||
</p>
|
||||
<FormField label='Workflows'>
|
||||
<WorkflowTagSelect
|
||||
workflows={deployedWorkflows}
|
||||
selectedIds={selectedWorkflowIds}
|
||||
onSelectionChange={setSelectedWorkflowIds}
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={deployedWorkflows.length === 0}
|
||||
/>
|
||||
</FormField>
|
||||
{hasInvalidWorkflows && (
|
||||
<p className='ml-[112px] text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
Workflow must have a Start block to be used as an MCP tool
|
||||
</p>
|
||||
)}
|
||||
|
||||
{createError && <p className='text-[12px] text-[var(--text-error)]'>{createError}</p>}
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
|
||||
<Button variant='ghost' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
disabled={
|
||||
!isFormValid ||
|
||||
hasInvalidWorkflows ||
|
||||
createServerMutation.isPending ||
|
||||
addToolMutation.isPending
|
||||
}
|
||||
variant='tertiary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
{createServerMutation.isPending || addToolMutation.isPending
|
||||
? 'Adding...'
|
||||
: 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,10 +162,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||
const [workflowMcpResetKey, setWorkflowMcpResetKey] = useState(0)
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
@@ -174,6 +175,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
@@ -244,7 +246,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(sectionId: SettingsSection) => {
|
||||
if (sectionId === activeSection) return
|
||||
if (sectionId === activeSection) {
|
||||
if (sectionId === 'workflow-mcp-servers') {
|
||||
setWorkflowMcpResetKey((prev) => prev + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||
@@ -469,7 +476,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{activeSection === 'workflow-mcp-servers' && (
|
||||
<WorkflowMcpServers resetKey={workflowMcpResetKey} />
|
||||
)}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -150,7 +150,7 @@ export function ContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={onClose}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
HelpModal,
|
||||
NavItemContextMenu,
|
||||
SearchModal,
|
||||
SettingsModal,
|
||||
UsageIndicator,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
WorkspaceHeader,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
|
||||
import {
|
||||
useContextMenu,
|
||||
useFolderOperations,
|
||||
useSidebarResize,
|
||||
useWorkflowOperations,
|
||||
@@ -168,6 +170,46 @@ export function Sidebar() {
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
/** Context menu state for navigation items */
|
||||
const [activeNavItemHref, setActiveNavItemHref] = useState<string | null>(null)
|
||||
const {
|
||||
isOpen: isNavContextMenuOpen,
|
||||
position: navContextMenuPosition,
|
||||
menuRef: navMenuRef,
|
||||
handleContextMenu: handleNavContextMenuBase,
|
||||
closeMenu: closeNavContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const handleNavItemContextMenu = useCallback(
|
||||
(e: React.MouseEvent, href: string) => {
|
||||
setActiveNavItemHref(href)
|
||||
handleNavContextMenuBase(e)
|
||||
},
|
||||
[handleNavContextMenuBase]
|
||||
)
|
||||
|
||||
const handleNavContextMenuClose = useCallback(() => {
|
||||
closeNavContextMenu()
|
||||
setActiveNavItemHref(null)
|
||||
}, [closeNavContextMenu])
|
||||
|
||||
const handleNavOpenInNewTab = useCallback(() => {
|
||||
if (activeNavItemHref) {
|
||||
window.open(activeNavItemHref, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}, [activeNavItemHref])
|
||||
|
||||
const handleNavCopyLink = useCallback(async () => {
|
||||
if (activeNavItemHref) {
|
||||
const fullUrl = `${window.location.origin}${activeNavItemHref}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy link to clipboard', { error })
|
||||
}
|
||||
}
|
||||
}, [activeNavItemHref])
|
||||
|
||||
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
|
||||
getWorkspaceId: () => workspaceId,
|
||||
})
|
||||
@@ -629,12 +671,23 @@ export function Sidebar() {
|
||||
href={item.href!}
|
||||
data-item-id={item.id}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onContextMenu={(e) => handleNavItemContextMenu(e, item.href!)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Nav Item Context Menu */}
|
||||
<NavItemContextMenu
|
||||
isOpen={isNavContextMenuOpen}
|
||||
position={navContextMenuPosition}
|
||||
menuRef={navMenuRef}
|
||||
onClose={handleNavContextMenuClose}
|
||||
onOpenInNewTab={handleNavOpenInNewTab}
|
||||
onCopyLink={handleNavCopyLink}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -661,7 +714,12 @@ export function Sidebar() {
|
||||
/>
|
||||
|
||||
{/* Footer Navigation Modals */}
|
||||
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
|
||||
<HelpModal
|
||||
open={isHelpModalOpen}
|
||||
onOpenChange={setIsHelpModalOpen}
|
||||
workflowId={workflowId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<SettingsModal
|
||||
open={isSettingsModalOpen}
|
||||
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
|
||||
|
||||
@@ -4,6 +4,7 @@ import JSZip from 'jszip'
|
||||
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { Variable } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('useExportWorkflow')
|
||||
|
||||
@@ -122,17 +123,12 @@ export function useExportWorkflow({
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch workflow variables
|
||||
// Fetch workflow variables (API returns Record format directly)
|
||||
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
|
||||
let workflowVariables: any[] = []
|
||||
let workflowVariables: Record<string, Variable> | undefined
|
||||
if (variablesResponse.ok) {
|
||||
const variablesData = await variablesResponse.json()
|
||||
workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}))
|
||||
workflowVariables = variablesData?.data
|
||||
}
|
||||
|
||||
// Prepare export state
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
exportWorkspaceToZip,
|
||||
type FolderExportData,
|
||||
type WorkflowExportData,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import type { Variable } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('useExportWorkspace')
|
||||
|
||||
@@ -74,15 +76,10 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
}
|
||||
|
||||
const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`)
|
||||
let workflowVariables: any[] = []
|
||||
let workflowVariables: Record<string, Variable> | undefined
|
||||
if (variablesResponse.ok) {
|
||||
const variablesData = await variablesResponse.json()
|
||||
workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}))
|
||||
workflowVariables = variablesData?.data
|
||||
}
|
||||
|
||||
workflowsToExport.push({
|
||||
@@ -101,15 +98,13 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
}
|
||||
}
|
||||
|
||||
const foldersToExport: Array<{
|
||||
id: string
|
||||
name: string
|
||||
parentId: string | null
|
||||
}> = (foldersData.folders || []).map((folder: any) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
}))
|
||||
const foldersToExport: FolderExportData[] = (foldersData.folders || []).map(
|
||||
(folder: FolderExportData) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId: folder.parentId,
|
||||
})
|
||||
)
|
||||
|
||||
const zipBlob = await exportWorkspaceToZip(
|
||||
workspaceName,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user