Compare commits

...

45 Commits

Author SHA1 Message Date
Emir Karabeg
1f6f58cf7f improvement(copilot): diff controls 2026-01-09 20:17:49 -08:00
Emir Karabeg
41d767e170 improvement(copilot): ui/ux 2026-01-09 18:56:57 -08:00
Siddharth Ganesan
c5dc78ff08 Enable images 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
ae6e29512a Previous options should not be selectable 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
6639871c92 Fix lint 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
0ba5ec65f7 Diff view 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
8597786962 Persist and load chats properly 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
1aada6ba57 Fix thinking text 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
a9edbd71f1 Renaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
bd35dda8fa Fix thinking scroll 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
b2b06c3dd1 Streaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
29eefd8416 Plan 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
51b2297e35 Fix previews 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
aa8da99ce2 Subagent rendering 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
2b2ed6df1a Fix spacing between options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
7305ecf4fa Fixes to options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
d2ef972bbb Options select 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
90c875b895 Editor component 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4280461cb8 Add evaluator subagent 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
3be426af8e Add deploy mcp tools 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
8fcb0349d2 Fix rendering of edit subblocks 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
96dc2b7afd Lint 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
e95b6135ac Options 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
2fe0afaef4 Diff view in chat 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
d86e43945f Remove context usage code 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4fd5656c01 Diff in chat 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
54f6047dd3 Overlays 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
86f8e77293 Trigger request 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
05034edc83 Fix bugs 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
7a925ad45c Many subagents 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
442d8a1f45 Message queue 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
56366088d8 Tweaks 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
f6cd0cbc55 Edit, plan, debug subagents 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
df80309c3b Add subagents 2026-01-09 18:56:29 -08:00
Adam Gough
fd76e98f0e improvement(wand): added more wands (#2756)
* added wand configs

* fixed greptile comments
2026-01-09 18:41:51 -08:00
Waleed
1dbd16115f feat(sidebar): context menu for nav items in sidebar, toolbar blocks, added missing docs for various blocks and triggers (#2754)
* feat(sidebar): context menu for nav items in sidebar

* added toolbar context menu, fixed incorrect access pattern in old context menus and added docs for missing blocks

* fixed links
2026-01-09 17:50:10 -08:00
Vikhyath Mondreti
38e827b61a fix(docs): new router (#2755)
* fix(docs): new router

* update image
2026-01-09 17:37:04 -08:00
Waleed
1f5e8a41f8 fix(tools): fixed workflow tool for agent to respect user provided params, inject at runtime like all other tools (#2750)
* fix(tools): fixed wrokflow tool for agent to respect user provided params, inject at runtime like all other tools

* ack comments

* remove redunant if-else

* added tests
2026-01-09 17:12:58 -08:00
Adam Gough
796f73ee01 improvement(google-drive) (#2752)
* expanded metadata fields for google drive

* added tag dropdown support

* fixed greptile

* added utils func

* removed comments

* updated docs

* greptile comments

* fixed output schema

* reverted back to bas64 string
2026-01-09 16:56:07 -08:00
Waleed
d3d6012d5c fix(tools): updated memory block to throw better errors, removed deprecated posthog route, remove deprecated templates & console helpers (#2753)
* fix(tools): updated memory block to throw better errors, removed deprecated posthog route, remove deprecated templates & console helpers

* remove isDeployed in favor of deploymentStatus

* ack PR comments
2026-01-09 16:53:37 -08:00
Vikhyath Mondreti
860610b4c2 improvement(billing): team upgrade + session management (#2751)
* improvement(billng): team upgrade + session management

* remove comments

* session updates should be atomic

* make consistent for onSubscritionUpdate

* plan upgrade to refresh session

* fix var name

* remove dead code

* preserve params
2026-01-09 16:36:45 -08:00
Waleed
05bbf34265 improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations (#2738)
* improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations

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

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

* don't allow flip handles for subflows

* ack PR comments

* more

* fix missing handler

* remove dead subflow-specific ops

* remove unused code

* fixed subflow ops

* keep edges on subflow actions intact

* fix subflow resizing

* fix remove from subflow bulk

* improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations

* don't allow flip handles for subflows

* ack PR comments

* more

* fix missing handler

* remove dead subflow-specific ops

* remove unused code

* fixed subflow ops

* fix subflow resizing

* keep edges on subflow actions intact

* fixed copy from inside subflow

* types improvement, preview fixes

* fetch varible data in deploy modal

* moved remove from subflow one position to the right

* fix subflow issues

* address greptile comment

* fix test

* improvement(preview): ui/ux

* fix(preview): subflows

* added batch add edges

* removed recovery

* use consolidated consts for sockets operations

* more

---------

Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-09 14:48:23 -08:00
Waleed
753600ed60 feat(i18n): update translations (#2749)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2026-01-09 14:11:57 -08:00
Vikhyath Mondreti
4da43d937c improvement(docs): multiplier dropped to 1.4 (#2748) 2026-01-09 11:41:04 -08:00
Waleed
9502227fd4 fix(sso): add missing deps to db container for running script (#2746) 2026-01-09 09:42:13 -08:00
271 changed files with 14424 additions and 4737 deletions

View File

@@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'>
<defs>
<linearGradient id='bedrock_gradient' x1='80%' x2='20%' y1='20%' y2='80%'>
<stop offset='0%' stopColor='#6350FB' />
<stop offset='50%' stopColor='#3D8FFF' />
<stop offset='100%' stopColor='#9AD8F8' />
</linearGradient>
</defs>
<path
d='M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z'
fill='url(#bedrock_gradient)'
fillRule='nonzero'
/>
</svg>
)
}

View File

@@ -49,40 +49,40 @@ Die Modellaufschlüsselung zeigt:
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
<Tab>
**Gehostete Modelle** - Sim stellt API-Schlüssel mit einem 2-fachen Preismultiplikator bereit:
**Hosted Models** - Sim bietet API-Schlüssel mit einem 1,4-fachen Preismultiplikator für Agent-Blöcke:
**OpenAI**
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ |
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ |
| GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ |
| GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ |
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ |
| o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ |
| o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
| o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ |
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ |
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| Modell | Basispreis (Eingabe/Ausgabe) | Gehosteter Preis (Eingabe/Ausgabe) |
| Modell | Basispreis (Eingabe/Ausgabe) | Hosted-Preis (Eingabe/Ausgabe) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ |
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*Der 2x-Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
*Der 1,4-fache Multiplikator deckt Infrastruktur- und API-Verwaltungskosten ab.*
</Tab>
<Tab>

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.
The Router block uses AI to intelligently route workflows based on content analysis. Unlike Condition blocks that use simple rules, Routers understand context and intent. Each route you define creates a separate output port, allowing you to connect different paths to different downstream blocks.
<div className="flex justify-center">
<Image
src="/static/blocks/router.png"
alt="Router Block with Multiple Paths"
alt="Router Block with Multiple Route Ports"
width={500}
height={400}
className="my-6"
@@ -32,21 +32,23 @@ The Router block uses AI to intelligently route workflows based on content analy
## Configuration Options
### Content/Prompt
### Context
The content or prompt that the Router will analyze to make routing decisions. This can be:
The context that the Router will analyze to make routing decisions. This is the input data that gets evaluated against your route descriptions. It can be:
- A direct user query or input
- Output from a previous block
- A system-generated message
- Any text content that needs intelligent routing
### Target Blocks
### Routes
The possible destination blocks that the Router can select from. The Router will automatically detect connected blocks, but you can also:
Define the possible paths that the Router can take. Each route consists of:
- Customize the descriptions of target blocks to improve routing accuracy
- Specify routing criteria for each target block
- Exclude certain blocks from being considered as routing targets
- **Route Title**: A name for the route (e.g., "Sales", "Support", "Technical")
- **Route Description**: A clear description of when this route should be selected (e.g., "Route here when the query is about pricing, purchasing, or sales inquiries")
Each route you add creates a **separate output port** on the Router block. Connect each port to the appropriate downstream block for that route.
### Model Selection
@@ -66,8 +68,9 @@ Your API key for the selected LLM provider. This is securely stored and used for
## Outputs
- **`<router.prompt>`**: Summary of the routing prompt
- **`<router.selected_path>`**: Chosen destination block
- **`<router.context>`**: The context that was analyzed
- **`<router.selectedRoute>`**: The ID of the selected route
- **`<router.selected_path>`**: Details of the chosen destination block
- **`<router.tokens>`**: Token usage statistics
- **`<router.cost>`**: Estimated routing cost
- **`<router.model>`**: Model used for decision-making
@@ -75,26 +78,36 @@ Your API key for the selected LLM provider. This is securely stored and used for
## Example Use Cases
**Customer Support Triage** - Route tickets to specialized departments
```
Input (Ticket) → Router → Agent (Engineering) or Agent (Finance)
Input (Ticket) → Router
├── [Sales Route] → Agent (Sales Team)
├── [Technical Route] → Agent (Engineering)
└── [Billing Route] → Agent (Finance)
```
**Content Classification** - Classify and route user-generated content
```
Input (Feedback) → Router → Workflow (Product) or Workflow (Technical)
Input (Feedback) → Router
├── [Product Feedback] → Workflow (Product Team)
└── [Bug Report] → Workflow (Technical Team)
```
**Lead Qualification** - Route leads based on qualification criteria
```
Input (Lead) → Router → Agent (Enterprise Sales) or Workflow (Self-serve)
```
```
Input (Lead) → Router
├── [Enterprise] → Agent (Enterprise Sales)
└── [Self-serve] → Workflow (Automated Onboarding)
```
## Best Practices
- **Provide clear target descriptions**: Help the Router understand when to select each destination with specific, detailed descriptions
- **Use specific routing criteria**: Define clear conditions and examples for each path to improve accuracy
- **Implement fallback paths**: Connect a default destination for when no specific path is appropriate
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content
- **Monitor routing performance**: Review routing decisions regularly and refine criteria based on actual usage patterns
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.
- **Choose appropriate models**: Use models with strong reasoning capabilities for complex routing decisions.

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 2x pricing multiplier:
**Hosted Models** - Sim provides API keys with a 1.4x pricing multiplier for Agent blocks:
**OpenAI**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| Model | Base Price (Input/Output) | Hosted Price (Input/Output) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*The 2x multiplier covers infrastructure and API management costs.*
*The 1.4x multiplier covers infrastructure and API management costs.*
</Tab>
<Tab>

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
Upload a file to Google Drive with complete metadata returned
#### Input
@@ -65,11 +65,11 @@ Upload a file to Google Drive
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | Uploaded file metadata including ID, name, and links |
| `file` | object | Complete uploaded file metadata from Google Drive |
### `google_drive_create_folder`
Create a new folder in Google Drive
Create a new folder in Google Drive with complete metadata returned
#### Input
@@ -83,11 +83,11 @@ Create a new folder in Google Drive
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | json | Created folder metadata including ID, name, and parent information |
| `file` | object | Complete created folder metadata from Google Drive |
### `google_drive_download`
Download a file from Google Drive (exports Google Workspace files automatically)
Download a file from Google Drive with complete metadata (exports Google Workspace files automatically)
#### Input
@@ -96,16 +96,17 @@ Download a file from Google Drive (exports Google Workspace files automatically)
| `fileId` | string | Yes | The ID of the file to download |
| `mimeType` | string | No | The MIME type to export Google Workspace files to \(optional\) |
| `fileName` | string | No | Optional filename override |
| `includeRevisions` | boolean | No | Whether to include revision history in the metadata \(default: true\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Downloaded file stored in execution files |
| `file` | object | Downloaded file stored in execution files |
### `google_drive_list`
List files and folders in Google Drive
List files and folders in Google Drive with complete metadata
#### Input
@@ -121,7 +122,7 @@ List files and folders in Google Drive
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | json | Array of file metadata objects from the specified folder |
| `files` | array | Array of file metadata objects from Google Drive |

View File

@@ -162,6 +162,7 @@ Create a webhook to receive recording events
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Grain API key \(Personal Access Token\) |
| `hookUrl` | string | Yes | Webhook endpoint URL \(must respond 2xx\) |
| `hookType` | string | Yes | Type of webhook: "recording_added" or "upload_status" |
| `filterBeforeDatetime` | string | No | Filter: recordings before this date |
| `filterAfterDatetime` | string | No | Filter: recordings after this date |
| `filterParticipantScope` | string | No | Filter: "internal" or "external" |
@@ -178,6 +179,7 @@ Create a webhook to receive recording events
| `id` | string | Hook UUID |
| `enabled` | boolean | Whether hook is active |
| `hook_url` | string | The webhook URL |
| `hook_type` | string | Type of hook: recording_added or upload_status |
| `filter` | object | Applied filters |
| `include` | object | Included fields |
| `inserted_at` | string | ISO8601 creation timestamp |

View File

@@ -851,24 +851,6 @@ List all status updates for a project in Linear
| --------- | ---- | ----------- |
| `updates` | array | Array of project updates |
### `linear_create_project_link`
Add an external link to a project in Linear
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Project ID to add link to |
| `url` | string | Yes | URL of the external link |
| `label` | string | No | Link label/title |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `link` | object | The created project link |
### `linear_list_notifications`
List notifications for the current user in Linear
@@ -1246,7 +1228,6 @@ Create a new project label in Linear
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | The project for this label |
| `name` | string | Yes | Project label name |
| `color` | string | No | Label color \(hex code\) |
| `description` | string | No | Label description |
@@ -1424,12 +1405,12 @@ Create a new project status in Linear
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | The project to create the status for |
| `name` | string | Yes | Project status name |
| `type` | string | Yes | Status type: "backlog", "planned", "started", "paused", "completed", or "canceled" |
| `color` | string | Yes | Status color \(hex code\) |
| `position` | number | Yes | Position in status list \(e.g. 0, 1, 2...\) |
| `description` | string | No | Status description |
| `indefinite` | boolean | No | Whether the status is indefinite |
| `position` | number | No | Position in status list |
#### Output

View File

@@ -79,30 +79,6 @@ Capture multiple events at once in PostHog. Use this for bulk event ingestion to
| `status` | string | Status message indicating whether the batch was captured successfully |
| `eventsProcessed` | number | Number of events processed in the batch |
### `posthog_list_events`
List events in PostHog. Note: This endpoint is deprecated but kept for backwards compatibility. For production use, prefer the Query endpoint with HogQL.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
| `region` | string | No | PostHog region: us \(default\) or eu |
| `projectId` | string | Yes | PostHog Project ID |
| `limit` | number | No | Number of events to return \(default: 100, max: 100\) |
| `offset` | number | No | Number of events to skip for pagination |
| `event` | string | No | Filter by specific event name |
| `distinctId` | string | No | Filter by specific distinct_id |
| `before` | string | No | ISO 8601 timestamp - only return events before this time |
| `after` | string | No | ISO 8601 timestamp - only return events after this time |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `events` | array | List of events with their properties and metadata |
### `posthog_list_persons`
List persons (users) in PostHog. Returns user profiles with their properties and distinct IDs.

View File

@@ -53,6 +53,9 @@ Send a chat completion request to any supported LLM provider
| `vertexProject` | string | No | Google Cloud project ID for Vertex AI |
| `vertexLocation` | string | No | Google Cloud location for Vertex AI \(defaults to us-central1\) |
| `vertexCredential` | string | No | Google Cloud OAuth credential ID for Vertex AI |
| `bedrockAccessKeyId` | string | No | AWS Access Key ID for Bedrock |
| `bedrockSecretKey` | string | No | AWS Secret Access Key for Bedrock |
| `bedrockRegion` | string | No | AWS region for Bedrock \(defaults to us-east-1\) |
#### Output

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 precio de 2x:
**Modelos alojados** - Sim proporciona claves API con un multiplicador de precios de 1.4x para bloques de agente:
**OpenAI**
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| Modelo | Precio base (entrada/salida) | Precio alojado (entrada/salida) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*El multiplicador 2x cubre los costos de infraestructura y gestión de API.*
*El multiplicador de 1.4x cubre los costos de infraestructura y gestión de API.*
</Tab>
<Tab>

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 2x :
**Modèles hébergés** - Sim fournit des clés API avec un multiplicateur de prix de 1,4x pour les blocs Agent :
**OpenAI**
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| GPT-5 | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,50 $ / 4,00 $ |
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,10 $ / 0,80 $ |
| GPT-4o | 2,50 $ / 10,00 $ | 5,00 $ / 20,00 $ |
| GPT-4.1 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,80 $ / 3,20 $ |
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,20 $ / 0,80 $ |
| o1 | 15,00 $ / 60,00 $ | 30,00 $ / 120,00 $ |
| o3 | 2,00 $ / 8,00 $ | 4,00 $ / 16,00 $ |
| o4 Mini | 1,10 $ / 4,40 $ | 2,20 $ / 8,80 $ |
| GPT-5.1 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
| GPT-5 | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
| GPT-5 Mini | 0,25 $ / 2,00 $ | 0,35 $ / 2,80 $ |
| GPT-5 Nano | 0,05 $ / 0,40 $ | 0,07 $ / 0,56 $ |
| GPT-4o | 2,50 $ / 10,00 $ | 3,50 $ / 14,00 $ |
| GPT-4.1 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ |
| GPT-4.1 Mini | 0,40 $ / 1,60 $ | 0,56 $ / 2,24 $ |
| GPT-4.1 Nano | 0,10 $ / 0,40 $ | 0,14 $ / 0,56 $ |
| o1 | 15,00 $ / 60,00 $ | 21,00 $ / 84,00 $ |
| o3 | 2,00 $ / 8,00 $ | 2,80 $ / 11,20 $ |
| o4 Mini | 1,10 $ / 4,40 $ | 1,54 $ / 6,16 $ |
**Anthropic**
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 10,00 $ / 50,00 $ |
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 30,00 $ / 150,00 $ |
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 6,00 $ / 30,00 $ |
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 2,00 $ / 10,00 $ |
| Claude Opus 4.5 | 5,00 $ / 25,00 $ | 7,00 $ / 35,00 $ |
| Claude Opus 4.1 | 15,00 $ / 75,00 $ | 21,00 $ / 105,00 $ |
| Claude Sonnet 4.5 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ |
| Claude Sonnet 4.0 | 3,00 $ / 15,00 $ | 4,20 $ / 21,00 $ |
| Claude Haiku 4.5 | 1,00 $ / 5,00 $ | 1,40 $ / 7,00 $ |
**Google**
| Modèle | Prix de base (entrée/sortie) | Prix hébergé (entrée/sortie) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 4,00 $ / 24,00 $ |
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 2,50 $ / 20,00 $ |
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,60 $ / 5,00 $ |
| Gemini 3 Pro Preview | 2,00 $ / 12,00 $ | 2,80 $ / 16,80 $ |
| Gemini 2.5 Pro | 1,25 $ / 10,00 $ | 1,75 $ / 14,00 $ |
| Gemini 2.5 Flash | 0,30 $ / 2,50 $ | 0,42 $ / 3,50 $ |
*Le multiplicateur 2x couvre les coûts d'infrastructure et de gestion des API.*
*Le multiplicateur de 1,4x couvre les coûts d'infrastructure et de gestion des API.*
</Tab>
<Tab>

View File

@@ -47,42 +47,42 @@ AIブロックを使用するワークフローでは、ログで詳細なコス
## 料金オプション
<Tabs items={['Hosted Models', 'Bring Your Own API Key']}>
<Tabs items={['ホステッドモデル', '独自のAPIキーを使用']}>
<Tab>
**ホステッドモデル** - Simは2倍の価格乗数APIキーを提供します
**ホステッドモデル** - Simは、エージェントブロック用に1.4倍の価格乗数を適用したAPIキーを提供します:
**OpenAI**
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| モデル | 基本価格(入力/出力) | ホステッド価格(入力/出力) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*2倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。*
*1.4倍の乗数は、インフラストラクチャとAPI管理コストをカバーします。*
</Tab>
<Tab>

View File

@@ -47,42 +47,42 @@ totalCost = baseExecutionCharge + modelCost
## 定价选项
<Tabs items={[ '托管模型', '自带 API 密钥' ]}>
<Tabs items={['托管模型', '自带 API Key']}>
<Tab>
**托管模型** - Sim 提供 API 密钥,价格为基础价格的 2 倍:
**托管模型** - Sim 为 Agent 模块提供 API Key价格乘以 1.4 倍:
**OpenAI**
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|-------|---------------------------|----------------------------|
| GPT-5.1 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 | $1.25 / $10.00 | $2.50 / $20.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.50 / $4.00 |
| GPT-5 Nano | $0.05 / $0.40 | $0.10 / $0.80 |
| GPT-4o | $2.50 / $10.00 | $5.00 / $20.00 |
| GPT-4.1 | $2.00 / $8.00 | $4.00 / $16.00 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.80 / $3.20 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.20 / $0.80 |
| o1 | $15.00 / $60.00 | $30.00 / $120.00 |
| o3 | $2.00 / $8.00 | $4.00 / $16.00 |
| o4 Mini | $1.10 / $4.40 | $2.20 / $8.80 |
| GPT-5.1 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 | $1.25 / $10.00 | $1.75 / $14.00 |
| GPT-5 Mini | $0.25 / $2.00 | $0.35 / $2.80 |
| GPT-5 Nano | $0.05 / $0.40 | $0.07 / $0.56 |
| GPT-4o | $2.50 / $10.00 | $3.50 / $14.00 |
| GPT-4.1 | $2.00 / $8.00 | $2.80 / $11.20 |
| GPT-4.1 Mini | $0.40 / $1.60 | $0.56 / $2.24 |
| GPT-4.1 Nano | $0.10 / $0.40 | $0.14 / $0.56 |
| o1 | $15.00 / $60.00 | $21.00 / $84.00 |
| o3 | $2.00 / $8.00 | $2.80 / $11.20 |
| o4 Mini | $1.10 / $4.40 | $1.54 / $6.16 |
**Anthropic**
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|-------|---------------------------|----------------------------|
| Claude Opus 4.5 | $5.00 / $25.00 | $10.00 / $50.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $30.00 / $150.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $6.00 / $30.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $2.00 / $10.00 |
| Claude Opus 4.5 | $5.00 / $25.00 | $7.00 / $35.00 |
| Claude Opus 4.1 | $15.00 / $75.00 | $21.00 / $105.00 |
| Claude Sonnet 4.5 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Sonnet 4.0 | $3.00 / $15.00 | $4.20 / $21.00 |
| Claude Haiku 4.5 | $1.00 / $5.00 | $1.40 / $7.00 |
**Google**
| 模型 | 基础价格(输入/输出) | 托管价格(输入/输出) |
|-------|---------------------------|----------------------------|
| Gemini 3 Pro Preview | $2.00 / $12.00 | $4.00 / $24.00 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $2.50 / $20.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.60 / $5.00 |
| Gemini 3 Pro Preview | $2.00 / $12.00 | $2.80 / $16.80 |
| Gemini 2.5 Pro | $1.25 / $10.00 | $1.75 / $14.00 |
| Gemini 2.5 Flash | $0.30 / $2.50 | $0.42 / $3.50 |
*2 倍系数涵盖了基础设施和 API 管理成本。*
*1.4系数涵盖了基础设施和 API 管理成本。*
</Tab>
<Tab>

View File

@@ -4581,11 +4581,11 @@ checksums:
content/10: d19c8c67f52eb08b6a49c0969a9c8b86
content/11: 4024a36e0d9479ff3191fb9cd2b2e365
content/12: 0396a1e5d9548207f56e6b6cae85a542
content/13: 4bfdeac5ad21c75209dcdfde85aa52b0
content/14: 35df9a16b866dbe4bb9fc1d7aee42711
content/15: 135c044066cea8cc0e22f06d67754ec5
content/16: 6882b91e30548d7d331388c26cf2e948
content/17: 29aed7061148ae46fa6ec8bcbc857c3d
content/13: 68f90237f86be125224c56a2643904a3
content/14: e854781f0fbf6f397a3ac682e892a993
content/15: 2340c44af715fb8ca58f43151515aae1
content/16: fc7ae93bff492d80f4b6f16e762e05fa
content/17: 8a46692d5df3fed9f94d59dfc3fb7e0a
content/18: e0571c88ea5bcd4305a6f5772dcbed98
content/19: 83fc31418ff454a5e06b290e3708ef32
content/20: 4392b5939a6d5774fb080cad1ee1dbb8

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 11 KiB

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 94133, USA</li>
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
</ul>
<p>We will respond to your request within a reasonable timeframe.</p>
</section>

View File

@@ -2,6 +2,7 @@
import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import posthog from 'posthog-js'
import { client } from '@/lib/auth/auth-client'
@@ -35,12 +36,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<AppSession>(null)
const [isPending, setIsPending] = useState(true)
const [error, setError] = useState<Error | null>(null)
const queryClient = useQueryClient()
const loadSession = useCallback(async () => {
const loadSession = useCallback(async (bypassCache = false) => {
try {
setIsPending(true)
setError(null)
const res = await client.getSession()
const res = bypassCache
? await client.getSession({ query: { disableCookieCache: true } })
: await client.getSession()
setData(res?.data ?? null)
} catch (e) {
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
@@ -50,8 +54,25 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}, [])
useEffect(() => {
loadSession()
}, [loadSession])
// Check if user was redirected after plan upgrade
const params = new URLSearchParams(window.location.search)
const wasUpgraded = params.get('upgraded') === 'true'
if (wasUpgraded) {
params.delete('upgraded')
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname
window.history.replaceState({}, '', newUrl)
}
loadSession(wasUpgraded).then(() => {
if (wasUpgraded) {
queryClient.invalidateQueries({ queryKey: ['organizations'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })
}
})
}, [loadSession, queryClient])
useEffect(() => {
if (isPending || typeof posthog.identify !== 'function') {

View File

@@ -42,6 +42,40 @@
animation: dash-animation 1.5s linear infinite !important;
}
/**
* React Flow selection box styling
* Uses brand-secondary color for selection highlighting
*/
.react-flow__selection {
background: rgba(51, 180, 255, 0.08) !important;
border: 1px solid var(--brand-secondary) !important;
}
.react-flow__nodesselection-rect,
.react-flow__nodesselection {
background: transparent !important;
border: none !important;
pointer-events: none !important;
}
/**
* Selected node ring indicator
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
*/
.react-flow__node.selected > div > div {
position: relative;
}
.react-flow__node.selected > div > div::after {
content: "";
position: absolute;
inset: 0;
z-index: 40;
border-radius: 8px;
box-shadow: 0 0 0 1.75px var(--brand-secondary);
pointer-events: none;
}
/**
* Color tokens - single source of truth for all colors
* Light mode: Warm theme

View File

@@ -253,7 +253,7 @@ export async function POST(
userId: deployment.userId,
workspaceId,
isDeployed: workflowRecord?.isDeployed ?? false,
variables: workflowRecord?.variables || {},
variables: (workflowRecord?.variables as Record<string, unknown>) ?? undefined,
}
const stream = await createStreamingResponse({

View File

@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
const UpdateMessagesSchema = z.object({
chatId: z.string(),
messages: z.array(
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(),
})
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
),
planArtifact: z.string().nullable().optional(),
config: z
@@ -57,6 +62,19 @@ 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

@@ -1,50 +0,0 @@
/**
* @deprecated This route is not currently in use
* @remarks Kept for reference - may be removed in future cleanup
*/
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('UpdateChatTitleAPI')
const UpdateTitleSchema = z.object({
chatId: z.string(),
title: z.string(),
})
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = UpdateTitleSchema.parse(body)
// Update the chat title
await db
.update(copilotChats)
.set({
title: parsed.title,
updatedAt: new Date(),
})
.where(eq(copilotChats.id, parsed.chatId))
logger.info('Chat title updated', { chatId: parsed.chatId, title: parsed.title })
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error updating chat title:', error)
return NextResponse.json(
{ success: false, error: 'Failed to update chat title' },
{ status: 500 }
)
}
}

View File

@@ -1,134 +0,0 @@
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,7 +21,6 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
// Get user session
const session = await getSession()
if (!session?.user?.email) {
logger.warn(`[${requestId}] Unauthorized help request attempt`)
@@ -30,20 +29,20 @@ export async function POST(req: NextRequest) {
const email = session.user.email
// Handle multipart form data
const formData = await req.formData()
// Extract form fields
const subject = formData.get('subject') as string
const message = formData.get('message') as string
const type = formData.get('type') as string
const workflowId = formData.get('workflowId') as string | null
const workspaceId = formData.get('workspaceId') as string
const userAgent = formData.get('userAgent') as string | null
logger.info(`[${requestId}] Processing help request`, {
type,
email: `${email.substring(0, 3)}***`, // Log partial email for privacy
})
// Validate the form data
const validationResult = helpFormSchema.safeParse({
subject,
message,
@@ -60,7 +59,6 @@ export async function POST(req: NextRequest) {
)
}
// Extract images
const images: { filename: string; content: Buffer; contentType: string }[] = []
for (const [key, value] of formData.entries()) {
@@ -81,10 +79,14 @@ export async function POST(req: NextRequest) {
logger.debug(`[${requestId}] Help request includes ${images.length} images`)
// Prepare email content
const userId = session.user.id
let emailText = `
Type: ${type}
From: ${email}
User ID: ${userId}
Workspace ID: ${workspaceId ?? 'N/A'}
Workflow ID: ${workflowId ?? 'N/A'}
Browser: ${userAgent ?? 'N/A'}
${message}
`
@@ -115,7 +117,6 @@ ${message}
logger.info(`[${requestId}] Help request email sent successfully`)
// Send confirmation email to the user
try {
const confirmationHtml = await renderHelpConfirmationEmail(
type as 'bug' | 'feedback' | 'feature_request' | 'other',

View File

@@ -10,6 +10,7 @@ import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credentials/credential-extractor'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateByIdAPI')
@@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
.where(eq(workflow.id, template.workflowId))
.limit(1)
const currentState = {
const currentState: Partial<WorkflowState> = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || undefined,
variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined,
lastSaved: Date.now(),
}

View File

@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils'
import {
type RegenerateStateInput,
regenerateWorkflowStateIds,
} from '@/lib/workflows/persistence/utils'
const logger = createLogger('TemplateUseAPI')
@@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
// When connecting to template (edit mode), keep original IDs
// When using template (copy mode), regenerate all IDs to avoid conflicts
const templateState = templateData.state as RegenerateStateInput
const workflowState = connectToTemplate
? templateData.state
: regenerateWorkflowStateIds(templateData.state)
? templateState
: regenerateWorkflowStateIds(templateState)
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
// Ensure variables in state are remapped for the new workflow as well

View File

@@ -243,7 +243,7 @@ export interface WorkflowExportState {
color?: string
exportedAt?: string
}
variables?: WorkflowVariable[]
variables?: Record<string, WorkflowVariable>
}
export interface WorkflowExportPayload {
@@ -317,36 +317,44 @@ export interface WorkspaceImportResponse {
// =============================================================================
/**
* Parse workflow variables from database JSON format to array format.
* Handles both array and Record<string, Variable> formats.
* Parse workflow variables from database JSON format to Record format.
* Handles both legacy Array and current Record<string, Variable> formats.
*/
export function parseWorkflowVariables(
dbVariables: DbWorkflow['variables']
): WorkflowVariable[] | undefined {
): Record<string, WorkflowVariable> | undefined {
if (!dbVariables) return undefined
try {
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
// Handle legacy Array format by converting to Record
if (Array.isArray(varsObj)) {
return varsObj.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}))
const result: Record<string, WorkflowVariable> = {}
for (const v of varsObj) {
result[v.id] = {
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}
}
return result
}
// Already Record format - normalize and return
if (typeof varsObj === 'object' && varsObj !== null) {
return Object.values(varsObj).map((v: unknown) => {
const result: Record<string, WorkflowVariable> = {}
for (const [key, v] of Object.entries(varsObj)) {
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
return {
result[key] = {
id: variable.id,
name: variable.name,
type: variable.type,
value: variable.value,
}
})
}
return result
}
} catch {
// pass

View File

@@ -74,8 +74,6 @@ export async function POST(
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: Date.now(),
isDeployed: true,
deployedAt: new Date(),
deploymentStatuses: deployedState.deploymentStatuses || {},
})
@@ -88,7 +86,6 @@ export async function POST(
.set({ lastSynced: new Date(), updatedAt: new Date() })
.where(eq(workflow.id, id))
// Sync MCP tools with the reverted version's parameter schema
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,

View File

@@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => {
update: { results: [{}] },
})
const variables = [
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
]
const variables = {
'var-1': {
id: 'var-1',
workflowId: 'workflow-123',
name: 'test',
type: 'string',
value: 'hello',
},
}
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
method: 'POST',
@@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => {
isWorkspaceOwner: false,
})
const variables = [
{ id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' },
]
const variables = {
'var-1': {
id: 'var-1',
workflowId: 'workflow-123',
name: 'test',
type: 'string',
value: 'hello',
},
}
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
method: 'POST',
@@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => {
isWorkspaceOwner: false,
})
// Invalid data - missing required fields
const invalidData = { variables: [{ name: 'test' }] }
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {

View File

@@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types'
const logger = createLogger('WorkflowVariablesAPI')
const VariableSchema = z.object({
id: z.string(),
workflowId: z.string(),
name: z.string(),
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.record(z.unknown()),
z.array(z.unknown()),
]),
})
const VariablesSchema = z.object({
variables: z.array(
z.object({
id: z.string(),
workflowId: z.string(),
name: z.string(),
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']),
value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]),
})
),
variables: z.record(z.string(), VariableSchema),
})
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
try {
const { variables } = VariablesSchema.parse(body)
// Format variables for storage
const variablesRecord: Record<string, Variable> = {}
variables.forEach((variable) => {
variablesRecord[variable.id] = variable
})
// Replace variables completely with the incoming ones
// Variables are already in Record format - use directly
// The frontend is the source of truth for what variables should exist
const updatedVariables = variablesRecord
// Update workflow with variables
await db
.update(workflow)
.set({
variables: updatedVariables,
variables,
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
@@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
headers,
}
)
} catch (error: any) {
} catch (error) {
logger.error(`[${requestId}] Workflow variables fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View File

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

View File

@@ -106,8 +106,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
isDeployed: input.isDeployed,
deployedAt: input.deployedAt,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
@@ -204,7 +202,6 @@ function TemplateCardInner({
{normalizedState && isInView ? (
<WorkflowPreview
workflowState={normalizedState}
showSubBlocks={false}
height={180}
width='100%'
isPannable={false}

View File

@@ -95,7 +95,12 @@ export function ChunkContextMenu({
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',

View File

@@ -100,7 +100,12 @@ export function DocumentContextMenu({
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',

View File

@@ -99,7 +99,12 @@ export function KnowledgeBaseContextMenu({
disableDelete = false,
}: KnowledgeBaseContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',

View File

@@ -43,7 +43,12 @@ export function KnowledgeListContextMenu({
disableAdd = false,
}: KnowledgeListContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',

View File

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

View File

@@ -0,0 +1,97 @@
'use client'
import type { RefObject } from 'react'
import { createPortal } from 'react-dom'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface SnapshotContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopy: () => void
onSearch?: () => void
wrapText?: boolean
onToggleWrap?: () => void
/** When true, only shows Copy option (for subblock values) */
copyOnly?: boolean
}
/**
* Context menu for execution snapshot sidebar.
* Provides copy, search, and display options.
* Uses createPortal to render outside any transformed containers (like modals).
*/
export function SnapshotContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopy,
onSearch,
wrapText,
onToggleWrap,
copyOnly = false,
}: SnapshotContextMenuProps) {
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onCopy()
onClose()
}}
>
Copy
</PopoverItem>
{!copyOnly && onSearch && (
<>
<PopoverDivider />
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
</>
)}
{!copyOnly && onToggleWrap && (
<>
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>,
document.body
)
}

View File

@@ -1,12 +1,23 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
import { createPortal } from 'react-dom'
import {
Modal,
ModalBody,
ModalContent,
ModalHeader,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
} from '@/components/emcn'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
@@ -60,6 +71,46 @@ export function ExecutionSnapshot({
}: ExecutionSnapshotProps) {
const { data, isLoading, error } = useExecutionSnapshot(executionId)
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(null)
const autoSelectedForExecutionRef = useRef<string | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuBlockId, setContextMenuBlockId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => {
setIsMenuOpen(false)
setContextMenuBlockId(null)
}, [])
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuBlockId(null)
setMenuPosition({ x: e.clientX, y: e.clientY })
setIsMenuOpen(true)
}, [])
const handleNodeContextMenu = useCallback(
(blockId: string, mousePosition: { x: number; y: number }) => {
setContextMenuBlockId(blockId)
setMenuPosition(mousePosition)
setIsMenuOpen(true)
},
[]
)
const handleCopyExecutionId = useCallback(() => {
navigator.clipboard.writeText(executionId)
closeMenu()
}, [executionId, closeMenu])
const handleOpenDetails = useCallback(() => {
if (contextMenuBlockId) {
setPinnedBlockId(contextMenuBlockId)
}
closeMenu()
}, [contextMenuBlockId, closeMenu])
const blockExecutions = useMemo(() => {
if (!traceSpans || !Array.isArray(traceSpans)) return {}
@@ -97,12 +148,21 @@ export function ExecutionSnapshot({
return blockExecutionMap
}, [traceSpans])
useEffect(() => {
setPinnedBlockId(null)
}, [executionId])
const workflowState = data?.workflowState as WorkflowState | undefined
// Auto-select the leftmost block once when data loads for a new executionId
useEffect(() => {
if (
workflowState &&
!isMigratedWorkflowState(workflowState) &&
autoSelectedForExecutionRef.current !== executionId
) {
autoSelectedForExecutionRef.current = executionId
const leftmostId = getLeftmostBlockId(workflowState)
setPinnedBlockId(leftmostId)
}
}, [executionId, workflowState])
const renderContent = () => {
if (isLoading) {
return (
@@ -169,22 +229,26 @@ export function ExecutionSnapshot({
<div
style={{ height, width }}
className={cn(
'flex overflow-hidden rounded-[4px] border border-[var(--border)]',
'flex overflow-hidden',
!isModal && 'rounded-[4px] border border-[var(--border)]',
className
)}
>
<div className='h-full flex-1'>
<div className='h-full flex-1' onContextMenu={handleCanvasContextMenu}>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={(blockId) => {
setPinnedBlockId((prev) => (prev === blockId ? null : blockId))
setPinnedBlockId(blockId)
}}
onNodeContextMenu={handleNodeContextMenu}
onPaneClick={() => setPinnedBlockId(null)}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
lightweight
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
@@ -193,32 +257,74 @@ export function ExecutionSnapshot({
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode
onClose={() => setPinnedBlockId(null)}
/>
)}
</div>
)
}
const canvasContextMenu =
typeof document !== 'undefined'
? createPortal(
<Popover
open={isMenuOpen}
onOpenChange={closeMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${menuPosition.x}px`,
top: `${menuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{contextMenuBlockId && (
<PopoverItem onClick={handleOpenDetails}>Open Details</PopoverItem>
)}
<PopoverItem onClick={handleCopyExecutionId}>Copy Execution ID</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)
: null
if (isModal) {
return (
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<>
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setPinnedBlockId(null)
onClose()
}
}}
>
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1'>{renderContent()}</ModalBody>
</ModalContent>
</Modal>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>{renderContent()}</ModalBody>
</ModalContent>
</Modal>
{canvasContextMenu}
</>
)
}
return renderContent()
return (
<>
{renderContent()}
{canvasContextMenu}
</>
)
}

View File

@@ -1,13 +1,27 @@
'use client'
import type React from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronDown, Code } from '@/components/emcn'
import { ArrowDown, ArrowUp, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
ChevronDown,
Code,
Input,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import type { TraceSpan } from '@/stores/logs/filters/types'
interface TraceSpansProps {
@@ -370,7 +384,7 @@ function SpanContent({
}
/**
* Renders input/output section with collapsible content
* Renders input/output section with collapsible content, context menu, and search
*/
function InputOutputSection({
label,
@@ -391,14 +405,63 @@ function InputOutputSection({
}) {
const sectionKey = `${spanId}-${sectionType}`
const isExpanded = expandedSections.has(sectionKey)
const contentRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
// Code viewer features
const {
wrapText,
toggleWrapText,
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => {
if (!data) return ''
return JSON.stringify(data, null, 2)
}, [data])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
const handleToggleWrap = useCallback(() => {
toggleWrapText()
closeContextMenu()
}, [toggleWrapText, closeContextMenu])
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => onToggle(sectionKey)}
@@ -433,12 +496,101 @@ function InputOutputSection({
/>
</div>
{isExpanded && (
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
/>
<>
<div ref={contentRef} onContextMenu={handleContextMenu}>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={closeSearch}
aria-label='Close search'
>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
Wrap Text
</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</>
)}
</div>
)

View File

@@ -47,7 +47,12 @@ export function LogRowContextMenu({
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -87,7 +92,7 @@ export function LogRowContextMenu({
onClose()
}}
>
Open Preview
Open Snapshot
</PopoverItem>
{/* Filter actions */}

View File

@@ -109,8 +109,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
isDeployed: input.isDeployed,
deployedAt: input.deployedAt,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
@@ -210,7 +208,6 @@ function TemplateCardInner({
{normalizedState && isInView ? (
<WorkflowPreview
workflowState={normalizedState}
showSubBlocks={false}
height={180}
width='100%'
isPannable={false}

View File

@@ -13,7 +13,7 @@ import { useDebounce } from '@/hooks/use-debounce'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
/**
* Template data structure with support for both new and legacy fields
* Template data structure
*/
export interface Template {
/** Unique identifier for the template */
@@ -59,16 +59,6 @@ export interface Template {
isStarred: boolean
/** Whether the current user is a super user */
isSuperUser?: boolean
/** @deprecated Legacy field - use creator.referenceId instead */
userId?: string
/** @deprecated Legacy field - use details.tagline instead */
description?: string | null
/** @deprecated Legacy field - use creator.name instead */
author?: string
/** @deprecated Legacy field - use creator.referenceType instead */
authorType?: 'user' | 'organization'
/** @deprecated Legacy field - use creator.referenceId when referenceType is 'organization' */
organizationId?: string | null
/** Display color for the template card */
color?: string
/** Display icon for the template card */
@@ -107,7 +97,6 @@ export default function Templates({
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = debouncedSearchQuery.toLowerCase()
@@ -115,7 +104,7 @@ export default function Templates({
return templates.filter((template) => {
const tabMatch =
activeTab === 'your'
? template.userId === currentUserId || template.isStarred
? template.creator?.referenceId === currentUserId || template.isStarred
: activeTab === 'gallery'
? template.status === 'approved'
: template.status === 'pending'
@@ -124,13 +113,7 @@ export default function Templates({
if (!query) return true
const searchableText = [
template.name,
template.description,
template.details?.tagline,
template.author,
template.creator?.name,
]
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
.filter(Boolean)
.join(' ')
.toLowerCase()
@@ -141,7 +124,6 @@ export default function Templates({
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (debouncedSearchQuery) {
@@ -235,25 +217,20 @@ export default function Templates({
</div>
</div>
) : (
filteredTemplates.map((template) => {
const author = template.author || template.creator?.name || 'Unknown'
const authorImageUrl = template.creator?.profileImageUrl || null
return (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
author={author}
authorImageUrl={authorImageUrl}
usageCount={template.views.toString()}
stars={template.stars}
state={template.state}
isStarred={template.isStarred}
isVerified={template.creator?.verified || false}
/>
)
})
filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
state={template.state}
isStarred={template.isStarred}
isVerified={template.creator?.verified || false}
/>
))
)}
</div>
</div>

View File

@@ -7,6 +7,7 @@ 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'
@@ -43,6 +44,11 @@ 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/use-float'
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { getChatPosition, useChatStore } from '@/stores/chat/store'
@@ -726,7 +726,9 @@ export function Chat() {
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
if (!isStreaming && !isExecuting) {
handleSendMessage()
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (promptHistory.length > 0) {
@@ -749,7 +751,7 @@ export function Chat() {
}
}
},
[handleSendMessage, promptHistory, historyIndex]
[handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting]
)
/**
@@ -1061,7 +1063,7 @@ export function Chat() {
onKeyDown={handleKeyPress}
placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'}
className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={!activeWorkflowId || isExecuting}
disabled={!activeWorkflowId}
/>
{/* Buttons positioned absolutely on the right */}
@@ -1091,7 +1093,8 @@ export function Chat() {
disabled={
(!chatMessage.trim() && chatFiles.length === 0) ||
!activeWorkflowId ||
isExecuting
isExecuting ||
isStreaming
}
className={cn(
'h-[22px] w-[22px] rounded-full p-0 transition-colors',

View File

@@ -2,10 +2,10 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { Layout, LibraryBig, Search } from 'lucide-react'
import { Layout, Search } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Button, Library } 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: LibraryBig,
icon: Library,
shortcut: 'L',
},
{

View File

@@ -56,7 +56,7 @@ export function BlockContextMenu({
return (
<Popover
open={isOpen}
onOpenChange={onClose}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
@@ -118,7 +118,7 @@ export function BlockContextMenu({
{getToggleEnabledLabel()}
</PopoverItem>
)}
{!allNoteBlocks && (
{!allNoteBlocks && !isSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {

View File

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

View File

@@ -1,10 +1,11 @@
import { memo, useCallback } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
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'
@@ -15,28 +16,20 @@ const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
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 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 { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback(
@@ -53,11 +46,6 @@ 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', {
@@ -109,12 +97,7 @@ export const DiffControls = memo(function DiffControls() {
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
isDeployed: rawState.isDeployed || false,
deploymentStatuses: rawState.deploymentStatuses || {},
// Only include deployedAt if it's a valid date, never include null/undefined
...(rawState.deployedAt && rawState.deployedAt instanceof Date
? { deployedAt: rawState.deployedAt }
: {}),
}
logger.info('Prepared complete workflow state for checkpoint', {
@@ -211,54 +194,47 @@ export const DiffControls = memo(function DiffControls() {
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(async () => {
const handleAccept = useCallback(() => {
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 {
// 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
}
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)
})
logger.info('Accept triggered; UI will update optimistically')
} catch (error) {
logger.error('Failed to accept changes:', 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)
})
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
logger.info('Accept triggered; UI will update optimistically')
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
@@ -298,54 +274,82 @@ 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(
'-translate-x-1/2 fixed left-1/2 z-30',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
'fixed z-30',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
style={{
bottom: 'calc(var(--terminal-height) + 8px)',
right: 'calc(var(--panel-width) + 8px)',
}}
>
<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'
<div
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
style={{ isolation: 'isolate' }}
>
{/* Reject side */}
<button
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>
{/* Accept */}
<Button
variant='tertiary'
</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
onClick={handleAccept}
className='h-[30px] rounded-[8px] px-3'
title='Accept changes'
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',
}}
>
Accept
</Button>
<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>
</div>
</div>
)

View File

@@ -11,6 +11,7 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
/**
* Notifications display component
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
* Shows both global notifications and workflow-specific notifications
*/
export const Notifications = memo(function Notifications() {
@@ -36,6 +37,7 @@ 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.
@@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() {
return null
}
const isResizing = isTerminalResizing || isSidebarResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'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'
'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'
)}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {

View File

@@ -3,75 +3,23 @@
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
*/
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
content: string
/** Whether the block is currently streaming */
isStreaming?: boolean
/** Persisted duration from content block */
duration?: number
/** Persisted start time from content block */
startTime?: number
/** 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
}
/**
* 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
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
export function ThinkingBlock({
content,
isStreaming = false,
duration: persistedDuration,
startTime: persistedStartTime,
hasFollowingContent = false,
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(persistedDuration ?? 0)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const userCollapsedRef = useRef<boolean>(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])
const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
/**
* Auto-expands block when streaming with content
* Auto-collapses when streaming ends
* Auto-collapses when streaming ends OR when following content arrives
*/
useEffect(() => {
if (!isStreaming) {
// Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])
}, [isStreaming, content, hasFollowingContent])
/**
* Updates duration timer during streaming
* Uses persisted duration when available
*/
// Reset start time when streaming begins
useEffect(() => {
if (typeof persistedDuration === 'number') {
setDuration(persistedDuration)
return
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
}
if (isStreaming) {
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
setDuration(Date.now() - startTimeRef.current)
}, [isStreaming, persistedDuration])
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])
/**
* Formats duration in milliseconds to human-readable format
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "150ms" or "2.5s")
* Formats duration in milliseconds to seconds
* Always shows seconds, rounded to nearest whole second, minimum 1s
*/
const formatDuration = (ms: number) => {
if (ms < SECONDS_THRESHOLD) {
return `${ms}ms`
}
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
const seconds = Math.max(1, Math.round(ms / 1000))
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 className='mt-1 mb-0'>
<div>
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
// If user collapses during streaming, remember to not auto-expand again
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
setIsExpanded((v) => !v)
}}
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)]'
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'
disabled={!hasContent}
>
<ShimmerOverlayText
label='Thought'
value={` for ${formatDuration(duration)}`}
active={isStreaming}
/>
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
{hasContent && (
<ChevronUp
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
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>
{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
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} />
</div>
)}
</div>
</div>
)
}

View File

@@ -4,10 +4,12 @@ import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/emcn'
import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription'
import { useCopilotStore } from '@/stores/panel/copilot/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const LIMIT_INCREMENTS = [0, 50, 100] as const
function roundUpToNearest50(value: number): number {
@@ -15,7 +17,7 @@ function roundUpToNearest50(value: number): number {
}
export function UsageLimitActions() {
const { data: subscriptionData } = useSubscriptionData()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const updateUsageLimitMutation = useUpdateUsageLimit()
const subscription = subscriptionData?.data

View File

@@ -1,9 +1,13 @@
'use client'
import { type FC, memo, useMemo, useState } from 'react'
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
OptionsSelector,
parseSpecialTags,
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
FileAttachmentDisplay,
SmoothStreamingText,
@@ -15,8 +19,6 @@ 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'
@@ -40,6 +42,8 @@ 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
}
/**
@@ -59,6 +63,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
checkpointCount = 0,
onEditModeChange,
onRevertModeChange,
isLastMessage = false,
}) => {
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
@@ -88,22 +93,6 @@ 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,
@@ -153,14 +142,6 @@ 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 ''
@@ -169,6 +150,42 @@ 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) {
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'text') {
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
// Clean content for this text block
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
// 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
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'thinking') {
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
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreamingThinking}
duration={block.duration}
startTime={block.startTime}
isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent}
/>
</div>
)
@@ -467,53 +483,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)}
{message.errorType === 'usage_limit' && (
<div className='mt-3 flex gap-1.5'>
<div className='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'>
@@ -533,6 +507,20 @@ 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>
)
@@ -570,6 +558,11 @@ 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,5 +1,6 @@
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

@@ -0,0 +1,102 @@
'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

@@ -1,76 +0,0 @@
'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,6 +1,5 @@
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,11 +178,12 @@ 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 || isLoading) return
if (disabled) return
fileInputRef.current?.click()
}, [disabled, isLoading])
}, [disabled])
/**
* Handles file input change event

View File

@@ -117,7 +117,6 @@ 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('')
@@ -300,7 +299,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
if (!trimmedMessage || disabled || isLoading) return
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) 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 || isLoading) && 'cursor-not-allowed opacity-50'
disabled && '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 */}
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
<input
ref={fileAttachments.fileInputRef}
type='file'
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
className='hidden'
accept='image/*'
multiple
disabled={disabled || isLoading}
disabled={disabled}
/>
</div>
</div>

View File

@@ -22,9 +22,11 @@ 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,
@@ -99,7 +101,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
loadChats,
messageCheckpoints,
currentChat,
fetchContextUsage,
selectChat,
deleteChat,
areChatsFresh,
@@ -118,7 +119,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -298,7 +298,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return
// Allow submission even when isSendingMessage - store will queue the message
if (!query || !activeWorkflowId) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
@@ -316,7 +317,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
[activeWorkflowId, sendMessage, showPlanTodos]
)
/**
@@ -443,7 +444,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<span className='min-w-0 flex-1 truncate'>
{chat.title || 'New Chat'}
</span>
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
<div
className={cn(
'flex flex-shrink-0 items-center gap-[4px]',
currentChat?.id !== chat.id &&
'opacity-0 transition-opacity group-hover:opacity-100'
)}
>
<Button
variant='ghost'
className='h-[16px] w-[16px] p-0'
@@ -563,6 +570,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
onRevertModeChange={(isReverting) =>
handleRevertModeChange(message.id, isReverting)
}
isLastMessage={index === messages.length - 1}
/>
)
})}
@@ -588,6 +596,9 @@ 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,7 +11,6 @@ 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
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -102,18 +100,6 @@ 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, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Maximize2 } from 'lucide-react'
import {
@@ -17,6 +17,7 @@ import { Skeleton } from '@/components/ui'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useDeploymentVersionState, useRevertToVersion } from '@/hooks/queries/workflows'
@@ -57,6 +58,7 @@ export function GeneralDeploy({
const [showPromoteDialog, setShowPromoteDialog] = useState(false)
const [showExpandedPreview, setShowExpandedPreview] = useState(false)
const [expandedSelectedBlockId, setExpandedSelectedBlockId] = useState<string | null>(null)
const hasAutoSelectedRef = useRef(false)
const [versionToLoad, setVersionToLoad] = useState<number | null>(null)
const [versionToPromote, setVersionToPromote] = useState<number | null>(null)
@@ -131,6 +133,19 @@ export function GeneralDeploy({
const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0
const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData
// Auto-select the leftmost block once when expanded preview opens
useEffect(() => {
if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) {
hasAutoSelectedRef.current = true
const leftmostId = getLeftmostBlockId(workflowToShow)
setExpandedSelectedBlockId(leftmostId)
}
// Reset when modal closes
if (!showExpandedPreview) {
hasAutoSelectedRef.current = false
}
}, [showExpandedPreview, workflowToShow])
if (showLoadingSkeleton) {
return (
<div className='space-y-[12px]'>
@@ -186,7 +201,7 @@ export function GeneralDeploy({
</div>
<div
className='[&_*]:!cursor-default relative h-[260px] w-full cursor-default overflow-hidden rounded-[4px] border border-[var(--border)]'
className='relative h-[260px] w-full overflow-hidden rounded-[4px] border border-[var(--border)]'
onWheelCapture={(e) => {
if (e.ctrlKey || e.metaKey) return
e.stopPropagation()
@@ -194,28 +209,28 @@ export function GeneralDeploy({
>
{workflowToShow ? (
<>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
<div className='[&_*]:!cursor-default h-full w-full cursor-default'>
<WorkflowPreview
workflowState={workflowToShow}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
size='sm'
onClick={() => setShowExpandedPreview(true)}
className='absolute top-[8px] right-[8px] z-10'
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Maximize2 className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Expand preview</Tooltip.Content>
<Tooltip.Content side='top'>See preview</Tooltip.Content>
</Tooltip.Root>
</>
) : (
@@ -316,21 +331,23 @@ export function GeneralDeploy({
<div className='h-full flex-1'>
<WorkflowPreview
workflowState={workflowToShow}
showSubBlocks={true}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.6}
onNodeClick={(blockId) => {
setExpandedSelectedBlockId(
expandedSelectedBlockId === blockId ? null : blockId
)
setExpandedSelectedBlockId(blockId)
}}
cursorStyle='pointer'
onPaneClick={() => setExpandedSelectedBlockId(null)}
selectedBlockId={expandedSelectedBlockId}
lightweight
/>
</div>
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
<BlockDetailsSidebar
block={workflowToShow.blocks[expandedSelectedBlockId]}
workflowVariables={workflowToShow.variables}
loops={workflowToShow.loops}
parallels={workflowToShow.parallels}
onClose={() => setExpandedSelectedBlockId(null)}
/>
)}

View File

@@ -488,7 +488,6 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
>
<WorkflowPreview
workflowState={workflowState}
showSubBlocks={false}
height='100%'
width='100%'
isPannable={false}
@@ -529,7 +528,6 @@ function TemplatePreviewContent({ existingTemplate }: TemplatePreviewContentProp
<WorkflowPreview
key={`template-preview-${existingTemplate.id}`}
workflowState={workflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import {
getCanonicalScopesForProvider,
@@ -26,6 +27,7 @@ import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('CredentialSelector')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
interface CredentialSelectorProps {
blockId: string
@@ -54,7 +56,7 @@ export function CredentialSelector({
const supportsCredentialSets = subBlock.supportsCredentialSets || false
const { data: organizationsData } = useOrganizations()
const { data: subscriptionData } = useSubscriptionData()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const activeOrganization = organizationsData?.activeOrganization
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise

View File

@@ -19,7 +19,9 @@ export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps)
const params = useParams()
const workflowId = params.workflowId as string
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone'))
const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as
| string
| undefined
const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, {
enabled: !isPreview,

View File

@@ -673,7 +673,7 @@ function WorkflowInputMapperSyncWrapper({
if (!workflowId) {
return (
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
Select a workflow to configure its inputs
</div>
)
@@ -681,15 +681,15 @@ function WorkflowInputMapperSyncWrapper({
if (isLoading) {
return (
<div className='flex items-center justify-center rounded-md border border-gray-600/50 bg-gray-900/20 p-8'>
<Loader2 className='h-5 w-5 animate-spin text-gray-400' />
<div className='flex items-center justify-center rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-8'>
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-muted)]' />
</div>
)
}
if (inputFields.length === 0) {
return (
<div className='rounded-md border border-gray-600/50 bg-gray-900/20 p-4 text-center text-gray-400 text-sm'>
<div className='rounded-md border border-[var(--border-1)] border-dashed bg-[var(--surface-3)] p-4 text-center text-[var(--text-muted)] text-sm'>
This workflow has no custom input fields
</div>
)
@@ -902,7 +902,22 @@ export function ToolInput({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
const { data: customTools = [] } = useCustomTools(workspaceId)
const value = isPreview ? previewValue : storeValue
const selectedTools: StoredTool[] =
Array.isArray(value) &&
value.length > 0 &&
value[0] !== null &&
typeof value[0]?.type === 'string'
? (value as StoredTool[])
: []
const hasReferenceOnlyCustomTools = selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
)
const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools
const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '')
const {
mcpTools,
@@ -918,24 +933,15 @@ export function ToolInput({
const mcpDataLoading = mcpLoading || mcpServersLoading
const hasRefreshedRef = useRef(false)
const value = isPreview ? previewValue : storeValue
const selectedTools: StoredTool[] =
Array.isArray(value) &&
value.length > 0 &&
value[0] !== null &&
typeof value[0]?.type === 'string'
? (value as StoredTool[])
: []
const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp')
useEffect(() => {
if (isPreview) return
if (hasMcpTools && !hasRefreshedRef.current) {
hasRefreshedRef.current = true
forceRefreshMcpTools(workspaceId)
}
}, [hasMcpTools, forceRefreshMcpTools, workspaceId])
}, [hasMcpTools, forceRefreshMcpTools, workspaceId, isPreview])
/**
* Returns issue info for an MCP tool.

View File

@@ -43,10 +43,12 @@ export function TriggerSave({
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl'))
const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as
| string
| null
const storedTestUrlExpiresAt = useSubBlockStore((state) =>
state.getValue(blockId, 'testUrlExpiresAt')
)
) as string | null
const isTestUrlExpired = useMemo(() => {
if (!storedTestUrlExpiresAt) return true

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { BookOpen, Check, ChevronUp, Pencil, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
import { BookOpen, Check, ChevronUp, Pencil, Settings } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -15,6 +15,8 @@ import {
useEditorBlockProperties,
useEditorSubblockLayout,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getBlock } from '@/blocks/registry'
@@ -58,9 +60,8 @@ export function Editor() {
const isSubflow =
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
// Get subflow display properties
const subflowIcon = isSubflow && currentBlock.type === 'loop' ? RepeatIcon : SplitIcon
const subflowBgColor = isSubflow && currentBlock.type === 'loop' ? '#2FB3FF' : '#FEE12B'
// Get subflow display properties from configs
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
// Refs for resize functionality
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -176,8 +177,9 @@ export function Editor() {
* Handles opening documentation link in a new secure tab.
*/
const handleOpenDocs = () => {
if (blockConfig?.docsLink) {
window.open(blockConfig.docsLink, '_blank', 'noopener,noreferrer')
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
if (docsLink) {
window.open(docsLink, '_blank', 'noopener,noreferrer')
}
}
@@ -195,10 +197,10 @@ export function Editor() {
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
<div
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px]'
style={{ background: isSubflow ? subflowBgColor : blockConfig?.bgColor }}
style={{ background: isSubflow ? subflowConfig?.bgColor : blockConfig?.bgColor }}
>
<IconComponent
icon={isSubflow ? subflowIcon : blockConfig?.icon}
icon={isSubflow ? subflowConfig?.icon : blockConfig?.icon}
className='h-[12px] w-[12px] text-[var(--white)]'
/>
</div>
@@ -295,7 +297,7 @@ export function Editor() {
</Tooltip.Content>
</Tooltip.Root>
)}
{currentBlock && !isSubflow && blockConfig?.docsLink && (
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
z-index: 9999;
`
// Create icon container
const iconContainer = document.createElement('div')
iconContainer.style.cssText = `
width: 24px;
@@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
flex-shrink: 0;
`
// Clone the actual icon if provided
if (info.iconElement) {
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
clonedIcon.style.width = '16px'
@@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
iconContainer.appendChild(clonedIcon)
}
// Create text element
const text = document.createElement('span')
text.textContent = info.name
text.style.cssText = `
color: #FFFFFF;
color: var(--text-primary);
font-size: 16px;
font-weight: 500;
white-space: nowrap;

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface ToolbarItemContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when add to canvas is clicked
*/
onAddToCanvas: () => void
/**
* Callback when view documentation is clicked
*/
onViewDocumentation?: () => void
/**
* Whether the view documentation option should be shown
*/
showViewDocumentation?: boolean
}
/**
* Context menu component for toolbar items (triggers and blocks).
* Displays options to add to canvas and view documentation.
*/
export function ToolbarItemContextMenu({
isOpen,
position,
menuRef,
onClose,
onAddToCanvas,
onViewDocumentation,
showViewDocumentation = false,
}: ToolbarItemContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onAddToCanvas()
onClose()
}}
>
Add to canvas
</PopoverItem>
{showViewDocumentation && onViewDocumentation && (
<PopoverItem
onClick={() => {
onViewDocumentation()
onClose()
}}
>
View documentation
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -17,6 +17,7 @@ import {
getTriggersForSidebar,
hasTriggerCapability,
} from '@/lib/workflows/triggers/trigger-utils'
import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components'
import {
calculateTriggerHeights,
useToolbarItemInteractions,
@@ -34,6 +35,7 @@ interface BlockItem {
config?: BlockConfig
icon?: any
bgColor?: string
docsLink?: string
}
/**
@@ -98,6 +100,7 @@ function getBlocks() {
type: LoopTool.type,
icon: LoopTool.icon,
bgColor: LoopTool.bgColor,
docsLink: LoopTool.docsLink,
isSpecial: true,
})
@@ -106,6 +109,7 @@ function getBlocks() {
type: ParallelTool.type,
icon: ParallelTool.icon,
bgColor: ParallelTool.bgColor,
docsLink: ParallelTool.docsLink,
isSpecial: true,
})
@@ -178,6 +182,16 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
// Toggle animation state
const [isToggling, setIsToggling] = useState(false)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const contextMenuRef = useRef<HTMLDivElement>(null)
const [activeItemInfo, setActiveItemInfo] = useState<{
type: string
isTrigger: boolean
docsLink?: string
} | null>(null)
// Toolbar store
const { toolbarTriggersHeight, setToolbarTriggersHeight, preSearchHeight, setPreSearchHeight } =
useToolbarStore()
@@ -338,6 +352,68 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
setIsToggling(false)
}, [])
/**
* Handle context menu for toolbar items
*/
const handleItemContextMenu = useCallback(
(e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setActiveItemInfo({ type, isTrigger, docsLink })
setIsContextMenuOpen(true)
},
[]
)
/**
* Close context menu and clear active item state
*/
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
setActiveItemInfo(null)
}, [])
/**
* Handle add to canvas from context menu
*/
const handleContextMenuAddToCanvas = useCallback(() => {
if (activeItemInfo) {
handleItemClick(activeItemInfo.type, activeItemInfo.isTrigger)
}
}, [activeItemInfo, handleItemClick])
/**
* Handle view documentation from context menu
*/
const handleViewDocumentation = useCallback(() => {
if (activeItemInfo?.docsLink) {
window.open(activeItemInfo.docsLink, '_blank', 'noopener,noreferrer')
}
}, [activeItemInfo])
/**
* Handle clicks outside the context menu to close it
*/
useEffect(() => {
if (!isContextMenuOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
closeContextMenu()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('click', handleClickOutside)
}
}, [isContextMenuOpen, closeContextMenu])
/**
* Handle keyboard navigation with ArrowUp / ArrowDown when the toolbar tab
* is active and search is open (e.g. after Mod+F). Navigation order:
@@ -553,6 +629,9 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
})
}}
onClick={() => handleItemClick(trigger.type, isTriggerCapable)}
onContextMenu={(e) =>
handleItemContextMenu(e, trigger.type, isTriggerCapable, trigger.docsLink)
}
className={clsx(
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
@@ -642,6 +721,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
document.body.classList.remove('sim-drag-subflow')
}}
onClick={() => handleItemClick(block.type, false)}
onContextMenu={(e) =>
handleItemContextMenu(
e,
block.type,
false,
block.docsLink ?? block.config?.docsLink
)
}
className={clsx(
'group flex h-[28px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]',
'cursor-pointer hover:bg-[var(--surface-6)] active:cursor-grabbing dark:hover:bg-[var(--surface-5)]',
@@ -685,6 +772,17 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
</div>
</div>
</div>
{/* Toolbar Item Context Menu */}
<ToolbarItemContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
onAddToCanvas={handleContextMenuAddToCanvas}
onViewDocumentation={handleViewDocumentation}
showViewDocumentation={Boolean(activeItemInfo?.docsLink)}
/>
</div>
)
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { PANEL_WIDTH } from '@/stores/constants'
import { usePanelStore } from '@/stores/panel/store'
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel/store'
* @returns Resize state and handlers
*/
export function usePanelResize() {
const { setPanelWidth } = usePanelStore()
const [isResizing, setIsResizing] = useState(false)
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
/**
* Handles mouse down on resize handle
*/
const handleMouseDown = useCallback(() => {
setIsResizing(true)
}, [])
}, [setIsResizing])
/**
* Setup resize event listeners and body styles when resizing
@@ -52,7 +51,7 @@ export function usePanelResize() {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setPanelWidth])
}, [isResizing, setPanelWidth, setIsResizing])
return {
isResizing,

View File

@@ -1,5 +1,8 @@
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useSubscriptionData } from '@/hooks/queries/subscription'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Simplified hook that uses React Query for usage limits.
* Provides usage exceeded status from existing subscription data.
@@ -12,7 +15,7 @@ export function useUsageLimits(options?: {
}) {
// For now, we only support user context via React Query
// Organization context should use useOrganizationBilling directly
const { data: subscriptionData, isLoading } = useSubscriptionData()
const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled })
const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false

View File

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

View File

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

View File

@@ -47,6 +47,8 @@ export interface SubflowNodeData {
parentId?: string
extent?: 'parent'
isPreview?: boolean
/** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean
kind: 'loop' | 'parallel'
name?: string
}
@@ -123,16 +125,18 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
const isPreviewSelected = data?.isPreviewSelected || false
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor) - blue ring
* 1. Focused (selected in editor) or preview selected - blue ring
* 2. Diff status (version comparison) - green/orange ring
*/
const hasRing = isFocused || diffStatus === 'new' || diffStatus === 'edited'
const hasRing = isFocused || isPreviewSelected || diffStatus === 'new' || diffStatus === 'edited'
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
isFocused && 'ring-[var(--brand-secondary)]',
diffStatus === 'new' && 'ring-[#22C55F]',
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
diffStatus === 'edited' && 'ring-[var(--warning)]'
)

View File

@@ -31,6 +31,7 @@ interface LogRowContextMenuProps {
onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onCopyRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
hasActiveFilters: boolean
@@ -50,6 +51,7 @@ export function LogRowContextMenu({
onFilterByBlock,
onFilterByStatus,
onFilterByRunId,
onCopyRunId,
onClearFilters,
onClearConsole,
hasActiveFilters,
@@ -64,7 +66,7 @@ export function LogRowContextMenu({
return (
<Popover
open={isOpen}
onOpenChange={onClose}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
@@ -79,18 +81,18 @@ export function LogRowContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Clear filters at top when active */}
{hasActiveFilters && (
{/* Copy actions */}
{entry && hasRunId && (
<>
<PopoverItem
onClick={() => {
onClearFilters()
onCopyRunId(entry.executionId!)
onClose()
}}
>
Clear All Filters
Copy Run ID
</PopoverItem>
{entry && <PopoverDivider />}
<PopoverDivider />
</>
)}
@@ -129,6 +131,18 @@ export function LogRowContextMenu({
</>
)}
{/* Clear filters */}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
)}
{/* Destructive action */}
{(entry || hasActiveFilters) && <PopoverDivider />}
<PopoverItem

View File

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

View File

@@ -49,6 +49,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -305,6 +306,7 @@ export function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const isTerminalFocusedRef = useRef(false)
const {
setTerminalHeight,
lastExpandedHeight,
@@ -337,27 +339,34 @@ export function Terminal() {
const [mainOptionsOpen, setMainOptionsOpen] = useState(false)
const [outputOptionsOpen, setOutputOptionsOpen] = useState(false)
// Output panel search state
const [isOutputSearchActive, setIsOutputSearchActive] = useState(false)
const [outputSearchQuery, setOutputSearchQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
const outputSearchInputRef = useRef<HTMLInputElement>(null)
const outputContentRef = useRef<HTMLDivElement>(null)
const {
isSearchActive: isOutputSearchActive,
searchQuery: outputSearchQuery,
setSearchQuery: setOutputSearchQuery,
matchCount,
currentMatchIndex,
activateSearch: activateOutputSearch,
closeSearch: closeOutputSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef: outputSearchInputRef,
} = useCodeViewerFeatures({
contentRef: outputContentRef,
externalWrapText: wrapText,
onWrapTextChange: setWrapText,
})
// Training controls state
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore()
// Playground state
const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false)
// Terminal resize hooks
const { handleMouseDown } = useTerminalResize()
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
// Terminal filters hook
const {
filters,
sortConfig,
@@ -370,12 +379,10 @@ export function Terminal() {
hasActiveFilters,
} = useTerminalFilters()
// Context menu state
const [hasSelection, setHasSelection] = useState(false)
const [contextMenuEntry, setContextMenuEntry] = useState<ConsoleEntry | null>(null)
const [storedSelectionText, setStoredSelectionText] = useState('')
// Context menu hooks
const {
isOpen: isLogRowMenuOpen,
position: logRowMenuPosition,
@@ -534,8 +541,11 @@ 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)
@@ -556,6 +566,26 @@ 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
@@ -577,44 +607,6 @@ export function Terminal() {
}
}, [activeWorkflowId, clearWorkflowConsole])
const activateOutputSearch = useCallback(() => {
setIsOutputSearchActive(true)
setTimeout(() => {
outputSearchInputRef.current?.focus()
}, 0)
}, [])
const closeOutputSearch = useCallback(() => {
setIsOutputSearchActive(false)
setOutputSearchQuery('')
setMatchCount(0)
setCurrentMatchIndex(0)
}, [])
/**
* Navigates to the next match in the search results.
*/
const goToNextMatch = useCallback(() => {
if (matchCount === 0) return
setCurrentMatchIndex((prev) => (prev + 1) % matchCount)
}, [matchCount])
/**
* Navigates to the previous match in the search results.
*/
const goToPreviousMatch = useCallback(() => {
if (matchCount === 0) return
setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount)
}, [matchCount])
/**
* Handles match count change from Code.Viewer.
*/
const handleMatchCountChange = useCallback((count: number) => {
setMatchCount(count)
setCurrentMatchIndex(0)
}, [])
const handleClearConsole = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -683,6 +675,14 @@ export function Terminal() {
[toggleRunId, closeLogRowMenu]
)
const handleCopyRunId = useCallback(
(runId: string) => {
navigator.clipboard.writeText(runId)
closeLogRowMenu()
},
[closeLogRowMenu]
)
const handleClearConsoleFromMenu = useCallback(() => {
clearCurrentWorkflowConsole()
}, [clearCurrentWorkflowConsole])
@@ -816,9 +816,12 @@ 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(
@@ -853,9 +856,12 @@ 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
@@ -885,66 +891,20 @@ export function Terminal() {
}, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded])
/**
* Handle Escape to close search or unselect entry
* Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures)
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && !isOutputSearchActive && selectedEntry) {
e.preventDefault()
// First close search if active
if (isOutputSearchActive) {
closeOutputSearch()
return
}
// Then unselect entry
if (selectedEntry) {
setSelectedEntry(null)
setAutoSelectEnabled(true)
}
setSelectedEntry(null)
setAutoSelectEnabled(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEntry, isOutputSearchActive, closeOutputSearch])
/**
* Handle Enter/Shift+Enter for search navigation when search input is focused
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOutputSearchActive) return
const isSearchInputFocused = document.activeElement === outputSearchInputRef.current
if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) {
e.preventDefault()
if (e.shiftKey) {
goToPreviousMatch()
} else {
goToNextMatch()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOutputSearchActive, matchCount, goToNextMatch, goToPreviousMatch])
/**
* Scroll to current match when it changes
*/
useEffect(() => {
if (!isOutputSearchActive || matchCount === 0 || !outputContentRef.current) return
// Find all match elements and scroll to the current one
const matchElements = outputContentRef.current.querySelectorAll('[data-search-match]')
const currentElement = matchElements[currentMatchIndex]
if (currentElement) {
currentElement.scrollIntoView({ block: 'center' })
}
}, [currentMatchIndex, isOutputSearchActive, matchCount])
}, [selectedEntry, isOutputSearchActive])
/**
* Adjust output panel width when sidebar or panel width changes.
@@ -1006,6 +966,9 @@ 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'>
@@ -1414,25 +1377,16 @@ export function Terminal() {
</div>
{/* Run ID */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className={clsx(
COLUMN_WIDTHS.RUN_ID,
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
</Tooltip.Trigger>
{entry.executionId && (
<Tooltip.Content>
<span className='font-mono text-[11px]'>{entry.executionId}</span>
</Tooltip.Content>
<span
className={clsx(
COLUMN_WIDTHS.RUN_ID,
COLUMN_BASE_CLASS,
'truncate font-medium font-mono text-[12px]'
)}
</Tooltip.Root>
style={{ color: runIdColor?.text || '#D2D2D2' }}
>
{formatRunId(entry.executionId)}
</span>
{/* Duration */}
<span
@@ -1489,9 +1443,7 @@ export function Terminal() {
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput &&
hasInputData &&
'!text-[var(--text-primary)] dark:!text-[var(--text-primary)]'
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
@@ -1509,7 +1461,7 @@ export function Terminal() {
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput && '!text-[var(--text-primary)]'
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
)}
onClick={(e) => {
e.stopPropagation()
@@ -1839,6 +1791,7 @@ 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,
collaborativeToggleBlockEnabled,
collaborativeToggleBlockHandles,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
} = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const blocks = useWorkflowStore((state) => state.blocks)
@@ -121,7 +121,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockEnabled(blockId)
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
@@ -161,29 +161,6 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[11px] w-[11px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
</Tooltip.Root>
)}
{!isNoteBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -192,7 +169,7 @@ export const ActionBar = memo(
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
@@ -211,6 +188,29 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
)
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[11px] w-[11px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -54,9 +54,11 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI
useCallback(
(state) => {
if (!activeWorkflowId) return undefined
return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider?.value as
| string
| undefined
const value = state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider
if (typeof value === 'object' && value !== null && 'value' in value) {
return (value as { value?: unknown }).value as string | undefined
}
return value as string | undefined
},
[activeWorkflowId, blockId]
)

View File

@@ -10,6 +10,8 @@ export interface WorkflowBlockProps {
isActive?: boolean
isPending?: boolean
isPreview?: boolean
/** Whether this block is selected in preview mode */
isPreviewSelected?: boolean
subBlockValues?: Record<string, any>
blockState?: any
}

View File

@@ -32,6 +32,7 @@ export function shouldSkipBlockRender(
prevProps.data.isActive === nextProps.data.isActive &&
prevProps.data.isPending === nextProps.data.isPending &&
prevProps.data.isPreview === nextProps.data.isPreview &&
prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected &&
prevProps.data.config === nextProps.data.config &&
prevProps.data.subBlockValues === nextProps.data.subBlockValues &&
prevProps.data.blockState === nextProps.data.blockState &&

View File

@@ -199,8 +199,9 @@ 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.
*/
const getDisplayValue = (value: unknown): string => {
export const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
// Try parsing JSON strings first
@@ -624,7 +625,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
if (!activeWorkflowId) return
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
if (!current) return
const cred = current.credential?.value as string | undefined
const credValue = current.credential
const cred =
typeof credValue === 'object' && credValue !== null && 'value' in credValue
? ((credValue as { value?: unknown }).value as string | undefined)
: (credValue as string | undefined)
if (prevCredRef.current !== cred) {
prevCredRef.current = cred
const keys = Object.keys(current)

View File

@@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({
})
const isSelected = data?.isSelected ?? false
const isInsideLoop = data?.isInsideLoop ?? false
const parentLoopId = data?.parentLoopId
// Combined store subscription to reduce subscription overhead
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
useShallow((state) => ({
diffAnalysis: state.diffAnalysis,
@@ -96,9 +93,10 @@ const WorkflowEdgeComponent = ({
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary)'
color = 'var(--brand-tertiary-2)'
} else if (edgeRunStatus === 'success') {
color = 'var(--border-success)'
// Use green for preview mode, default for canvas execution
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
} else if (edgeRunStatus === 'error') {
color = 'var(--text-error)'
}
@@ -120,34 +118,18 @@ const WorkflowEdgeComponent = ({
strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
opacity,
}
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus])
}, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
return (
<>
<BaseEdge
path={edgePath}
data-testid='workflow-edge'
style={edgeStyle}
interactionWidth={30}
data-edge-id={id}
data-parent-loop-id={parentLoopId}
data-is-selected={isSelected ? 'true' : 'false'}
data-is-inside-loop={isInsideLoop ? 'true' : 'false'}
/>
{/* Animate dash offset for edge movement effect */}
<animate
attributeName='stroke-dashoffset'
from={edgeDiffStatus === 'deleted' ? '15' : '10'}
to='0'
dur={edgeDiffStatus === 'deleted' ? '2s' : '1s'}
repeatCount='indefinite'
/>
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
{isSelected && (
<EdgeLabelRenderer>
<div
className='nodrag nopan group flex h-[22px] w-[22px] cursor-pointer items-center justify-center transition-colors'
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 100,

View File

@@ -1,8 +1,17 @@
export {
clearDragHighlights,
computeClampedPositionUpdates,
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
selectNodesDeferred,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
export { useAutoLayout } from './use-auto-layout'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { useBlockVisual } from './use-block-visual'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
export { useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'

View File

@@ -21,14 +21,15 @@ interface UseBlockVisualProps {
/**
* Provides visual state and interaction handlers for workflow blocks.
* Computes ring styling based on execution, focus, diff, and run path states.
* In preview mode, all interactive and execution-related visual states are disabled.
* Computes ring styling based on execution, diff, deletion, and run path states.
* In preview mode, uses isPreviewSelected for selection highlighting.
*
* @param props - The hook properties
* @returns Visual state, click handler, and ring styling for the block
*/
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
const isPreview = data.isPreview ?? false
const isPreviewSelected = data.isPreviewSelected ?? false
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -40,14 +41,13 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
isDeletedBlock,
} = useBlockState(blockId, currentWorkflow, data)
const isActive = isPreview ? false : blockIsActive
// In preview mode, use isPreviewSelected for selection state
const isActive = isPreview ? isPreviewSelected : blockIsActive
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = isPreview ? false : currentBlockId === blockId
const handleClick = useCallback(() => {
if (!isPreview) {
@@ -60,12 +60,12 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
getBlockRingStyles({
isActive,
isPending: isPreview ? false : isPending,
isFocused,
isDeletedBlock: isPreview ? false : isDeletedBlock,
diffStatus: isPreview ? undefined : diffStatus,
runPathStatus,
isPreviewSelection: isPreview && isPreviewSelected,
}),
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
)
return {

View File

@@ -16,8 +16,6 @@ export interface CurrentWorkflow {
loops: Record<string, Loop>
parallels: Record<string, Parallel>
lastSaved?: number
isDeployed?: boolean
deployedAt?: Date
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
@@ -50,8 +48,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
isDeployed: state.isDeployed,
deployedAt: state.deployedAt,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}))
@@ -82,8 +78,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
lastSaved: activeWorkflow.lastSaved,
isDeployed: activeWorkflow.isDeployed,
deployedAt: activeWorkflow.deployedAt,
deploymentStatuses: activeWorkflow.deploymentStatuses,
needsRedeployment: activeWorkflow.needsRedeployment,

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { useReactFlow } from 'reactflow'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('NodeUtilities')
@@ -208,28 +209,30 @@ export function useNodeUtilities(blocks: Record<string, any>) {
* to the content area bounds (after header and padding).
* @param nodeId ID of the node being repositioned
* @param newParentId ID of the new parent
* @param skipClamping If true, returns raw relative position without clamping to container bounds
* @returns Relative position coordinates {x, y} within the parent
*/
const calculateRelativePosition = useCallback(
(nodeId: string, newParentId: string): { x: number; y: number } => {
(nodeId: string, newParentId: string, skipClamping?: boolean): { x: number; y: number } => {
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId)
const parentNode = getNodes().find((n) => n.id === newParentId)
// Calculate raw relative position (relative to parent origin)
const rawPosition = {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
}
// Get container and block dimensions
if (skipClamping) {
return rawPosition
}
const parentNode = getNodes().find((n) => n.id === newParentId)
const containerDimensions = {
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = getBlockDimensions(nodeId)
// Clamp position to keep block inside content area
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
},
[getNodeAbsolutePosition, getNodes, getBlockDimensions]
@@ -298,12 +301,12 @@ export function useNodeUtilities(blocks: Record<string, any>) {
*/
const calculateLoopDimensions = useCallback(
(nodeId: string): { width: number; height: number } => {
// Check both React Flow's node.parentId AND blocks store's data.parentId
// This ensures we catch children even if React Flow hasn't re-rendered yet
const childNodes = getNodes().filter(
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
const currentBlocks = useWorkflowStore.getState().blocks
const childBlockIds = Object.keys(currentBlocks).filter(
(id) => currentBlocks[id]?.data?.parentId === nodeId
)
if (childNodes.length === 0) {
if (childBlockIds.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
@@ -313,30 +316,28 @@ export function useNodeUtilities(blocks: Record<string, any>) {
let maxRight = 0
let maxBottom = 0
childNodes.forEach((node) => {
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
// Use block position from store if available (more up-to-date)
const block = blocks[node.id]
const position = block?.position || node.position
maxRight = Math.max(maxRight, position.x + nodeWidth)
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
})
for (const childId of childBlockIds) {
const child = currentBlocks[childId]
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getBlockDimensions(childId)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
},
[getNodes, getBlockDimensions, blocks]
[getBlockDimensions]
)
/**
@@ -345,29 +346,27 @@ export function useNodeUtilities(blocks: Record<string, any>) {
*/
const resizeLoopNodes = useCallback(
(updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => {
const containerNodes = getNodes()
.filter((node) => node.type && isContainerType(node.type))
.map((node) => ({
...node,
depth: getNodeDepth(node.id),
const currentBlocks = useWorkflowStore.getState().blocks
const containerBlocks = Object.entries(currentBlocks)
.filter(([, block]) => block?.type && isContainerType(block.type))
.map(([id, block]) => ({
id,
block,
depth: getNodeDepth(id),
}))
// Sort by depth descending - process innermost containers first
// so their dimensions are correct when outer containers calculate sizes
.sort((a, b) => b.depth - a.depth)
containerNodes.forEach((node) => {
const dimensions = calculateLoopDimensions(node.id)
// Get current dimensions from the blocks store rather than React Flow's potentially stale state
const currentWidth = blocks[node.id]?.data?.width
const currentHeight = blocks[node.id]?.data?.height
for (const { id, block } of containerBlocks) {
const dimensions = calculateLoopDimensions(id)
const currentWidth = block?.data?.width
const currentHeight = block?.data?.height
// Only update if dimensions actually changed to avoid unnecessary re-renders
if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) {
updateNodeDimensions(node.id, dimensions)
updateNodeDimensions(id, dimensions)
}
})
}
},
[getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks]
[isContainerType, getNodeDepth, calculateLoopDimensions]
)
/**

View File

@@ -117,7 +117,6 @@ export async function applyAutoLayoutAndUpdateStore(
const cleanedWorkflowState = {
...stateToSave,
deployedAt: stateToSave.deployedAt ? new Date(stateToSave.deployedAt) : undefined,
loops: stateToSave.loops || {},
parallels: stateToSave.parallels || {},
edges: (stateToSave.edges || []).map((edge: any) => {

View File

@@ -7,66 +7,64 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined
export interface BlockRingOptions {
isActive: boolean
isPending: boolean
isFocused: boolean
isDeletedBlock: boolean
diffStatus: BlockDiffStatus
runPathStatus: BlockRunPathStatus
isPreviewSelection?: boolean
}
/**
* Derives visual ring visibility and class names for workflow blocks
* based on execution, focus, diff, deletion, and run-path states.
* based on execution, diff, deletion, and run-path states.
*/
export function getBlockRingStyles(options: BlockRingOptions): {
hasRing: boolean
ringClassName: string
} {
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
options
const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock ||
!!runPathStatus
const ringClassName = cn(
// Preview selection: static blue ring (standard thickness, no animation)
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
// Executing block: pulsing success ring with prominent thickness
isActive && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
isActive &&
!isPreviewSelection &&
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
// Non-active states use standard ring utilities
!isActive && hasRing && 'ring-[1.75px]',
// Pending state: warning ring
!isActive && isPending && 'ring-[var(--warning)]',
// Focused (selected) state: brand ring
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
// Deleted state (highest priority after active/pending/focused)
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
// Deleted state (highest priority after active/pending)
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
// Diff states
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'new' &&
'ring-[var(--brand-tertiary)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
diffStatus === 'edited' &&
'ring-[var(--warning)]',
// Run path states (lowest priority - only show if no other states active)
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'success' &&
'ring-[var(--border-success)]',
!isActive &&
!isPending &&
!isFocused &&
!isDeletedBlock &&
!diffStatus &&
runPathStatus === 'error' &&

View File

@@ -0,0 +1,181 @@
import type { Edge, Node } from 'reactflow'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Checks if the currently focused element is an editable input.
* Returns true if the user is typing in an input, textarea, or contenteditable element.
*/
export function isInEditableElement(): boolean {
const activeElement = document.activeElement
return (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable') === true
)
}
interface TriggerValidationResult {
isValid: boolean
message?: string
}
/**
* Validates that pasting/duplicating trigger blocks won't violate constraints.
* Returns validation result with error message if invalid.
*/
export function validateTriggerPaste(
blocksToAdd: Array<{ type: string }>,
existingBlocks: Record<string, BlockState>,
action: 'paste' | 'duplicate'
): TriggerValidationResult {
for (const block of blocksToAdd) {
if (TriggerUtils.isAnyTriggerType(block.type)) {
const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type)
if (issue) {
const actionText = action === 'paste' ? 'paste' : 'duplicate'
const message =
issue.issue === 'legacy'
? `Cannot ${actionText} trigger blocks when a legacy Start block exists.`
: `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
return { isValid: false, message }
}
}
}
return { isValid: true }
}
/**
* Clears drag highlight classes and resets cursor state.
* Used when drag operations end or are cancelled.
*/
export function clearDragHighlights(): void {
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
})
document.body.style.cursor = ''
}
/**
* Selects nodes by their IDs after paste/duplicate operations.
* Defers selection to next animation frame to allow displayNodes to sync from store first.
* This is necessary because the component uses controlled state (nodes={displayNodes})
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
*/
export function selectNodesDeferred(
nodeIds: string[],
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
): void {
const idsSet = new Set(nodeIds)
requestAnimationFrame(() => {
setDisplayNodes((nodes) =>
nodes.map((node) => ({
...node,
selected: idsSet.has(node.id),
}))
)
})
}
interface BlockData {
height?: number
data?: {
parentId?: string
width?: number
height?: number
}
}
/**
* Calculates the final position for a node, clamping it to parent container if needed.
* Returns the clamped position suitable for persistence.
*/
export function getClampedPositionForNode(
nodeId: string,
nodePosition: { x: number; y: number },
blocks: Record<string, BlockData>,
allNodes: Node[]
): { x: number; y: number } {
const currentBlock = blocks[nodeId]
const currentParentId = currentBlock?.data?.parentId
if (!currentParentId) {
return nodePosition
}
const parentNode = allNodes.find((n) => n.id === currentParentId)
if (!parentNode) {
return nodePosition
}
const containerDimensions = {
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions)
}
/**
* Computes position updates for multiple nodes, clamping each to its parent container.
* Used for batch position updates after multi-node drag or selection drag.
*/
export function computeClampedPositionUpdates(
nodes: Node[],
blocks: Record<string, BlockData>,
allNodes: Node[]
): Array<{ id: string; position: { x: number; y: number } }> {
return nodes.map((node) => ({
id: node.id,
position: getClampedPositionForNode(node.id, node.position, blocks, allNodes),
}))
}
interface ParentUpdateEntry {
blockId: string
newParentId: string
affectedEdges: Edge[]
}
/**
* Computes parent update entries for nodes being moved into a subflow.
* Only includes "boundary edges" - edges that cross the selection boundary
* (one end inside selection, one end outside). Edges between nodes in the
* selection are preserved.
*/
export function computeParentUpdateEntries(
validNodes: Node[],
allEdges: Edge[],
targetParentId: string
): ParentUpdateEntry[] {
const movingNodeIds = new Set(validNodes.map((n) => n.id))
// Find edges that cross the boundary (one end inside selection, one end outside)
// Edges between nodes in the selection should stay intact
const boundaryEdges = allEdges.filter((e) => {
const sourceInSelection = movingNodeIds.has(e.source)
const targetInSelection = movingNodeIds.has(e.target)
// Only remove if exactly one end is in the selection (crosses boundary)
return sourceInSelection !== targetInSelection
})
// Build updates for all valid nodes
return validNodes.map((n) => {
// Only include boundary edges connected to this specific node
const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id)
return {
blockId: n.id,
newParentId: targetParentId,
affectedEdges: edgesForThisNode,
}
})
}

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