mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
22 Commits
feat/copil
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -4575,22 +4575,3 @@ 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>
|
||||
**Hosted Models** - Sim bietet API-Schlüssel mit einem 1,4-fachen Preismultiplikator für Agent-Blöcke:
|
||||
**Gehostete Modelle** - Sim stellt API-Schlüssel mit einem 2-fachen Preismultiplikator bereit:
|
||||
|
||||
**OpenAI**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 $ |
|
||||
|
||||
**Anthropic**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 $ |
|
||||
|
||||
**Google**
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|
||||
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 $ |
|
||||
|
||||
*Der 1,4-fache Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
|
||||
*Der 2x-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. Each route you define creates a separate output port, allowing you to connect different paths to different downstream blocks.
|
||||
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.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/router.png"
|
||||
alt="Router Block with Multiple Route Ports"
|
||||
alt="Router Block with Multiple Paths"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
@@ -32,23 +32,21 @@ The Router block uses AI to intelligently route workflows based on content analy
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Context
|
||||
### Content/Prompt
|
||||
|
||||
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:
|
||||
The content or prompt that the Router will analyze to make routing decisions. This can be:
|
||||
|
||||
- A direct user query or input
|
||||
- Output from a previous block
|
||||
- A system-generated message
|
||||
- Any text content that needs intelligent routing
|
||||
|
||||
### Routes
|
||||
### Target Blocks
|
||||
|
||||
Define the possible paths that the Router can take. Each route consists of:
|
||||
The possible destination blocks that the Router can select from. The Router will automatically detect connected blocks, but you can also:
|
||||
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
### Model Selection
|
||||
|
||||
@@ -68,9 +66,8 @@ Your API key for the selected LLM provider. This is securely stored and used for
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<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.prompt>`**: Summary of the routing prompt
|
||||
- **`<router.selected_path>`**: Chosen destination block
|
||||
- **`<router.tokens>`**: Token usage statistics
|
||||
- **`<router.cost>`**: Estimated routing cost
|
||||
- **`<router.model>`**: Model used for decision-making
|
||||
@@ -78,36 +75,26 @@ 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
|
||||
├── [Sales Route] → Agent (Sales Team)
|
||||
├── [Technical Route] → Agent (Engineering)
|
||||
└── [Billing Route] → Agent (Finance)
|
||||
Input (Ticket) → Router → Agent (Engineering) or Agent (Finance)
|
||||
```
|
||||
|
||||
**Content Classification** - Classify and route user-generated content
|
||||
|
||||
```
|
||||
Input (Feedback) → Router
|
||||
├── [Product Feedback] → Workflow (Product Team)
|
||||
└── [Bug Report] → Workflow (Technical Team)
|
||||
Input (Feedback) → Router → Workflow (Product) or Workflow (Technical)
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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 1.4x pricing multiplier for Agent blocks:
|
||||
**Hosted Models** - Sim provides API keys with a 2x pricing multiplier:
|
||||
|
||||
**OpenAI**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Anthropic**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Google**
|
||||
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
*The 1.4x multiplier covers infrastructure and API management costs.*
|
||||
*The 2x 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 with complete metadata returned
|
||||
Upload a file to Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -65,11 +65,11 @@ Upload a file to Google Drive with complete metadata returned
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete uploaded file metadata from Google Drive |
|
||||
| `file` | json | Uploaded file metadata including ID, name, and links |
|
||||
|
||||
### `google_drive_create_folder`
|
||||
|
||||
Create a new folder in Google Drive with complete metadata returned
|
||||
Create a new folder in Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -83,11 +83,11 @@ Create a new folder in Google Drive with complete metadata returned
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | object | Complete created folder metadata from Google Drive |
|
||||
| `file` | json | Created folder metadata including ID, name, and parent information |
|
||||
|
||||
### `google_drive_download`
|
||||
|
||||
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
|
||||
Download a file from Google Drive (exports Google Workspace files automatically)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -96,17 +96,16 @@ Download a file from Google Drive with complete metadata (exports Google Workspa
|
||||
| `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` | object | Downloaded file stored in execution files |
|
||||
| `file` | file | Downloaded file stored in execution files |
|
||||
|
||||
### `google_drive_list`
|
||||
|
||||
List files and folders in Google Drive with complete metadata
|
||||
List files and folders in Google Drive
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -122,7 +121,7 @@ List files and folders in Google Drive with complete metadata
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | array | Array of file metadata objects from Google Drive |
|
||||
| `files` | json | Array of file metadata objects from the specified folder |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ 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" |
|
||||
@@ -179,7 +178,6 @@ 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,6 +851,24 @@ 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
|
||||
@@ -1228,6 +1246,7 @@ 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 |
|
||||
@@ -1405,12 +1424,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,6 +79,30 @@ 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,9 +53,6 @@ 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 precios de 1.4x para bloques de agente:
|
||||
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precio de 2x:
|
||||
|
||||
**OpenAI**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Anthropic**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Google**
|
||||
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
*El multiplicador de 1.4x cubre los costos de infraestructura y gestión de API.*
|
||||
*El multiplicador 2x 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 1,4x pour les blocs Agent :
|
||||
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 2x :
|
||||
|
||||
**OpenAI**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 $ |
|
||||
| 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 $ |
|
||||
|
||||
**Anthropic**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 $ |
|
||||
| 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 $ |
|
||||
|
||||
**Google**
|
||||
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 $ |
|
||||
| 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 $ |
|
||||
|
||||
*Le multiplicateur de 1,4x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
*Le multiplicateur 2x couvre les coûts d'infrastructure et de gestion des API.*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
|
||||
|
||||
## 料金オプション
|
||||
|
||||
<Tabs items={['ホステッドモデル', '独自のAPIキーを使用']}>
|
||||
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
|
||||
<Tab>
|
||||
**ホステッドモデル** - Simは、エージェントブロック用に1.4倍の価格乗数を適用したAPIキーを提供します:
|
||||
**ホステッドモデル** - Simは2倍の価格乗数でAPIキーを提供します:
|
||||
|
||||
**OpenAI**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Anthropic**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Google**
|
||||
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
*1.4倍の乗数は、インフラストラクチャとAPI管理のコストをカバーします。*
|
||||
*2倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost
|
||||
|
||||
## 定价选项
|
||||
|
||||
<Tabs items={['托管模型', '自带 API Key']}>
|
||||
<Tabs items={[ '托管模型', '自带 API 密钥' ]}>
|
||||
<Tab>
|
||||
**托管模型** - Sim 为 Agent 模块提供 API Key,价格乘以 1.4 倍:
|
||||
**托管模型** - Sim 提供 API 密钥,价格为基础价格的 2 倍:
|
||||
|
||||
**OpenAI**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Anthropic**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
**Google**
|
||||
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|
||||
|-------|---------------------------|----------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
*1.4 倍的系数涵盖了基础设施和 API 管理成本。*
|
||||
*2 倍系数涵盖了基础设施和 API 管理成本。*
|
||||
</Tab>
|
||||
|
||||
<Tab>
|
||||
|
||||
@@ -4581,11 +4581,11 @@ checksums:
|
||||
content/10: d19c8c67f52eb08b6a49c0969a9c8b86
|
||||
content/11: 4024a36e0d9479ff3191fb9cd2b2e365
|
||||
content/12: 0396a1e5d9548207f56e6b6cae85a542
|
||||
content/13: 68f90237f86be125224c56a2643904a3
|
||||
content/14: e854781f0fbf6f397a3ac682e892a993
|
||||
content/15: 2340c44af715fb8ca58f43151515aae1
|
||||
content/16: fc7ae93bff492d80f4b6f16e762e05fa
|
||||
content/17: 8a46692d5df3fed9f94d59dfc3fb7e0a
|
||||
content/13: 4bfdeac5ad21c75209dcdfde85aa52b0
|
||||
content/14: 35df9a16b866dbe4bb9fc1d7aee42711
|
||||
content/15: 135c044066cea8cc0e22f06d67754ec5
|
||||
content/16: 6882b91e30548d7d331388c26cf2e948
|
||||
content/17: 29aed7061148ae46fa6ec8bcbc857c3d
|
||||
content/18: e0571c88ea5bcd4305a6f5772dcbed98
|
||||
content/19: 83fc31418ff454a5e06b290e3708ef32
|
||||
content/20: 4392b5939a6d5774fb080cad1ee1dbb8
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 33 KiB |
@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
|
||||
</ul>
|
||||
<p>We will respond to your request within a reasonable timeframe.</p>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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'
|
||||
|
||||
@@ -36,15 +35,12 @@ 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 (bypassCache = false) => {
|
||||
const loadSession = useCallback(async () => {
|
||||
try {
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
const res = bypassCache
|
||||
? await client.getSession({ query: { disableCookieCache: true } })
|
||||
: await client.getSession()
|
||||
const res = await client.getSession()
|
||||
setData(res?.data ?? null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
|
||||
@@ -54,25 +50,8 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// 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])
|
||||
loadSession()
|
||||
}, [loadSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || typeof posthog.identify !== 'function') {
|
||||
|
||||
@@ -42,40 +42,6 @@
|
||||
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 as Record<string, unknown>) ?? undefined,
|
||||
variables: workflowRecord?.variables || {},
|
||||
}
|
||||
|
||||
const stream = await createStreamingResponse({
|
||||
|
||||
@@ -17,30 +17,25 @@ const logger = createLogger('CopilotChatUpdateAPI')
|
||||
const UpdateMessagesSchema = z.object({
|
||||
chatId: z.string(),
|
||||
messages: z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contexts: z.array(z.any()).optional(),
|
||||
citations: z.array(z.any()).optional(),
|
||||
errorType: z.string().optional(),
|
||||
})
|
||||
.passthrough() // Preserve any additional fields for future compatibility
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
planArtifact: z.string().nullable().optional(),
|
||||
config: z
|
||||
@@ -62,19 +57,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// Debug: Log what we received
|
||||
const lastMsg = body.messages?.[body.messages.length - 1]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Received messages to save`, {
|
||||
messageCount: body.messages?.length,
|
||||
lastMsgId: lastMsg.id,
|
||||
lastMsgContentLength: lastMsg.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
|
||||
50
apps/sim/app/api/copilot/chat/update-title/route.ts
Normal file
50
apps/sim/app/api/copilot/chat/update-title/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
apps/sim/app/api/copilot/context-usage/route.ts
Normal file
134
apps/sim/app/api/copilot/context-usage/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getCopilotModel } from '@/lib/copilot/config'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
|
||||
import type { CopilotProviderConfig } from '@/lib/copilot/types'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('ContextUsageAPI')
|
||||
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const ContextUsageRequestSchema = z.object({
|
||||
chatId: z.string(),
|
||||
model: z.string(),
|
||||
workflowId: z.string(),
|
||||
provider: z.any().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/copilot/context-usage
|
||||
* Fetch context usage from sim-agent API
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
logger.info('[Context Usage API] Request received')
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('[Context Usage API] No session/user ID')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
logger.info('[Context Usage API] Request body', body)
|
||||
|
||||
const parsed = ContextUsageRequestSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn('[Context Usage API] Invalid request body', parsed.error.errors)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request body', details: parsed.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { chatId, model, workflowId, provider } = parsed.data
|
||||
const userId = session.user.id // Get userId from session, not from request
|
||||
|
||||
logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId })
|
||||
|
||||
// Build provider config similar to chat route
|
||||
let providerConfig: CopilotProviderConfig | undefined = provider
|
||||
if (!providerConfig) {
|
||||
const defaults = getCopilotModel('chat')
|
||||
const modelToUse = env.COPILOT_MODEL || defaults.model
|
||||
const providerEnv = env.COPILOT_PROVIDER as any
|
||||
|
||||
if (providerEnv) {
|
||||
if (providerEnv === 'azure-openai') {
|
||||
providerConfig = {
|
||||
provider: 'azure-openai',
|
||||
model: modelToUse,
|
||||
apiKey: env.AZURE_OPENAI_API_KEY,
|
||||
apiVersion: env.AZURE_OPENAI_API_VERSION,
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
}
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
}
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call sim-agent API
|
||||
const requestPayload = {
|
||||
chatId,
|
||||
model,
|
||||
userId,
|
||||
workflowId,
|
||||
...(providerConfig ? { provider: providerConfig } : {}),
|
||||
}
|
||||
|
||||
logger.info('[Context Usage API] Calling sim-agent', {
|
||||
url: `${SIM_AGENT_API_URL}/api/get-context-usage`,
|
||||
payload: requestPayload,
|
||||
})
|
||||
|
||||
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
})
|
||||
|
||||
logger.info('[Context Usage API] Sim-agent response', {
|
||||
status: simAgentResponse.status,
|
||||
ok: simAgentResponse.ok,
|
||||
})
|
||||
|
||||
if (!simAgentResponse.ok) {
|
||||
const errorText = await simAgentResponse.text().catch(() => '')
|
||||
logger.warn('[Context Usage API] Sim agent request failed', {
|
||||
status: simAgentResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch context usage from sim-agent' },
|
||||
{ status: simAgentResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await simAgentResponse.json()
|
||||
logger.info('[Context Usage API] Sim-agent data received', data)
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching context usage:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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`)
|
||||
@@ -29,20 +30,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,
|
||||
@@ -59,6 +60,7 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Extract images
|
||||
const images: { filename: string; content: Buffer; contentType: string }[] = []
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
@@ -79,14 +81,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.debug(`[${requestId}] Help request includes ${images.length} images`)
|
||||
|
||||
const userId = session.user.id
|
||||
// Prepare email content
|
||||
let emailText = `
|
||||
Type: ${type}
|
||||
From: ${email}
|
||||
User ID: ${userId}
|
||||
Workspace ID: ${workspaceId ?? 'N/A'}
|
||||
Workflow ID: ${workflowId ?? 'N/A'}
|
||||
Browser: ${userAgent ?? 'N/A'}
|
||||
|
||||
${message}
|
||||
`
|
||||
@@ -117,6 +115,7 @@ ${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',
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
extractRequiredCredentials,
|
||||
sanitizeCredentials,
|
||||
} from '@/lib/workflows/credentials/credential-extractor'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -190,12 +189,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState: Partial<WorkflowState> = {
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ 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 {
|
||||
type RegenerateStateInput,
|
||||
regenerateWorkflowStateIds,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils'
|
||||
|
||||
const logger = createLogger('TemplateUseAPI')
|
||||
|
||||
@@ -107,10 +104,9 @@ 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
|
||||
? templateState
|
||||
: regenerateWorkflowStateIds(templateState)
|
||||
? templateData.state
|
||||
: regenerateWorkflowStateIds(templateData.state)
|
||||
|
||||
// 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?: Record<string, WorkflowVariable>
|
||||
variables?: WorkflowVariable[]
|
||||
}
|
||||
|
||||
export interface WorkflowExportPayload {
|
||||
@@ -317,44 +317,36 @@ export interface WorkspaceImportResponse {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Parse workflow variables from database JSON format to Record format.
|
||||
* Handles both legacy Array and current Record<string, Variable> formats.
|
||||
* Parse workflow variables from database JSON format to array format.
|
||||
* Handles both array and Record<string, Variable> formats.
|
||||
*/
|
||||
export function parseWorkflowVariables(
|
||||
dbVariables: DbWorkflow['variables']
|
||||
): Record<string, WorkflowVariable> | undefined {
|
||||
): 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)) {
|
||||
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
|
||||
return varsObj.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
}))
|
||||
}
|
||||
|
||||
// Already Record format - normalize and return
|
||||
if (typeof varsObj === 'object' && varsObj !== null) {
|
||||
const result: Record<string, WorkflowVariable> = {}
|
||||
for (const [key, v] of Object.entries(varsObj)) {
|
||||
return Object.values(varsObj).map((v: unknown) => {
|
||||
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
|
||||
result[key] = {
|
||||
return {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
value: variable.value,
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// pass
|
||||
|
||||
@@ -74,6 +74,8 @@ export async function POST(
|
||||
loops: deployedState.loops || {},
|
||||
parallels: deployedState.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: true,
|
||||
deployedAt: new Date(),
|
||||
deploymentStatuses: deployedState.deploymentStatuses || {},
|
||||
})
|
||||
|
||||
@@ -86,6 +88,7 @@ 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,15 +207,9 @@ describe('Workflow Variables API Route', () => {
|
||||
update: { results: [{}] },
|
||||
})
|
||||
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
const variables = [
|
||||
{ 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',
|
||||
@@ -248,15 +242,9 @@ describe('Workflow Variables API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
const variables = [
|
||||
{ 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',
|
||||
@@ -289,6 +277,7 @@ 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,22 +11,16 @@ 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.record(z.string(), VariableSchema),
|
||||
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())]),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -66,12 +60,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
try {
|
||||
const { variables } = VariablesSchema.parse(body)
|
||||
|
||||
// Variables are already in Record format - use directly
|
||||
// Format variables for storage
|
||||
const variablesRecord: Record<string, Variable> = {}
|
||||
variables.forEach((variable) => {
|
||||
variablesRecord[variable.id] = variable
|
||||
})
|
||||
|
||||
// Replace variables completely with the incoming ones
|
||||
// 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,
|
||||
variables: updatedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
@@ -145,9 +148,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
headers,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workflow variables fetch error`, error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,6 +332,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
return (
|
||||
<WorkflowPreview
|
||||
workflowState={template.state}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -106,6 +106,8 @@ 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,
|
||||
@@ -202,6 +204,7 @@ function TemplateCardInner({
|
||||
{normalizedState && isInView ? (
|
||||
<WorkflowPreview
|
||||
workflowState={normalizedState}
|
||||
showSubBlocks={false}
|
||||
height={180}
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
|
||||
@@ -95,12 +95,7 @@ export function ChunkContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -100,12 +100,7 @@ export function DocumentContextMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -99,12 +99,7 @@ export function KnowledgeBaseContextMenu({
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -43,12 +43,7 @@ export function KnowledgeListContextMenu({
|
||||
disableAdd = false,
|
||||
}: KnowledgeListContextMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { SnapshotContextMenu } from './snapshot-context-menu'
|
||||
@@ -1,97 +0,0 @@
|
||||
'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,23 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { Modal, ModalBody, ModalContent, ModalHeader } 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'
|
||||
@@ -71,46 +60,6 @@ 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 {}
|
||||
@@ -148,20 +97,11 @@ export function ExecutionSnapshot({
|
||||
return blockExecutionMap
|
||||
}, [traceSpans])
|
||||
|
||||
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])
|
||||
setPinnedBlockId(null)
|
||||
}, [executionId])
|
||||
|
||||
const workflowState = data?.workflowState as WorkflowState | undefined
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
@@ -229,26 +169,22 @@ export function ExecutionSnapshot({
|
||||
<div
|
||||
style={{ height, width }}
|
||||
className={cn(
|
||||
'flex overflow-hidden',
|
||||
!isModal && 'rounded-[4px] border border-[var(--border)]',
|
||||
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
|
||||
<div className='h-full flex-1'>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.8}
|
||||
onNodeClick={(blockId) => {
|
||||
setPinnedBlockId(blockId)
|
||||
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
|
||||
}}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={() => setPinnedBlockId(null)}
|
||||
cursorStyle='pointer'
|
||||
executedBlocks={blockExecutions}
|
||||
selectedBlockId={pinnedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
|
||||
@@ -257,74 +193,32 @@ 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 overflow-hidden'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
return renderContent()
|
||||
}
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
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 { ChevronDown, Code } 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 {
|
||||
@@ -384,7 +370,7 @@ function SpanContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders input/output section with collapsible content, context menu, and search
|
||||
* Renders input/output section with collapsible content
|
||||
*/
|
||||
function InputOutputSection({
|
||||
label,
|
||||
@@ -405,63 +391,14 @@ 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='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
|
||||
<div
|
||||
className='group flex cursor-pointer items-center justify-between'
|
||||
onClick={() => onToggle(sectionKey)}
|
||||
@@ -496,101 +433,12 @@ function InputOutputSection({
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<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
|
||||
)}
|
||||
</>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -47,12 +47,7 @@ export function LogRowContextMenu({
|
||||
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -92,7 +87,7 @@ export function LogRowContextMenu({
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open Snapshot
|
||||
Open Preview
|
||||
</PopoverItem>
|
||||
|
||||
{/* Filter actions */}
|
||||
|
||||
@@ -109,6 +109,8 @@ 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,
|
||||
@@ -208,6 +210,7 @@ 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
|
||||
* Template data structure with support for both new and legacy fields
|
||||
*/
|
||||
export interface Template {
|
||||
/** Unique identifier for the template */
|
||||
@@ -59,6 +59,16 @@ 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 */
|
||||
@@ -97,6 +107,7 @@ 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()
|
||||
@@ -104,7 +115,7 @@ export default function Templates({
|
||||
return templates.filter((template) => {
|
||||
const tabMatch =
|
||||
activeTab === 'your'
|
||||
? template.creator?.referenceId === currentUserId || template.isStarred
|
||||
? template.userId === currentUserId || template.isStarred
|
||||
: activeTab === 'gallery'
|
||||
? template.status === 'approved'
|
||||
: template.status === 'pending'
|
||||
@@ -113,7 +124,13 @@ export default function Templates({
|
||||
|
||||
if (!query) return true
|
||||
|
||||
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.details?.tagline,
|
||||
template.author,
|
||||
template.creator?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
@@ -124,6 +141,7 @@ export default function Templates({
|
||||
|
||||
/**
|
||||
* Get empty state message based on current filters
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
@@ -217,20 +235,25 @@ export default function Templates({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
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}
|
||||
/>
|
||||
))
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
* ad-hoc ids or shortcuts to ensure a single source of truth.
|
||||
*/
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'goto-templates'
|
||||
| 'goto-logs'
|
||||
@@ -44,11 +43,6 @@ export interface CommandDefinition {
|
||||
* All global commands must be declared here to be usable.
|
||||
*/
|
||||
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
'accept-diff-changes': {
|
||||
id: 'accept-diff-changes',
|
||||
shortcut: 'Mod+Shift+Enter',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'add-agent': {
|
||||
id: 'add-agent',
|
||||
shortcut: 'Mod+Shift+A',
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
useFloatBoundarySync,
|
||||
useFloatDrag,
|
||||
useFloatResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-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,9 +726,7 @@ export function Chat() {
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!isStreaming && !isExecuting) {
|
||||
handleSendMessage()
|
||||
}
|
||||
handleSendMessage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (promptHistory.length > 0) {
|
||||
@@ -751,7 +749,7 @@ export function Chat() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting]
|
||||
[handleSendMessage, promptHistory, historyIndex]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1063,7 +1061,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}
|
||||
disabled={!activeWorkflowId || isExecuting}
|
||||
/>
|
||||
|
||||
{/* Buttons positioned absolutely on the right */}
|
||||
@@ -1093,8 +1091,7 @@ export function Chat() {
|
||||
disabled={
|
||||
(!chatMessage.trim() && chatFiles.length === 0) ||
|
||||
!activeWorkflowId ||
|
||||
isExecuting ||
|
||||
isStreaming
|
||||
isExecuting
|
||||
}
|
||||
className={cn(
|
||||
'h-[22px] w-[22px] rounded-full p-0 transition-colors',
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, Library } from '@/components/emcn'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: Library,
|
||||
icon: LibraryBig,
|
||||
shortcut: 'L',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ export function BlockContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
@@ -118,7 +118,7 @@ export function BlockContextMenu({
|
||||
{getToggleEnabledLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
{!allNoteBlocks && !isSubflow && (
|
||||
{!allNoteBlocks && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function PaneContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -16,20 +15,28 @@ const logger = createLogger('DiffControls')
|
||||
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||
useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const {
|
||||
isShowingDiff,
|
||||
isDiffReady,
|
||||
hasActiveDiff,
|
||||
toggleDiffView,
|
||||
acceptChanges,
|
||||
rejectChanges,
|
||||
baselineWorkflow,
|
||||
} = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
toggleDiffView: state.toggleDiffView,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||
useCallback(
|
||||
@@ -46,6 +53,11 @@ export const DiffControls = memo(function DiffControls() {
|
||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||
)
|
||||
|
||||
const handleToggleDiff = useCallback(() => {
|
||||
logger.info('Toggling diff view', { currentState: isShowingDiff })
|
||||
toggleDiffView()
|
||||
}, [isShowingDiff, toggleDiffView])
|
||||
|
||||
const createCheckpoint = useCallback(async () => {
|
||||
if (!activeWorkflowId || !currentChat?.id) {
|
||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||
@@ -97,7 +109,12 @@ 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', {
|
||||
@@ -194,47 +211,54 @@ export const DiffControls = memo(function DiffControls() {
|
||||
}
|
||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
const handleAccept = useCallback(async () => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
// This happens synchronously first for instant UI feedback
|
||||
try {
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
try {
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
|
||||
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||
createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint after accept:', error)
|
||||
})
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept changes:', error)
|
||||
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error('Workflow update failed:', errorMessage)
|
||||
alert(`Failed to save workflow changes: ${errorMessage}`)
|
||||
}
|
||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
@@ -274,82 +298,54 @@ export const DiffControls = memo(function DiffControls() {
|
||||
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
// Register global command to accept changes (Cmd/Ctrl + Shift + Enter)
|
||||
const acceptCommand = useMemo(
|
||||
() =>
|
||||
createCommand({
|
||||
id: 'accept-diff-changes',
|
||||
handler: () => {
|
||||
if (hasActiveDiff && isDiffReady) {
|
||||
handleAccept()
|
||||
}
|
||||
},
|
||||
}),
|
||||
[hasActiveDiff, isDiffReady, handleAccept]
|
||||
)
|
||||
useRegisterGlobalCommands([acceptCommand])
|
||||
|
||||
// Don't show anything if no diff is available or diff is not ready
|
||||
if (!hasActiveDiff || !isDiffReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isPanelResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'fixed z-30',
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
'-translate-x-1/2 fixed left-1/2 z-30',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
)}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
}}
|
||||
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
|
||||
>
|
||||
<div
|
||||
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
|
||||
style={{ isolation: 'isolate' }}
|
||||
>
|
||||
{/* Reject side */}
|
||||
<button
|
||||
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>
|
||||
{/* Toggle (left, icon-only) */}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleToggleDiff}
|
||||
className='h-[30px] w-[30px] rounded-[8px] p-0'
|
||||
title={isShowingDiff ? 'View original' : 'Preview changes'}
|
||||
>
|
||||
{isShowingDiff ? (
|
||||
<Eye className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<EyeOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Reject */}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleReject}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Reject changes'
|
||||
className='relative flex h-full items-center border border-[var(--border)] bg-[var(--surface-4)] pr-[20px] pl-[12px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
style={{
|
||||
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '4px 0 0 4px',
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
{/* Slanted divider - split gray/green */}
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 z-10'
|
||||
style={{
|
||||
left: '66px',
|
||||
width: '2px',
|
||||
transform: 'skewX(-18.4deg)',
|
||||
background:
|
||||
'linear-gradient(to right, var(--border) 50%, color-mix(in srgb, var(--brand-tertiary-2) 70%, black) 50%)',
|
||||
}}
|
||||
/>
|
||||
{/* Accept side */}
|
||||
<button
|
||||
</Button>
|
||||
|
||||
{/* Accept */}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleAccept}
|
||||
title='Accept changes (⇧⌘⏎)'
|
||||
className='-ml-[10px] relative flex h-full items-center border border-[rgba(0,0,0,0.15)] bg-[var(--brand-tertiary-2)] pr-[12px] pl-[20px] font-medium text-[13px] text-[var(--text-inverse)] transition-[background-color,border-color,fill,stroke] hover:brightness-110 dark:border-[rgba(255,255,255,0.1)]'
|
||||
style={{
|
||||
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
}}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Accept changes'
|
||||
>
|
||||
Accept
|
||||
<kbd className='ml-2 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-medium font-sans text-[10px]'>
|
||||
⇧⌘<span className='translate-y-[-1px]'>⏎</span>
|
||||
</kbd>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
openCopilotWithMessage,
|
||||
useNotificationStore,
|
||||
} from '@/stores/notifications'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -20,7 +19,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
|
||||
|
||||
/**
|
||||
* Notifications display component
|
||||
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
|
||||
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
|
||||
* Shows both global notifications and workflow-specific notifications
|
||||
*/
|
||||
export const Notifications = memo(function Notifications() {
|
||||
@@ -37,7 +36,6 @@ export const Notifications = memo(function Notifications() {
|
||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
|
||||
|
||||
/**
|
||||
* Executes a notification action and handles side effects.
|
||||
@@ -105,14 +103,12 @@ export const Notifications = memo(function Notifications() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isSidebarResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
|
||||
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
|
||||
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
)}
|
||||
>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
|
||||
@@ -3,23 +3,75 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Max height for thinking content before internal scrolling kicks in
|
||||
*/
|
||||
const THINKING_MAX_HEIGHT = 200
|
||||
|
||||
/**
|
||||
* Interval for auto-scroll during streaming (ms)
|
||||
*/
|
||||
const SCROLL_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Timer update interval in milliseconds
|
||||
*/
|
||||
const TIMER_UPDATE_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Milliseconds threshold for displaying as seconds
|
||||
*/
|
||||
const SECONDS_THRESHOLD = 1000
|
||||
|
||||
/**
|
||||
* Props for the ShimmerOverlayText component
|
||||
*/
|
||||
interface ShimmerOverlayTextProps {
|
||||
/** Label text to display */
|
||||
label: string
|
||||
/** Value text to display */
|
||||
value: string
|
||||
/** Whether the shimmer animation is active */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ShimmerOverlayText component for thinking block
|
||||
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Text with optional shimmer overlay effect
|
||||
*/
|
||||
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
|
||||
return (
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{label}</span>
|
||||
<span className='text-[var(--text-muted)]'>{value}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ThinkingBlock component
|
||||
*/
|
||||
@@ -28,19 +80,16 @@ interface ThinkingBlockProps {
|
||||
content: string
|
||||
/** Whether the block is currently streaming */
|
||||
isStreaming?: boolean
|
||||
/** Whether there are more content blocks after this one (e.g., tool calls) */
|
||||
hasFollowingContent?: boolean
|
||||
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
|
||||
label?: string
|
||||
/** Whether special tags (plan, options) are present - triggers collapse */
|
||||
hasSpecialTags?: boolean
|
||||
/** Persisted duration from content block */
|
||||
duration?: number
|
||||
/** Persisted start time from content block */
|
||||
startTime?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingBlock component displays AI reasoning/thinking process
|
||||
* Shows collapsible content with duration timer
|
||||
* Auto-expands during streaming and collapses when complete
|
||||
* Auto-collapses when a tool call or other content comes in after it
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Thinking block with expandable content and timer
|
||||
@@ -48,248 +97,112 @@ interface ThinkingBlockProps {
|
||||
export function ThinkingBlock({
|
||||
content,
|
||||
isStreaming = false,
|
||||
hasFollowingContent = false,
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
duration: persistedDuration,
|
||||
startTime: persistedStartTime,
|
||||
}: ThinkingBlockProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
const [duration, setDuration] = useState(persistedDuration ?? 0)
|
||||
const userCollapsedRef = useRef<boolean>(false)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const startTimeRef = useRef<number>(Date.now())
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const programmaticScrollRef = useRef(false)
|
||||
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
|
||||
|
||||
/**
|
||||
* Updates start time reference when persisted start time changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof persistedStartTime === 'number') {
|
||||
startTimeRef.current = persistedStartTime
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
|
||||
/**
|
||||
* Auto-expands block when streaming with content
|
||||
* Auto-collapses when streaming ends OR when following content arrives
|
||||
* Auto-collapses when streaming ends
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Collapse if streaming ended or if there's following content (like a tool call)
|
||||
if (!isStreaming || hasFollowingContent) {
|
||||
if (!isStreaming) {
|
||||
setIsExpanded(false)
|
||||
userCollapsedRef.current = false
|
||||
setUserHasScrolledAway(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content, hasFollowingContent])
|
||||
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
if (isStreaming && !hasFollowingContent) {
|
||||
startTimeRef.current = Date.now()
|
||||
setDuration(0)
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Update duration timer during streaming (stop when following content arrives)
|
||||
useEffect(() => {
|
||||
// Stop timer if not streaming or if there's following content (thinking is done)
|
||||
if (!isStreaming || hasFollowingContent) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Handle scroll events to detect user scrolling away
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !isExpanded) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (programmaticScrollRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 20
|
||||
|
||||
const delta = scrollTop - lastScrollTopRef.current
|
||||
const movedUp = delta < -2
|
||||
|
||||
if (movedUp && !isNearBottom) {
|
||||
setUserHasScrolledAway(true)
|
||||
}
|
||||
|
||||
// Re-stick if user scrolls back to bottom
|
||||
if (userHasScrolledAway && isNearBottom) {
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
lastScrollTopRef.current = container.scrollTop
|
||||
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [isExpanded, userHasScrolledAway])
|
||||
|
||||
// Smart auto-scroll: only scroll if user hasn't scrolled away
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 50
|
||||
|
||||
if (isNearBottom) {
|
||||
programmaticScrollRef.current = true
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
programmaticScrollRef.current = false
|
||||
}, 150)
|
||||
}
|
||||
}, SCROLL_INTERVAL)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
}, [isStreaming, content])
|
||||
|
||||
/**
|
||||
* Formats duration in milliseconds to seconds
|
||||
* Always shows seconds, rounded to nearest whole second, minimum 1s
|
||||
* Updates duration timer during streaming
|
||||
* Uses persisted duration when available
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof persistedDuration === 'number') {
|
||||
setDuration(persistedDuration)
|
||||
return
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, [isStreaming, persistedDuration])
|
||||
|
||||
/**
|
||||
* Formats duration in milliseconds to human-readable format
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string (e.g., "150ms" or "2.5s")
|
||||
*/
|
||||
const formatDuration = (ms: number) => {
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
if (ms < SECONDS_THRESHOLD) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = content && content.trim().length > 0
|
||||
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
||||
return lbl
|
||||
}
|
||||
const streamingLabel = getStreamingLabel(label)
|
||||
|
||||
// During streaming: show header with shimmer effect + expanded content
|
||||
if (!isThinkingDone) {
|
||||
return (
|
||||
<div>
|
||||
{/* Define shimmer keyframes */}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
if (!next) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{streamingLabel}</span>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{streamingLabel}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Render markdown during streaming with thinking text styling */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After done: show collapsible header with duration
|
||||
return (
|
||||
<div>
|
||||
<div className='mt-1 mb-0'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => !v)
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
// If user collapses during streaming, remember to not auto-expand again
|
||||
if (!next && isStreaming) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
className='mb-1 inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
|
||||
<ShimmerOverlayText
|
||||
label='Thought'
|
||||
value={` for ${formatDuration(duration)}`}
|
||||
active={isStreaming}
|
||||
/>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Use markdown renderer for completed content */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
|
||||
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ 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 {
|
||||
@@ -17,7 +15,7 @@ function roundUpToNearest50(value: number): number {
|
||||
}
|
||||
|
||||
export function UsageLimitActions() {
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const updateUsageLimitMutation = useUpdateUsageLimit()
|
||||
|
||||
const subscription = subscriptionData?.data
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { type FC, memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import {
|
||||
OptionsSelector,
|
||||
parseSpecialTags,
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
FileAttachmentDisplay,
|
||||
SmoothStreamingText,
|
||||
@@ -19,6 +15,8 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
|
||||
import {
|
||||
useCheckpointManagement,
|
||||
useMessageEditing,
|
||||
useMessageFeedback,
|
||||
useSuccessTimers,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
@@ -42,8 +40,6 @@ interface CopilotMessageProps {
|
||||
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
|
||||
/** Callback when revert mode changes */
|
||||
onRevertModeChange?: (isReverting: boolean) => void
|
||||
/** Whether this is the last message in the conversation */
|
||||
isLastMessage?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +59,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
checkpointCount = 0,
|
||||
onEditModeChange,
|
||||
onRevertModeChange,
|
||||
isLastMessage = false,
|
||||
}) => {
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
@@ -93,6 +88,22 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
// Success timers hook
|
||||
const {
|
||||
showCopySuccess,
|
||||
showUpvoteSuccess,
|
||||
showDownvoteSuccess,
|
||||
handleCopy,
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
} = useSuccessTimers()
|
||||
|
||||
// Message feedback hook
|
||||
const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, {
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
})
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
@@ -142,6 +153,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
pendingEditRef,
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles copying message content to clipboard
|
||||
* Uses the success timer hook to show feedback
|
||||
*/
|
||||
const handleCopyContent = () => {
|
||||
handleCopy(message.content)
|
||||
}
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
@@ -150,42 +169,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||
}, [message.content])
|
||||
|
||||
// Parse special tags from message content (options, plan)
|
||||
// Parse during streaming to show options/plan as they stream in
|
||||
const parsedTags = useMemo(() => {
|
||||
if (isUser) return null
|
||||
|
||||
// Try message.content first
|
||||
if (message.content) {
|
||||
const parsed = parseSpecialTags(message.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
|
||||
// During streaming, check content blocks for options/plan
|
||||
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
|
||||
for (const block of message.contentBlocks) {
|
||||
if (block.type === 'text' && block.content) {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||
|
||||
// Get sendMessage from store for continuation actions
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
// Handler for option selection
|
||||
const handleOptionSelect = useCallback(
|
||||
(_optionKey: string, optionText: string) => {
|
||||
// Send the option text as a message
|
||||
sendMessage(optionText)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||
@@ -196,12 +179,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (block.type === 'text') {
|
||||
const isLastTextBlock =
|
||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||
// Always strip special tags from display (they're rendered separately as options/plan)
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Skip if no content after stripping tags
|
||||
if (!cleanBlockContent.trim()) return null
|
||||
// Clean content for this text block
|
||||
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Use smooth streaming for the last text block if we're streaming
|
||||
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||
@@ -222,14 +201,19 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)
|
||||
}
|
||||
if (block.type === 'thinking') {
|
||||
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||
const isLastBlock = index === message.contentBlocks!.length - 1
|
||||
// Consider the thinking block streaming if the overall message is streaming
|
||||
// and the block has not been finalized with a duration yet. This avoids
|
||||
// freezing the timer when new blocks are appended after the thinking block.
|
||||
const isStreamingThinking = isStreaming && (block as any).duration == null
|
||||
|
||||
return (
|
||||
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
||||
<ThinkingBlock
|
||||
content={block.content}
|
||||
isStreaming={isStreaming}
|
||||
hasFollowingContent={hasFollowingContent}
|
||||
isStreaming={isStreamingThinking}
|
||||
duration={block.duration}
|
||||
startTime={block.startTime}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -483,11 +467,53 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
<div className='mt-3 flex gap-1.5'>
|
||||
<UsageLimitActions />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for completed messages */}
|
||||
{!isStreaming && cleanTextContent && (
|
||||
<div className='flex items-center gap-[8px] pt-[8px]'>
|
||||
<Button
|
||||
onClick={handleCopyContent}
|
||||
variant='ghost'
|
||||
title='Copy'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpvote}
|
||||
variant='ghost'
|
||||
title='Upvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showUpvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsUp className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownvote}
|
||||
variant='ghost'
|
||||
title='Downvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showDownvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsDown className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Citations if available */}
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className='pt-1'>
|
||||
@@ -507,20 +533,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options selector when agent presents choices - streams in but disabled until complete */}
|
||||
{/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */}
|
||||
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
|
||||
<OptionsSelector
|
||||
options={parsedTags.options}
|
||||
onSelect={handleOptionSelect}
|
||||
disabled={!isLastMessage || isSendingMessage || isStreaming}
|
||||
enableKeyboardNav={
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -558,11 +570,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// If isLastMessage changed, re-render (for options visibility)
|
||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming messages, check if content actually changed
|
||||
if (nextProps.isStreaming) {
|
||||
const prevBlocks = prevMessage.contentBlocks || []
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ArrowUp, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
|
||||
/**
|
||||
* Displays queued messages in a Cursor-style collapsible panel above the input box.
|
||||
*/
|
||||
export function QueuedMessages() {
|
||||
const messageQueue = useCopilotStore((s) => s.messageQueue)
|
||||
const removeFromQueue = useCopilotStore((s) => s.removeFromQueue)
|
||||
const sendNow = useCopilotStore((s) => s.sendNow)
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
removeFromQueue(id)
|
||||
},
|
||||
[removeFromQueue]
|
||||
)
|
||||
|
||||
const handleSendNow = useCallback(
|
||||
async (id: string) => {
|
||||
await sendNow(id)
|
||||
},
|
||||
[sendNow]
|
||||
)
|
||||
|
||||
if (messageQueue.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='mx-2 overflow-hidden rounded-t-lg border border-black/[0.08] border-b-0 bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
|
||||
{/* Header */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='font-medium text-[var(--text-secondary)] text-xs'>
|
||||
{messageQueue.length} Queued
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Message list */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{messageQueue.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className='group flex items-center gap-2 border-black/[0.04] border-t px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
|
||||
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[var(--text-primary)] text-xs'>{msg.content}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions - always visible */}
|
||||
<div className='flex shrink-0 items-center gap-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSendNow(msg.id)
|
||||
}}
|
||||
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
|
||||
title='Send now (aborts current stream)'
|
||||
>
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemove(msg.id)
|
||||
}}
|
||||
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
|
||||
title='Remove from queue'
|
||||
>
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
|
||||
interface ContextUsageIndicatorProps {
|
||||
/** Usage percentage (0-100) */
|
||||
percentage: number
|
||||
/** Size of the indicator in pixels */
|
||||
size?: number
|
||||
/** Stroke width in pixels */
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular context usage indicator showing percentage of context window used.
|
||||
* Displays a progress ring that changes color based on usage level.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered context usage indicator
|
||||
*/
|
||||
export function ContextUsageIndicator({
|
||||
percentage,
|
||||
size = 20,
|
||||
strokeWidth = 2,
|
||||
}: ContextUsageIndicatorProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (percentage / 100) * circumference
|
||||
|
||||
const color = useMemo(() => {
|
||||
if (percentage >= 90) return 'var(--text-error)'
|
||||
if (percentage >= 75) return 'var(--warning)'
|
||||
return 'var(--text-muted)'
|
||||
}, [percentage])
|
||||
|
||||
const displayPercentage = useMemo(() => {
|
||||
return Math.round(percentage)
|
||||
}, [percentage])
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-center transition-opacity hover:opacity-80'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className='rotate-[-90deg]'>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke='currentColor'
|
||||
strokeWidth={strokeWidth}
|
||||
fill='none'
|
||||
className='text-muted-foreground/20'
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill='none'
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className='transition-all duration-300 ease-in-out'
|
||||
strokeLinecap='round'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{displayPercentage}% context used</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
|
||||
@@ -178,12 +178,11 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
|
||||
/**
|
||||
* Opens file picker dialog
|
||||
* Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message
|
||||
*/
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (disabled) return
|
||||
if (disabled || isLoading) return
|
||||
fileInputRef.current?.click()
|
||||
}, [disabled])
|
||||
}, [disabled, isLoading])
|
||||
|
||||
/**
|
||||
* Handles file input change event
|
||||
|
||||
@@ -117,6 +117,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const selectedModel =
|
||||
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
||||
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
||||
const contextUsage = copilotStore.contextUsage
|
||||
|
||||
// Internal state
|
||||
const [internalMessage, setInternalMessage] = useState('')
|
||||
@@ -299,8 +300,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||
const targetMessage = overrideMessage ?? message
|
||||
const trimmedMessage = targetMessage.trim()
|
||||
// Allow submission even when isLoading - store will queue the message
|
||||
if (!trimmedMessage || disabled) return
|
||||
if (!trimmedMessage || disabled || isLoading) return
|
||||
|
||||
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
|
||||
if (failedUploads.length > 0) {
|
||||
@@ -746,7 +746,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
(disabled || isLoading) && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||
@@ -802,7 +802,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
ref={fileAttachments.fileInputRef}
|
||||
type='file'
|
||||
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
className='hidden'
|
||||
accept='image/*'
|
||||
multiple
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
TodoList,
|
||||
UserInput,
|
||||
Welcome,
|
||||
@@ -101,6 +99,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
loadChats,
|
||||
messageCheckpoints,
|
||||
currentChat,
|
||||
fetchContextUsage,
|
||||
selectChat,
|
||||
deleteChat,
|
||||
areChatsFresh,
|
||||
@@ -119,6 +118,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -298,8 +298,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||
// Allow submission even when isSendingMessage - store will queue the message
|
||||
if (!query || !activeWorkflowId) return
|
||||
if (!query || isSendingMessage || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
const store = useCopilotStore.getState()
|
||||
@@ -317,7 +316,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
logger.error('Failed to send message:', error)
|
||||
}
|
||||
},
|
||||
[activeWorkflowId, sendMessage, showPlanTodos]
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -444,13 +443,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{chat.title || 'New Chat'}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center gap-[4px]',
|
||||
currentChat?.id !== chat.id &&
|
||||
'opacity-0 transition-opacity group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
@@ -570,7 +563,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
onRevertModeChange={(isReverting) =>
|
||||
handleRevertModeChange(message.id, isReverting)
|
||||
}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -596,9 +588,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queued messages (shown when messages are waiting) */}
|
||||
<QueuedMessages />
|
||||
|
||||
{/* Input area with integrated mode selector */}
|
||||
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
|
||||
<UserInput
|
||||
|
||||
@@ -11,6 +11,7 @@ interface UseCopilotInitializationProps {
|
||||
chatsLoadedForWorkflow: string | null
|
||||
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
||||
loadChats: (forceRefresh?: boolean) => Promise<void>
|
||||
fetchContextUsage: () => Promise<void>
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
currentChat: any
|
||||
isSendingMessage: boolean
|
||||
@@ -29,6 +30,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -100,6 +102,18 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
isSendingMessage,
|
||||
])
|
||||
|
||||
/**
|
||||
* Fetch context usage when component is initialized and has a current chat
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isInitialized && currentChat?.id && activeWorkflowId) {
|
||||
logger.info('[Copilot] Component initialized, fetching context usage')
|
||||
fetchContextUsage().catch((err) => {
|
||||
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
|
||||
})
|
||||
}
|
||||
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
|
||||
|
||||
/**
|
||||
* Load auto-allowed tools once on mount
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ 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'
|
||||
@@ -58,7 +57,6 @@ 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)
|
||||
|
||||
@@ -133,19 +131,6 @@ 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]'>
|
||||
@@ -201,7 +186,7 @@ export function GeneralDeploy({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='relative h-[260px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
|
||||
onWheelCapture={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) return
|
||||
e.stopPropagation()
|
||||
@@ -209,28 +194,28 @@ export function GeneralDeploy({
|
||||
>
|
||||
{workflowToShow ? (
|
||||
<>
|
||||
<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>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowToShow}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
defaultZoom={0.6}
|
||||
/>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setShowExpandedPreview(true)}
|
||||
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)]'
|
||||
className='absolute top-[8px] right-[8px] z-10'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>See preview</Tooltip.Content>
|
||||
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
) : (
|
||||
@@ -331,23 +316,21 @@ 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(blockId)
|
||||
setExpandedSelectedBlockId(
|
||||
expandedSelectedBlockId === blockId ? null : blockId
|
||||
)
|
||||
}}
|
||||
onPaneClick={() => setExpandedSelectedBlockId(null)}
|
||||
selectedBlockId={expandedSelectedBlockId}
|
||||
lightweight
|
||||
cursorStyle='pointer'
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
<BlockDetailsSidebar
|
||||
block={workflowToShow.blocks[expandedSelectedBlockId]}
|
||||
workflowVariables={workflowToShow.variables}
|
||||
loops={workflowToShow.loops}
|
||||
parallels={workflowToShow.parallels}
|
||||
onClose={() => setExpandedSelectedBlockId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -488,6 +488,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
>
|
||||
<WorkflowPreview
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={false}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={false}
|
||||
@@ -528,6 +529,7 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
|
||||
<WorkflowPreview
|
||||
key={`template-preview-${existingTemplate.id}`}
|
||||
workflowState={workflowState}
|
||||
showSubBlocks={true}
|
||||
height='100%'
|
||||
width='100%'
|
||||
isPannable={true}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -27,7 +26,6 @@ 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
|
||||
@@ -56,7 +54,7 @@ export function CredentialSelector({
|
||||
const supportsCredentialSets = subBlock.supportsCredentialSets || false
|
||||
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise
|
||||
|
||||
@@ -19,9 +19,7 @@ export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps)
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as
|
||||
| string
|
||||
| undefined
|
||||
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
|
||||
|
||||
const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, {
|
||||
enabled: !isPreview,
|
||||
|
||||
@@ -673,7 +673,7 @@ function WorkflowInputMapperSyncWrapper({
|
||||
|
||||
if (!workflowId) {
|
||||
return (
|
||||
<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'>
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 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-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-8'>
|
||||
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputFields.length === 0) {
|
||||
return (
|
||||
<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'>
|
||||
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
|
||||
This workflow has no custom input fields
|
||||
</div>
|
||||
)
|
||||
@@ -902,22 +902,7 @@ 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 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 { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
|
||||
const {
|
||||
mcpTools,
|
||||
@@ -933,15 +918,24 @@ 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, isPreview])
|
||||
}, [hasMcpTools, forceRefreshMcpTools, workspaceId])
|
||||
|
||||
/**
|
||||
* Returns issue info for an MCP tool.
|
||||
|
||||
@@ -43,12 +43,10 @@ export function TriggerSave({
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
|
||||
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as
|
||||
| string
|
||||
| null
|
||||
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
|
||||
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, Settings } from 'lucide-react'
|
||||
import { BookOpen, Check, ChevronUp, Pencil, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -15,8 +15,6 @@ 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'
|
||||
@@ -60,8 +58,9 @@ export function Editor() {
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
// Get subflow display properties
|
||||
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
|
||||
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
@@ -177,9 +176,8 @@ export function Editor() {
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
if (blockConfig?.docsLink) {
|
||||
window.open(blockConfig.docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +195,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 ? subflowConfig?.bgColor : blockConfig?.bgColor }}
|
||||
style={{ background: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
|
||||
>
|
||||
<IconComponent
|
||||
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
|
||||
icon={isSubflow ? subflowIcon : blockConfig?.icon}
|
||||
className='h-[12px] w-[12px] text-[var(--white)]'
|
||||
/>
|
||||
</div>
|
||||
@@ -297,7 +295,7 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
{currentBlock && !isSubflow && blockConfig?.docsLink && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
// Create icon container
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.cssText = `
|
||||
width: 24px;
|
||||
@@ -44,6 +45,7 @@ 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'
|
||||
@@ -53,10 +55,11 @@ 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: var(--text-primary);
|
||||
color: #FFFFFF;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { createDragPreview, type DragItemInfo } from './drag-preview'
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'
|
||||
@@ -1,88 +0,0 @@
|
||||
'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,7 +17,6 @@ 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,
|
||||
@@ -35,7 +34,6 @@ interface BlockItem {
|
||||
config?: BlockConfig
|
||||
icon?: any
|
||||
bgColor?: string
|
||||
docsLink?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +98,6 @@ function getBlocks() {
|
||||
type: LoopTool.type,
|
||||
icon: LoopTool.icon,
|
||||
bgColor: LoopTool.bgColor,
|
||||
docsLink: LoopTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -109,7 +106,6 @@ function getBlocks() {
|
||||
type: ParallelTool.type,
|
||||
icon: ParallelTool.icon,
|
||||
bgColor: ParallelTool.bgColor,
|
||||
docsLink: ParallelTool.docsLink,
|
||||
isSpecial: true,
|
||||
})
|
||||
|
||||
@@ -182,16 +178,6 @@ 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()
|
||||
@@ -352,68 +338,6 @@ 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:
|
||||
@@ -629,9 +553,6 @@ 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)]',
|
||||
@@ -721,14 +642,6 @@ 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)]',
|
||||
@@ -772,17 +685,6 @@ 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,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
|
||||
@@ -10,14 +10,15 @@ import { usePanelStore } from '@/stores/panel/store'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function usePanelResize() {
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||
const { setPanelWidth } = usePanelStore()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
*/
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsResizing(true)
|
||||
}, [setIsResizing])
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Setup resize event listeners and body styles when resizing
|
||||
@@ -51,7 +52,7 @@ export function usePanelResize() {
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isResizing, setPanelWidth, setIsResizing])
|
||||
}, [isResizing, setPanelWidth])
|
||||
|
||||
return {
|
||||
isResizing,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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.
|
||||
@@ -15,7 +12,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({ enabled: isBillingEnabled })
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
|
||||
const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false
|
||||
|
||||
|
||||
@@ -9,5 +9,4 @@ export const LoopTool = {
|
||||
name: 'Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
docsLink: 'https://docs.sim.ai/blocks/loop',
|
||||
} as const
|
||||
|
||||
@@ -9,5 +9,4 @@ export const ParallelTool = {
|
||||
name: 'Parallel',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
docsLink: 'https://docs.sim.ai/blocks/parallel',
|
||||
} as const
|
||||
|
||||
@@ -47,8 +47,6 @@ export interface SubflowNodeData {
|
||||
parentId?: string
|
||||
extent?: 'parent'
|
||||
isPreview?: boolean
|
||||
/** Whether this subflow is selected in preview mode */
|
||||
isPreviewSelected?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
}
|
||||
@@ -125,18 +123,16 @@ 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) or preview selected - blue ring
|
||||
* 1. Focused (selected in editor) - blue ring
|
||||
* 2. Diff status (version comparison) - green/orange ring
|
||||
*/
|
||||
const hasRing = isFocused || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const hasRing = isFocused || diffStatus === 'new' || diffStatus === 'edited'
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
|
||||
isFocused && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ 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
|
||||
@@ -51,7 +50,6 @@ export function LogRowContextMenu({
|
||||
onFilterByBlock,
|
||||
onFilterByStatus,
|
||||
onFilterByRunId,
|
||||
onCopyRunId,
|
||||
onClearFilters,
|
||||
onClearConsole,
|
||||
hasActiveFilters,
|
||||
@@ -66,7 +64,7 @@ export function LogRowContextMenu({
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
@@ -81,18 +79,18 @@ export function LogRowContextMenu({
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{/* Copy actions */}
|
||||
{entry && hasRunId && (
|
||||
{/* Clear filters at top when active */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onCopyRunId(entry.executionId!)
|
||||
onClearFilters()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Copy Run ID
|
||||
Clear All Filters
|
||||
</PopoverItem>
|
||||
<PopoverDivider />
|
||||
{entry && <PopoverDivider />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -131,18 +129,6 @@ 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={(open) => !open && onClose()}
|
||||
onOpenChange={onClose}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
|
||||
@@ -49,7 +49,6 @@ 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'
|
||||
@@ -306,7 +305,6 @@ export function Terminal() {
|
||||
const terminalRef = useRef<HTMLElement>(null)
|
||||
const prevEntriesLengthRef = useRef(0)
|
||||
const prevWorkflowEntriesLengthRef = useRef(0)
|
||||
const isTerminalFocusedRef = useRef(false)
|
||||
const {
|
||||
setTerminalHeight,
|
||||
lastExpandedHeight,
|
||||
@@ -339,34 +337,27 @@ 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,
|
||||
@@ -379,10 +370,12 @@ 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,
|
||||
@@ -541,11 +534,8 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle row click - toggle if clicking same entry
|
||||
* Disables auto-selection when user manually selects, re-enables when deselecting
|
||||
* Also focuses the terminal to enable keyboard navigation
|
||||
*/
|
||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||
// Focus the terminal to enable keyboard navigation
|
||||
terminalRef.current?.focus()
|
||||
setSelectedEntry((prev) => {
|
||||
const isDeselecting = prev?.id === entry.id
|
||||
setAutoSelectEnabled(isDeselecting)
|
||||
@@ -566,26 +556,6 @@ export function Terminal() {
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal focus - enables keyboard navigation
|
||||
*/
|
||||
const handleTerminalFocus = useCallback(() => {
|
||||
isTerminalFocusedRef.current = true
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal blur - disables keyboard navigation
|
||||
*/
|
||||
const handleTerminalBlur = useCallback((e: React.FocusEvent) => {
|
||||
// Only blur if focus is moving outside the terminal
|
||||
if (!terminalRef.current?.contains(e.relatedTarget as Node)) {
|
||||
isTerminalFocusedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle copy output to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!selectedEntry) return
|
||||
|
||||
@@ -607,6 +577,44 @@ 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()
|
||||
@@ -675,14 +683,6 @@ export function Terminal() {
|
||||
[toggleRunId, closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleCopyRunId = useCallback(
|
||||
(runId: string) => {
|
||||
navigator.clipboard.writeText(runId)
|
||||
closeLogRowMenu()
|
||||
},
|
||||
[closeLogRowMenu]
|
||||
)
|
||||
|
||||
const handleClearConsoleFromMenu = useCallback(() => {
|
||||
clearCurrentWorkflowConsole()
|
||||
}, [clearCurrentWorkflowConsole])
|
||||
@@ -816,12 +816,9 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation through logs
|
||||
* Disables auto-selection when user manually navigates
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
if (isEventFromEditableElement(e)) return
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const toolbarRoot = document.querySelector(
|
||||
@@ -856,12 +853,9 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation for input/output toggle
|
||||
* Left arrow shows output, right arrow shows input
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
// Ignore when typing/navigating inside editable inputs/editors
|
||||
if (isEventFromEditableElement(e)) return
|
||||
|
||||
@@ -891,20 +885,66 @@ export function Terminal() {
|
||||
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
|
||||
|
||||
/**
|
||||
* Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures)
|
||||
* Handle Escape to close search or unselect entry
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isOutputSearchActive && selectedEntry) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setSelectedEntry(null)
|
||||
setAutoSelectEnabled(true)
|
||||
// First close search if active
|
||||
if (isOutputSearchActive) {
|
||||
closeOutputSearch()
|
||||
return
|
||||
}
|
||||
// Then unselect entry
|
||||
if (selectedEntry) {
|
||||
setSelectedEntry(null)
|
||||
setAutoSelectEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEntry, isOutputSearchActive])
|
||||
}, [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])
|
||||
|
||||
/**
|
||||
* Adjust output panel width when sidebar or panel width changes.
|
||||
@@ -966,9 +1006,6 @@ export function Terminal() {
|
||||
isToggling && 'transition-[height] duration-100 ease-out'
|
||||
)}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
onFocus={handleTerminalFocus}
|
||||
onBlur={handleTerminalBlur}
|
||||
tabIndex={-1}
|
||||
aria-label='Terminal'
|
||||
>
|
||||
<div className='relative flex h-full border-[var(--border)] border-t'>
|
||||
@@ -1377,16 +1414,25 @@ export function Terminal() {
|
||||
</div>
|
||||
|
||||
{/* Run ID */}
|
||||
<span
|
||||
className={clsx(
|
||||
COLUMN_WIDTHS.RUN_ID,
|
||||
COLUMN_BASE_CLASS,
|
||||
'truncate font-medium font-mono text-[12px]'
|
||||
<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>
|
||||
)}
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(entry.executionId)}
|
||||
</span>
|
||||
</Tooltip.Root>
|
||||
|
||||
{/* Duration */}
|
||||
<span
|
||||
@@ -1443,7 +1489,9 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
!showInput &&
|
||||
hasInputData &&
|
||||
'!text-[var(--text-primary)] dark:!text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1461,7 +1509,7 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
showInput && '!text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1791,7 +1839,6 @@ 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,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
@@ -121,7 +121,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
collaborativeToggleBlockEnabled(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,6 +161,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: { 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>
|
||||
@@ -169,7 +192,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
collaborativeToggleBlockHandles(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)]'
|
||||
@@ -188,29 +211,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: { 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,11 +54,9 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!activeWorkflowId) return 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
|
||||
return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider?.value as
|
||||
| string
|
||||
| undefined
|
||||
},
|
||||
[activeWorkflowId, blockId]
|
||||
)
|
||||
|
||||
@@ -10,8 +10,6 @@ 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,7 +32,6 @@ 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 &&
|
||||
|
||||
@@ -199,9 +199,8 @@ const tryParseJson = (value: unknown): unknown => {
|
||||
|
||||
/**
|
||||
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
||||
* Used by both the canvas workflow blocks and copilot edit summaries.
|
||||
*/
|
||||
export const getDisplayValue = (value: unknown): string => {
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
// Try parsing JSON strings first
|
||||
@@ -625,11 +624,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
if (!activeWorkflowId) return
|
||||
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
|
||||
if (!current) return
|
||||
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)
|
||||
const cred = current.credential?.value as string | undefined
|
||||
if (prevCredRef.current !== cred) {
|
||||
prevCredRef.current = cred
|
||||
const keys = Object.keys(current)
|
||||
|
||||
@@ -40,7 +40,10 @@ 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,
|
||||
@@ -93,10 +96,9 @@ const WorkflowEdgeComponent = ({
|
||||
} else if (isErrorEdge) {
|
||||
color = 'var(--text-error)'
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary-2)'
|
||||
color = 'var(--brand-tertiary)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
color = 'var(--border-success)'
|
||||
} else if (edgeRunStatus === 'error') {
|
||||
color = 'var(--text-error)'
|
||||
}
|
||||
@@ -118,18 +120,34 @@ const WorkflowEdgeComponent = ({
|
||||
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
|
||||
opacity,
|
||||
}
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
|
||||
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
|
||||
<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'
|
||||
/>
|
||||
|
||||
{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,17 +1,8 @@
|
||||
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,15 +21,14 @@ interface UseBlockVisualProps {
|
||||
|
||||
/**
|
||||
* Provides visual state and interaction handlers for workflow blocks.
|
||||
* Computes ring styling based on execution, diff, deletion, and run path states.
|
||||
* In preview mode, uses isPreviewSelected for selection highlighting.
|
||||
* Computes ring styling based on execution, focus, diff, and run path states.
|
||||
* In preview mode, all interactive and execution-related visual states are disabled.
|
||||
*
|
||||
* @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)
|
||||
@@ -41,13 +40,14 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
isDeletedBlock,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
// In preview mode, use isPreviewSelected for selection state
|
||||
const isActive = isPreview ? isPreviewSelected : blockIsActive
|
||||
const isActive = isPreview ? false : 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, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
||||
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface CurrentWorkflow {
|
||||
loops: Record<string, Loop>
|
||||
parallels: Record<string, Parallel>
|
||||
lastSaved?: number
|
||||
isDeployed?: boolean
|
||||
deployedAt?: Date
|
||||
deploymentStatuses?: Record<string, DeploymentStatus>
|
||||
needsRedeployment?: boolean
|
||||
|
||||
@@ -48,6 +50,8 @@ 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,
|
||||
}))
|
||||
@@ -78,6 +82,8 @@ 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,7 +3,6 @@ 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')
|
||||
|
||||
@@ -209,30 +208,28 @@ 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, skipClamping?: boolean): { x: number; y: number } => {
|
||||
(nodeId: string, newParentId: string): { 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,
|
||||
}
|
||||
|
||||
if (skipClamping) {
|
||||
return rawPosition
|
||||
}
|
||||
|
||||
const parentNode = getNodes().find((n) => n.id === newParentId)
|
||||
// Get container and block dimensions
|
||||
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]
|
||||
@@ -301,12 +298,12 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const calculateLoopDimensions = useCallback(
|
||||
(nodeId: string): { width: number; height: number } => {
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const childBlockIds = Object.keys(currentBlocks).filter(
|
||||
(id) => currentBlocks[id]?.data?.parentId === nodeId
|
||||
// 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
|
||||
)
|
||||
|
||||
if (childBlockIds.length === 0) {
|
||||
if (childNodes.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
@@ -316,28 +313,30 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
[getBlockDimensions]
|
||||
[getNodes, getBlockDimensions, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -346,27 +345,29 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const resizeLoopNodes = useCallback(
|
||||
(updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => {
|
||||
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),
|
||||
const containerNodes = getNodes()
|
||||
.filter((node) => node.type && isContainerType(node.type))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
depth: getNodeDepth(node.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)
|
||||
|
||||
for (const { id, block } of containerBlocks) {
|
||||
const dimensions = calculateLoopDimensions(id)
|
||||
const currentWidth = block?.data?.width
|
||||
const currentHeight = block?.data?.height
|
||||
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
|
||||
|
||||
// Only update if dimensions actually changed to avoid unnecessary re-renders
|
||||
if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) {
|
||||
updateNodeDimensions(id, dimensions)
|
||||
updateNodeDimensions(node.id, dimensions)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[isContainerType, getNodeDepth, calculateLoopDimensions]
|
||||
[getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,6 +117,7 @@ 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,64 +7,66 @@ 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, diff, deletion, and run-path states.
|
||||
* based on execution, focus, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
||||
options
|
||||
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = 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 &&
|
||||
!isPreviewSelection &&
|
||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||
isActive && '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)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// 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)]',
|
||||
// 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' &&
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user