Compare commits

..

22 Commits

Author SHA1 Message Date
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
271 changed files with 4759 additions and 14446 deletions

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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

View File

@@ -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({

View File

@@ -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

View 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 }
)
}
}

View 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 })
}
}

View File

@@ -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',

View File

@@ -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(),
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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', {

View File

@@ -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 })
}
}

View File

@@ -332,6 +332,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
return (
<WorkflowPreview
workflowState={template.state}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}

View File

@@ -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}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -1 +0,0 @@
export { SnapshotContextMenu } from './snapshot-context-menu'

View File

@@ -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
)
}

View File

@@ -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()
}

View File

@@ -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>
)

View File

@@ -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 */}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',
},
{

View File

@@ -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={() => {

View File

@@ -38,7 +38,7 @@ export function PaneContextMenu({
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'

View File

@@ -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>
)

View File

@@ -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) => {

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 || []

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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)}
/>
)}

View File

@@ -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}

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -1,2 +1 @@
export { createDragPreview, type DragItemInfo } from './drag-preview'
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'

View File

@@ -1 +0,0 @@
export { ToolbarItemContextMenu } from './toolbar-item-context-menu'

View File

@@ -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>
)
}

View File

@@ -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>
)
})

View File

@@ -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,

View File

@@ -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

View File

@@ -9,5 +9,4 @@ export const LoopTool = {
name: 'Loop',
icon: RepeatIcon,
bgColor: '#2FB3FF',
docsLink: 'https://docs.sim.ai/blocks/loop',
} as const

View File

@@ -9,5 +9,4 @@ export const ParallelTool = {
name: 'Parallel',
icon: SplitIcon,
bgColor: '#FEE12B',
docsLink: 'https://docs.sim.ai/blocks/parallel',
} as const

View File

@@ -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)]'
)

View File

@@ -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

View File

@@ -52,7 +52,7 @@ export function OutputContextMenu({
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'

View File

@@ -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()

View File

@@ -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

View File

@@ -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]
)

View File

@@ -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
}

View File

@@ -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 &&

View File

@@ -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)

View File

@@ -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,

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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]
)
/**

View File

@@ -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) => {

View File

@@ -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' &&

View File

@@ -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,
}
})
}

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