Compare commits

...

34 Commits

Author SHA1 Message Date
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
ba2377f83b feat(combobox): added expression support to combobox (#2697)
* feat(combobox): added expression support to combobox

* fix chat messages styling in light mode

* last sec stuff

* ack comments
2026-01-06 16:01:32 -08:00
Siddharth Ganesan
f502f984f3 improvement(router): add ports to router block (#2683)
* Add ports to router block

* Add tag dropdowns

* Fix lint

* fix tests + add context into block preview

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-06 15:44:25 -08:00
Waleed
74f371cc79 fix(build): fix type assertion (#2696)
* fix(build): fix type assertion

* ack PR comment

* more
2026-01-06 15:01:55 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
d248557042 fix(settings): added isHosted gate to access homepage from settings, fixed context menu options (#2694)
* fix(settings): added isHosted gate to access homepage from settings, fixed context menu options

* stronger typing
2026-01-06 14:22:48 -08:00
Siddharth Ganesan
8215a819e5 improvement(hitl): add webhook notification and resume, add webhook block (#2673)
* Add api blcok as tool

* Add webhook block

* Hitl v1

* Cleanup

* Fix

* Update names for fields in hitl

* Fix hitl tag dropdown

* Update hitl dashboard

* Lint
2026-01-06 13:58:44 -08:00
Waleed
155f544ce8 feat(terminal): added terminal context menu (#2692) 2026-01-06 13:57:04 -08:00
Waleed
22f949a41c fix(condition): added success check on condition block processor, fixed terminal preventDefault copy bug (#2691) 2026-01-06 12:52:40 -08:00
Waleed
f9aef6ae22 feat(i18n): update translations (#2690)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2026-01-06 12:42:19 -08:00
Waleed
46b04a964d feat(supabase): added ability so select certain rows in supabase tools (#2689)
* feat(supabase): added ability so select certain rows in supabase tools

* ack PR comments
2026-01-06 12:21:08 -08:00
Waleed
964b40de45 fix(traces): remove child trace spans from workflow block after being merged with parent output (#2688) 2026-01-05 20:34:45 -08:00
Waleed
75aca00b6e improvement(kb): optimize processes, add more robust fallbacks for large file ops (#2684)
* improvement(kb): optimize processes, add more robust fallbacks for large file ops

* stronger typing

* comments cleanup

* ack PR comments

* upgraded turborepo

* ack more PR comments

* fix failing test

* moved doc update inside tx for embeddings chunks upload

* ack more PR comments
2026-01-05 20:26:16 -08:00
Vikhyath Mondreti
d25084e05d fix(child-workflow): hosted api key resolution (#2687) 2026-01-05 17:41:58 -08:00
Adam Gough
445932c1c8 fix(grain): fixed output and dropdown (#2685)
* fixed output and dropdown

* changed payload

* removed comments
2026-01-05 17:05:45 -08:00
Waleed
cc3f565d5e fix(webhook): strip extraneous fields from trigger processing (#2686) 2026-01-05 16:19:49 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
0977ed228f improvement(kb): add configurable concurrency to chunks processing, sped up 22x for large docs (#2681) 2026-01-05 00:29:31 -08:00
Waleed
ed6b9c0c4a fix(kb): fix styling inconsistencies, add rename capability for documents, added search preview (#2680) 2026-01-04 23:47:54 -08:00
Adam Gough
86bcdcf0d3 fix(grain): save before deploying workflow (#2678)
* save before deployment fix

* moved to helper

* removed comment
2026-01-04 12:41:33 -08:00
Vikhyath Mondreti
ac942416de fix(kalshi): remove synthetically constructed outputs (#2677)
* fix(kalshi): remove synthetically constructed outputs

* fix api interface
2026-01-03 17:40:55 -08:00
Emir Karabeg
195e0e8e3f feat(popover): sections; improvement: tooltip, popover; fix(notifications): loading content (#2676) 2026-01-03 16:51:24 -08:00
Siddharth Ganesan
1673ef98ac fix(variables): fix variables block parsing error for json (#2675) 2026-01-03 14:42:39 -08:00
Siddharth Ganesan
356b473dc3 fix(import): fix missing blocks in import if undefined keys exist (#2674) 2026-01-03 14:37:59 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

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

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

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

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

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

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

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

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

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

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

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

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

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

* feat(admin): routes to manage deployments

* fix naming fo deployed by

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

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

* removed unused params, cleaned up redundant utils

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

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Emir Karabeg
8d15219c12 improvement(invite): aligned styling (#2669)
* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments
2026-01-02 19:45:10 -08:00
Waleed
c3adcf315b feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)
* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils
2026-01-02 18:46:39 -08:00
Vikhyath Mondreti
4df5d56ac5 feat(admin): routes to manage deployments (#2667)
* feat(admin): routes to manage deployments

* fix naming fo deployed by
2026-01-02 17:58:19 -08:00
Adam Gough
7515809df0 fix(grain): updated grain trigger to auto-establish trigger (#2666)
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-02 17:56:06 -08:00
Waleed
385e93f4bb feat(i18n): update translations (#2665)
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
2026-01-02 15:40:15 -08:00
Waleed
096af4fdfa feat(imap): added support for imap trigger (#2663)
* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup
2026-01-02 15:28:00 -08:00
Vikhyath Mondreti
dc3de95c39 fix(logging): hitl + trigger dev crash protection (#2664)
* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture
2026-01-02 14:01:01 -08:00
Emir Karabeg
79be435918 feat(email): welcome email; improvement(emails): ui/ux (#2658)
* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>
2026-01-02 00:16:27 -08:00
Waleed
852562cfdd feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659) 2026-01-01 13:47:30 -08:00
257 changed files with 10859 additions and 5168 deletions

View File

@@ -30,6 +30,18 @@ import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/component
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
```
## No Re-exports
Do not re-export from non-barrel files. Import directly from the source.
```typescript
// ✓ Good - import from where it's declared
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
// ✗ Bad - re-exporting in utils.ts then importing from there
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
```
## Import Order
1. React/core libraries

View File

@@ -9,7 +9,7 @@ globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
1. **No inline styles** - Use Tailwind classes
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
3. **Exact values** - `text-[14px]`, `h-[25px]`
3. **Exact values** - `text-[14px]`, `h-[26px]`
4. **Transitions** - `transition-colors` for interactive states
## Conditional Classes

View File

@@ -52,7 +52,7 @@ import { useWorkflowStore } from '@/stores/workflows/store'
import { useWorkflowStore } from '../../../stores/workflows/store'
```
Use barrel exports (`index.ts`) when a folder has 3+ exports.
Use barrel exports (`index.ts`) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
### Import Order
1. React/core libraries

View File

@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Sim Studio, Inc.
Copyright 2026 Sim Studio, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

2
NOTICE
View File

@@ -1,4 +1,4 @@
Sim Studio
Copyright 2025 Sim Studio
Copyright 2026 Sim Studio
This product includes software developed for the Sim project.

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@ import {
LinkupIcon,
MailchimpIcon,
MailgunIcon,
MailServerIcon,
Mem0Icon,
MicrosoftExcelIcon,
MicrosoftOneDriveIcon,
@@ -165,6 +166,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
huggingface: HuggingFaceIcon,
hunter: HunterIOIcon,
image_generator: ImageIcon,
imap: MailServerIcon,
incidentio: IncidentioIcon,
intercom: IntercomIcon,
jina: JinaAIIcon,

View File

@@ -0,0 +1,36 @@
---
title: IMAP-E-Mail
description: Workflows auslösen, wenn neue E-Mails über IMAP eintreffen
(funktioniert mit jedem E-Mail-Anbieter)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
Der IMAP-E-Mail-Trigger ermöglicht es Ihren Sim-Workflows, automatisch zu starten, sobald eine neue E-Mail in einem Postfach empfangen wird, das das IMAP-Protokoll unterstützt. Dies funktioniert mit Gmail, Outlook, Yahoo und den meisten anderen E-Mail-Anbietern.
Mit dem IMAP-Trigger können Sie:
- **E-Mail-Verarbeitung automatisieren**: Starten Sie Workflows in Echtzeit, wenn neue Nachrichten in Ihrem Posteingang eintreffen.
- **Nach Absender, Betreff oder Ordner filtern**: Konfigurieren Sie Ihren Trigger so, dass er nur auf E-Mails reagiert, die bestimmte Bedingungen erfüllen.
- **Anhänge extrahieren und verarbeiten**: Laden Sie Dateianhänge automatisch herunter und verwenden Sie sie in Ihren automatisierten Abläufen.
- **E-Mail-Inhalte parsen und verwenden**: Greifen Sie auf Betreff, Absender, Empfänger, vollständigen Text und andere Metadaten in nachfolgenden Workflow-Schritten zu.
- **Mit jedem E-Mail-Anbieter integrieren**: Funktioniert mit jedem Dienst, der standardmäßigen IMAP-Zugriff bietet, ohne Vendor-Lock-in.
- **Bei ungelesenen, markierten oder benutzerdefinierten Kriterien auslösen**: Richten Sie erweiterte Filter für die Arten von E-Mails ein, die Ihre Workflows starten.
Mit Sim gibt Ihnen die IMAP-Integration die Möglichkeit, E-Mails in eine handlungsfähige Automatisierungsquelle zu verwandeln. Reagieren Sie auf Kundenanfragen, verarbeiten Sie Benachrichtigungen, starten Sie Daten-Pipelines und mehr direkt aus Ihrem E-Mail-Posteingang, ohne manuelles Eingreifen.
{/* MANUAL-CONTENT-END */}
## Nutzungsanleitung
Verbinden Sie sich über das IMAP-Protokoll mit jedem E-Mail-Server, um Workflows auszulösen, wenn neue E-Mails empfangen werden. Unterstützt Gmail, Outlook, Yahoo und jeden anderen IMAP-kompatiblen E-Mail-Anbieter.
## Hinweise
- Kategorie: `triggers`
- Typ: `imap`

View File

@@ -123,8 +123,6 @@ Kontostand und Portfoliowert von Kalshi abrufen
| --------- | ---- | ----------- |
| `balance` | number | Kontostand in Cent |
| `portfolioValue` | number | Portfoliowert in Cent |
| `balanceDollars` | number | Kontostand in Dollar |
| `portfolioValueDollars` | number | Portfoliowert in Dollar |
### `kalshi_get_positions`

View File

@@ -47,10 +47,11 @@ Daten aus einer Supabase-Tabelle abfragen
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z. B. jdrkgepadsdopsntdlom\) |
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z.B. jdrkgepadsdopsntdlom\) |
| `table` | string | Ja | Der Name der abzufragenden Supabase-Tabelle |
| `schema` | string | Nein | Datenbankschema für die Abfrage \(Standard: public\). Verwenden Sie dies, um auf Tabellen in anderen Schemas zuzugreifen. |
| `filter` | string | Nein | PostgREST-Filter \(z. B. "id=eq.123"\) |
| `select` | string | Nein | Zurückzugebende Spalten \(durch Komma getrennt\). Standard ist * \(alle Spalten\) |
| `filter` | string | Nein | PostgREST-Filter \(z.B. "id=eq.123"\) |
| `orderBy` | string | Nein | Spalte zum Sortieren \(fügen Sie DESC für absteigende Sortierung hinzu\) |
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Zeilen |
| `apiKey` | string | Ja | Ihr Supabase Service Role Secret Key |
@@ -91,10 +92,11 @@ Eine einzelne Zeile aus einer Supabase-Tabelle basierend auf Filterkriterien abr
| Parameter | Typ | Erforderlich | Beschreibung |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z. B. jdrkgepadsdopsntdlom\) |
| `projectId` | string | Ja | Ihre Supabase-Projekt-ID \(z.B. jdrkgepadsdopsntdlom\) |
| `table` | string | Ja | Der Name der abzufragenden Supabase-Tabelle |
| `schema` | string | Nein | Datenbankschema für die Abfrage \(Standard: public\). Verwenden Sie dies, um auf Tabellen in anderen Schemas zuzugreifen. |
| `filter` | string | Ja | PostgREST-Filter zum Auffinden der spezifischen Zeile \(z. B. "id=eq.123"\) |
| `select` | string | Nein | Zurückzugebende Spalten \(durch Komma getrennt\). Standard ist * \(alle Spalten\) |
| `filter` | string | Ja | PostgREST-Filter zum Finden der spezifischen Zeile \(z.B. "id=eq.123"\) |
| `apiKey` | string | Ja | Ihr Supabase Service Role Secret Key |
#### Ausgabe

View File

@@ -0,0 +1,40 @@
---
title: IMAP Email
description: Trigger workflows when new emails arrive via IMAP (works with any email provider)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
The IMAP Email trigger allows your Sim workflows to start automatically whenever a new email is received in any mailbox that supports the IMAP protocol. This works with Gmail, Outlook, Yahoo, and most other email providers.
With the IMAP trigger, you can:
- **Automate email processing**: Start workflows in real time when new messages arrive in your inbox.
- **Filter by sender, subject, or folder**: Configure your trigger to react only to emails that match certain conditions.
- **Extract and process attachments**: Automatically download and use file attachments in your automated flows.
- **Parse and use email content**: Access the subject, sender, recipients, full body, and other metadata in downstream workflow steps.
- **Integrate with any email provider**: Works with any service that provides standard IMAP access, without vendor lock-in.
- **Trigger on unread, flagged, or custom criteria**: Set up advanced filters for the kinds of emails that start your workflows.
With Sim, the IMAP integration gives you the power to turn email into an actionable source of automation. Respond to customer inquiries, process notifications, kick off data pipelines, and more—directly from your email inbox, with no manual intervention.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to any email server via IMAP protocol to trigger workflows when new emails are received. Supports Gmail, Outlook, Yahoo, and any other IMAP-compatible email provider.
## Notes
- Category: `triggers`
- Type: `imap`

View File

@@ -126,8 +126,6 @@ Retrieve your account balance and portfolio value from Kalshi
| --------- | ---- | ----------- |
| `balance` | number | Account balance in cents |
| `portfolioValue` | number | Portfolio value in cents |
| `balanceDollars` | number | Account balance in dollars |
| `portfolioValueDollars` | number | Portfolio value in dollars |
### `kalshi_get_positions`

View File

@@ -42,6 +42,7 @@
"huggingface",
"hunter",
"image_generator",
"imap",
"incidentio",
"intercom",
"jina",

View File

@@ -53,6 +53,7 @@ Query data from a Supabase table
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
| `table` | string | Yes | The name of the Supabase table to query |
| `schema` | string | No | Database schema to query from \(default: public\). Use this to access tables in other schemas. |
| `select` | string | No | Columns to return \(comma-separated\). Defaults to * \(all columns\) |
| `filter` | string | No | PostgREST filter \(e.g., "id=eq.123"\) |
| `orderBy` | string | No | Column to order by \(add DESC for descending\) |
| `limit` | number | No | Maximum number of rows to return |
@@ -97,6 +98,7 @@ Get a single row from a Supabase table based on filter criteria
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
| `table` | string | Yes | The name of the Supabase table to query |
| `schema` | string | No | Database schema to query from \(default: public\). Use this to access tables in other schemas. |
| `select` | string | No | Columns to return \(comma-separated\). Defaults to * \(all columns\) |
| `filter` | string | Yes | PostgREST filter to find the specific row \(e.g., "id=eq.123"\) |
| `apiKey` | string | Yes | Your Supabase service role secret key |

View File

@@ -0,0 +1,36 @@
---
title: Correo electrónico IMAP
description: Activa flujos de trabajo cuando lleguen nuevos correos electrónicos
a través de IMAP (funciona con cualquier proveedor de correo electrónico)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
El activador de correo electrónico IMAP permite que tus flujos de trabajo de Sim se inicien automáticamente cada vez que se reciba un nuevo correo electrónico en cualquier buzón que admita el protocolo IMAP. Esto funciona con Gmail, Outlook, Yahoo y la mayoría de los demás proveedores de correo electrónico.
Con el activador IMAP, puedes:
- **Automatizar el procesamiento de correos electrónicos**: inicia flujos de trabajo en tiempo real cuando lleguen nuevos mensajes a tu bandeja de entrada.
- **Filtrar por remitente, asunto o carpeta**: configura tu activador para que reaccione solo a los correos electrónicos que cumplan ciertas condiciones.
- **Extraer y procesar archivos adjuntos**: descarga y utiliza automáticamente archivos adjuntos en tus flujos automatizados.
- **Analizar y utilizar el contenido del correo electrónico**: accede al asunto, remitente, destinatarios, cuerpo completo y otros metadatos en los pasos posteriores del flujo de trabajo.
- **Integrar con cualquier proveedor de correo electrónico**: funciona con cualquier servicio que proporcione acceso IMAP estándar, sin dependencia de proveedores.
- **Activar según criterios de no leído, marcado o personalizados**: configura filtros avanzados para los tipos de correos electrónicos que inician tus flujos de trabajo.
Con Sim, la integración IMAP te brinda el poder de convertir el correo electrónico en una fuente de automatización procesable. Responde a consultas de clientes, procesa notificaciones, inicia pipelines de datos y más, directamente desde tu bandeja de entrada de correo electrónico, sin intervención manual.
{/* MANUAL-CONTENT-END */}
## Instrucciones de uso
Conéctate a cualquier servidor de correo electrónico a través del protocolo IMAP para activar flujos de trabajo cuando se reciban nuevos correos electrónicos. Compatible con Gmail, Outlook, Yahoo y cualquier otro proveedor de correo electrónico compatible con IMAP.
## Notas
- Categoría: `triggers`
- Tipo: `imap`

View File

@@ -122,9 +122,7 @@ Recuperar el saldo de tu cuenta y el valor de la cartera desde Kalshi
| Parámetro | Tipo | Descripción |
| --------- | ---- | ----------- |
| `balance` | number | Saldo de la cuenta en centavos |
| `portfolioValue` | number | Valor de la cartera en centavos |
| `balanceDollars` | number | Saldo de la cuenta en dólares |
| `portfolioValueDollars` | number | Valor de la cartera en dólares |
| `portfolioValue` | number | Valor del portafolio en centavos |
### `kalshi_get_positions`

View File

@@ -46,12 +46,13 @@ Consultar datos de una tabla de Supabase
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Sí | ID de tu proyecto Supabase \(p. ej., jdrkgepadsdopsntdlom\) |
| `table` | string | Sí | Nombre de la tabla Supabase a consultar |
| `schema` | string | No | Esquema de base de datos desde donde consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
| `schema` | string | No | Esquema de base de datos desde el que consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
| `select` | string | No | Columnas a devolver \(separadas por comas\). Predeterminado: * \(todas las columnas\) |
| `filter` | string | No | Filtro PostgREST \(p. ej., "id=eq.123"\) |
| `orderBy` | string | No | Columna para ordenar \(añade DESC para descendente\) |
| `orderBy` | string | No | Columna por la que ordenar \(añade DESC para orden descendente\) |
| `limit` | number | No | Número máximo de filas a devolver |
| `apiKey` | string | Sí | Tu clave secreta de rol de servicio de Supabase |
@@ -90,10 +91,11 @@ Obtener una sola fila de una tabla de Supabase basada en criterios de filtro
#### Entrada
| Parámetro | Tipo | Obligatorio | Descripción |
| --------- | ---- | ----------- | ----------- |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Sí | ID de tu proyecto Supabase \(p. ej., jdrkgepadsdopsntdlom\) |
| `table` | string | Sí | Nombre de la tabla Supabase a consultar |
| `schema` | string | No | Esquema de base de datos desde donde consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
| `schema` | string | No | Esquema de base de datos desde el que consultar \(predeterminado: public\). Usa esto para acceder a tablas en otros esquemas. |
| `select` | string | No | Columnas a devolver \(separadas por comas\). Predeterminado: * \(todas las columnas\) |
| `filter` | string | Sí | Filtro PostgREST para encontrar la fila específica \(p. ej., "id=eq.123"\) |
| `apiKey` | string | Sí | Tu clave secreta de rol de servicio de Supabase |

View File

@@ -0,0 +1,36 @@
---
title: Email IMAP
description: Déclenchez des workflows lorsque de nouveaux emails arrivent via
IMAP (fonctionne avec n'importe quel fournisseur d'email)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
Le déclencheur Email IMAP permet à vos workflows Sim de démarrer automatiquement dès qu'un nouvel email est reçu dans n'importe quelle boîte mail prenant en charge le protocole IMAP. Cela fonctionne avec Gmail, Outlook, Yahoo et la plupart des autres fournisseurs d'email.
Avec le déclencheur IMAP, vous pouvez :
- **Automatiser le traitement des emails** : démarrez des workflows en temps réel lorsque de nouveaux messages arrivent dans votre boîte de réception.
- **Filtrer par expéditeur, objet ou dossier** : configurez votre déclencheur pour réagir uniquement aux emails correspondant à certaines conditions.
- **Extraire et traiter les pièces jointes** : téléchargez et utilisez automatiquement les fichiers joints dans vos flux automatisés.
- **Analyser et utiliser le contenu des emails** : accédez à l'objet, l'expéditeur, les destinataires, le corps complet et d'autres métadonnées dans les étapes suivantes du workflow.
- **Intégrer avec n'importe quel fournisseur d'email** : fonctionne avec tout service offrant un accès IMAP standard, sans dépendance à un fournisseur.
- **Déclencher sur non lu, marqué ou critères personnalisés** : configurez des filtres avancés pour les types d'emails qui démarrent vos workflows.
Avec Sim, l'intégration IMAP vous donne le pouvoir de transformer l'email en une source d'automatisation exploitable. Répondez aux demandes clients, traitez les notifications, lancez des pipelines de données et plus encore, directement depuis votre boîte de réception email, sans intervention manuelle.
{/* MANUAL-CONTENT-END */}
## Instructions d'utilisation
Connectez-vous à n'importe quel serveur email via le protocole IMAP pour déclencher des workflows lorsque de nouveaux emails sont reçus. Prend en charge Gmail, Outlook, Yahoo et tout autre fournisseur d'email compatible IMAP.
## Remarques
- Catégorie : `triggers`
- Type : `imap`

View File

@@ -123,8 +123,6 @@ Récupérer le solde de votre compte et la valeur de votre portefeuille depuis K
| --------- | ---- | ----------- |
| `balance` | number | Solde du compte en centimes |
| `portfolioValue` | number | Valeur du portefeuille en centimes |
| `balanceDollars` | number | Solde du compte en dollars |
| `portfolioValueDollars` | number | Valeur du portefeuille en dollars |
### `kalshi_get_positions`

View File

@@ -49,7 +49,8 @@ Interroger des données d'une table Supabase
| --------- | ---- | ----------- | ----------- |
| `projectId` | string | Oui | L'ID de votre projet Supabase \(ex. : jdrkgepadsdopsntdlom\) |
| `table` | string | Oui | Le nom de la table Supabase à interroger |
| `schema` | string | Non | Schéma de base de données à interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
| `schema` | string | Non | Schéma de base de données à partir duquel interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
| `select` | string | Non | Colonnes à retourner \(séparées par des virgules\). Par défaut * \(toutes les colonnes\) |
| `filter` | string | Non | Filtre PostgREST \(ex. : "id=eq.123"\) |
| `orderBy` | string | Non | Colonne pour le tri \(ajoutez DESC pour l'ordre décroissant\) |
| `limit` | number | Non | Nombre maximum de lignes à retourner |
@@ -93,7 +94,8 @@ Obtenir une seule ligne d'une table Supabase selon des critères de filtrage
| --------- | ---- | ----------- | ----------- |
| `projectId` | string | Oui | L'ID de votre projet Supabase \(ex. : jdrkgepadsdopsntdlom\) |
| `table` | string | Oui | Le nom de la table Supabase à interroger |
| `schema` | string | Non | Schéma de base de données à interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
| `schema` | string | Non | Schéma de base de données à partir duquel interroger \(par défaut : public\). Utilisez ceci pour accéder aux tables dans d'autres schémas. |
| `select` | string | Non | Colonnes à retourner \(séparées par des virgules\). Par défaut * \(toutes les colonnes\) |
| `filter` | string | Oui | Filtre PostgREST pour trouver la ligne spécifique \(ex. : "id=eq.123"\) |
| `apiKey` | string | Oui | Votre clé secrète de rôle de service Supabase |

View File

@@ -0,0 +1,35 @@
---
title: IMAPメール
description: IMAP経由で新しいメールが届いたときにワークフローをトリガーすべてのメールプロバイダーで動作
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
IMAPメールトリガーを使用すると、IMAPプロトコルをサポートする任意のメールボックスで新しいメールを受信したときに、Simワークフローを自動的に開始できます。Gmail、Outlook、Yahoo、その他ほとんどのメールプロバイダーで動作します。
IMAPトリガーでできること
- **メール処理の自動化**:受信トレイに新しいメッセージが届いたときにリアルタイムでワークフローを開始します。
- **送信者、件名、フォルダーでフィルタリング**:特定の条件に一致するメールにのみ反応するようにトリガーを設定します。
- **添付ファイルの抽出と処理**:自動化フローでファイル添付を自動的にダウンロードして使用します。
- **メールコンテンツの解析と使用**:件名、送信者、受信者、本文全体、その他のメタデータに、ワークフローの後続ステップでアクセスします。
- **あらゆるメールプロバイダーとの統合**ベンダーロックインなしで、標準のIMAPアクセスを提供する任意のサービスで動作します。
- **未読、フラグ付き、カスタム条件でトリガー**:ワークフローを開始するメールの種類に対して高度なフィルターを設定します。
Simを使用すると、IMAP統合により、メールを実行可能な自動化ソースに変える力が得られます。顧客からの問い合わせへの対応、通知の処理、データパイプラインの開始など、手動操作なしで、メール受信トレイから直接実行できます。
{/* MANUAL-CONTENT-END */}
## 使用方法
IMAPプロトコル経由で任意のメールサーバーに接続し、新しいメールを受信したときにワークフローをトリガーします。Gmail、Outlook、Yahoo、その他のIMAP互換メールプロバイダーをサポートします。
## 注意事項
- カテゴリ: `triggers`
- タイプ: `imap`

View File

@@ -121,10 +121,8 @@ Kalshiからアカウント残高とポートフォリオ価値を取得
| パラメータ | 型 | 説明 |
| --------- | ---- | ----------- |
| `balance` | number | セント単位のアカウント残高 |
| `portfolioValue` | number | セント単位のポートフォリオ価値 |
| `balanceDollars` | number | ドル単位のアカウント残高 |
| `portfolioValueDollars` | number | ドル単位のポートフォリオ価値 |
| `balance` | number | アカウント残高(セント単位) |
| `portfolioValue` | number | ポートフォリオ価値(セント単位) |
### `kalshi_get_positions`

View File

@@ -49,7 +49,8 @@ Supabaseテーブルからデータを照会する
| --------- | ---- | -------- | ----------- |
| `projectId` | string | はい | あなたのSupabaseプロジェクトIDjdrkgepadsdopsntdlom |
| `table` | string | はい | クエリするSupabaseテーブルの名前 |
| `schema` | string | いいえ | クエリするデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `schema` | string | いいえ | クエリ元のデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `select` | string | いいえ | 返す列(カンマ区切り)。デフォルトは*(すべての列) |
| `filter` | string | いいえ | PostgRESTフィルター"id=eq.123" |
| `orderBy` | string | いいえ | 並べ替える列降順の場合はDESCを追加 |
| `limit` | number | いいえ | 返す最大行数 |
@@ -93,7 +94,8 @@ Supabaseテーブルにデータを挿入する
| --------- | ---- | -------- | ----------- |
| `projectId` | string | はい | あなたのSupabaseプロジェクトIDjdrkgepadsdopsntdlom |
| `table` | string | はい | クエリするSupabaseテーブルの名前 |
| `schema` | string | いいえ | クエリするデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `schema` | string | いいえ | クエリ元のデータベーススキーマデフォルトpublic。他のスキーマのテーブルにアクセスする場合に使用します。 |
| `select` | string | いいえ | 返す列(カンマ区切り)。デフォルトは*(すべての列) |
| `filter` | string | はい | 特定の行を見つけるためのPostgRESTフィルター"id=eq.123" |
| `apiKey` | string | はい | あなたのSupabaseサービスロールシークレットキー |

View File

@@ -0,0 +1,35 @@
---
title: IMAP 邮件
description: 当通过 IMAP 收到新邮件时触发工作流(适用于任何邮箱服务商)
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="imap"
color="#6366F1"
/>
{/* MANUAL-CONTENT-START:intro */}
IMAP 邮件触发器可以让你的 Sim 工作流在任何支持 IMAP 协议的邮箱收到新邮件时自动启动。适用于 Gmail、Outlook、Yahoo 及大多数其他邮箱服务商。
使用 IMAP 触发器,你可以:
- **自动化邮件处理**:当新邮件到达收件箱时,实时启动工作流。
- **按发件人、主题或文件夹筛选**:配置触发器,仅对符合特定条件的邮件做出响应。
- **提取并处理附件**:自动下载并在自动化流程中使用邮件附件。
- **解析并利用邮件内容**:在后续工作流步骤中访问主题、发件人、收件人、正文及其他元数据。
- **与任意邮箱服务集成**:支持所有提供标准 IMAP 访问的服务,无需受限于特定厂商。
- **按未读、标记或自定义条件触发**:为启动工作流的邮件设置高级筛选条件。
借助 SimIMAP 集成让你能够将邮件变为可操作的自动化来源。无需人工干预,即可直接从邮箱收件箱响应客户咨询、处理通知、启动数据流程等。
{/* MANUAL-CONTENT-END */}
## 使用说明
通过 IMAP 协议连接任意邮件服务器,在收到新邮件时触发工作流。支持 Gmail、Outlook、Yahoo 及所有兼容 IMAP 的邮箱服务商。
## 注意事项
- 分类:`triggers`
- 类型:`imap`

View File

@@ -123,8 +123,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
| --------- | ---- | ----------- |
| `balance` | number | 账户余额(以分为单位) |
| `portfolioValue` | number | 投资组合价值(以分为单位) |
| `balanceDollars` | number | 账户余额(以美元为单位) |
| `portfolioValueDollars` | number | 投资组合价值(以美元为单位) |
### `kalshi_get_positions`

View File

@@ -50,8 +50,9 @@ Sim 的 Supabase 集成使您能够轻松地将代理工作流连接到您的 Su
| `projectId` | string | 是 | 您的 Supabase 项目 ID \(例如jdrkgepadsdopsntdlom\) |
| `table` | string | 是 | 要查询的 Supabase 表名 |
| `schema` | string | 否 | 要查询的数据库 schema \(默认public\)。用于访问其他 schema 下的表。|
| `select` | string | 否 | 要返回的列(逗号分隔)。默认为 *(所有列)|
| `filter` | string | 否 | PostgREST 过滤条件 \(例如:"id=eq.123"\) |
| `orderBy` | string | 否 | 排序的列名 \(添加 DESC 表示降序\) |
| `orderBy` | string | 否 | 排序的列添加 DESC 表示降序|
| `limit` | number | 否 | 返回的最大行数 |
| `apiKey` | string | 是 | 您的 Supabase 服务角色密钥 |
@@ -94,7 +95,8 @@ Sim 的 Supabase 集成使您能够轻松地将代理工作流连接到您的 Su
| `projectId` | string | 是 | 您的 Supabase 项目 ID \(例如jdrkgepadsdopsntdlom\) |
| `table` | string | 是 | 要查询的 Supabase 表名 |
| `schema` | string | 否 | 要查询的数据库 schema \(默认public\)。用于访问其他 schema 下的表。|
| `filter` | string | | 用于查找特定行的 PostgREST 过滤条件 \(例如:"id=eq.123"\) |
| `select` | string | | 要返回的列(逗号分隔)。默认为 *(所有列)|
| `filter` | string | 是 | PostgREST 过滤条件,用于查找特定行 \(例如:"id=eq.123"\) |
| `apiKey` | string | 是 | 您的 Supabase 服务角色密钥 |
#### 输出

View File

@@ -700,7 +700,7 @@ checksums:
content/11: 04bd9805ef6a50af8469463c34486dbf
content/12: a3671dd7ba76a87dc75464d9bf9b7b4b
content/13: 371d0e46b4bd2c23f559b8bc112f6955
content/14: 80578981b8b3a1cf579e52ff05e7468d
content/14: 5102b3705883f9e0c5440aeabafd1d24
content/15: bcadfc362b69078beee0088e5936c98b
content/16: 09ed43219d02501c829594dbf4128959
content/17: 88ae2285d728c80937e1df8194d92c60
@@ -712,7 +712,7 @@ checksums:
content/23: 7d96d99e45880195ccbd34bddaac6319
content/24: 75d05f96dff406db06b338d9ab8d0bd7
content/25: 371d0e46b4bd2c23f559b8bc112f6955
content/26: cfd801fa517b4bcfa5fa034b2c4e908a
content/26: 38373ac018fd7db3a20ba5308beac81e
content/27: bcadfc362b69078beee0088e5936c98b
content/28: a0284632eb0a15e66f69479ec477c5b1
content/29: b1e60734e590a8ad894a96581a253bf4
@@ -48276,7 +48276,7 @@ checksums:
content/35: 371d0e46b4bd2c23f559b8bc112f6955
content/36: bddd30707802c07aac61620721bfaf16
content/37: bcadfc362b69078beee0088e5936c98b
content/38: fa2c581e6fb204f5ddbd0ffcbf0f7123
content/38: 4619dad6a45478396332397f1e53db85
content/39: 65de097e276f762b71d59fa7f9b0a207
content/40: 013f52c249b5919fdb6d96700b25f379
content/41: 371d0e46b4bd2c23f559b8bc112f6955
@@ -50184,3 +50184,16 @@ checksums:
content/12: 3a322eee25c8bd5d81e7ae92f4239300
content/13: a82eb7d47a82c3289a00ccf27a860685
content/14: 26b9713de1a21d662c198154b673fd7d
b92b25e42e07ea0c1acc84c25f897c03:
meta/title: b578e5df9a37263d79d61eea1550b381
meta/description: a3ac2f556f8a1d72eee5058799e45b4f
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
content/1: 03b3a9b927648526481589ec2205aed1
content/2: ad3b7957de2e6e94935420692f41912b
content/3: b5d66b7cc95f747232f3c39e71e58125
content/4: a97fd9d5ca27813be7aa04fc9162ec5d
content/5: 47006103fb87648dd28524557c946bd0
content/6: 821e6394b0a953e2b0842b04ae8f3105
content/7: 7b29d23aec8fda839f3934c5fc71c6d3
content/8: b3f310d5ef115bea5a8b75bf25d7ea9a
content/9: 79ecd09a7bedc128285814d8b439ed40

View File

@@ -50,8 +50,8 @@
@layer base {
:root,
.light {
--bg: #fdfdfd; /* main canvas - neutral near-white */
--surface-1: #fcfcfc; /* sidebar, panels */
--bg: #fefefe; /* main canvas - neutral near-white */
--surface-1: #fefefe; /* sidebar, panels */
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
--surface-3: #f7f7f7; /* popovers, headers */
--surface-4: #f5f5f5; /* buttons base */
@@ -70,6 +70,7 @@
--text-muted: #737373;
--text-subtle: #8c8c8c;
--text-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
--text-error: #ef4444;
/* Borders / dividers */
@@ -186,6 +187,7 @@
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-inverse: #1b1b1b;
--text-muted-inverse: #b3b3b3;
--text-error: #ef4444;
/* --border-strong: #303030; */
@@ -331,38 +333,38 @@
}
::-webkit-scrollbar-track {
background: var(--surface-1);
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--surface-7);
background-color: #c0c0c0;
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-7);
background-color: #a8a8a8;
}
/* Dark Mode Global Scrollbar */
.dark ::-webkit-scrollbar-track {
background: var(--surface-4);
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background-color: var(--surface-7);
background-color: #5a5a5a;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-7);
background-color: #6a6a6a;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--surface-7) var(--surface-1);
scrollbar-color: #c0c0c0 transparent;
}
.dark * {
scrollbar-color: var(--surface-7) var(--surface-4);
scrollbar-color: #5a5a5a transparent;
}
.copilot-scrollable {

View File

@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
import { generateRequestId } from '@/lib/core/utils/request'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock()
})
vi.doMock('zod', () => ({
z: {
object: vi.fn().mockReturnValue({

View File

@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { renderOTPEmail } from '@/components/emails/render-email'
import { renderOTPEmail } from '@/components/emails'
import { getRedisClient } from '@/lib/core/config/redis'
import { getStorageMethod } from '@/lib/core/storage'
import { generateRequestId } from '@/lib/core/utils/request'

View File

@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string'
? value.toLowerCase() === 'true' || value === '1'
: Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
})
})
const validData = {
workflowId: 'workflow-123',
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
}),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
})
})
const validData = {
workflowId: 'workflow-123',

View File

@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
SIM_AGENT_API_URL: null,
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
},
}))
})
})
})
afterEach(() => {

View File

@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
SIM_AGENT_API_URL: null,
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
},
}))
})
})
})
afterEach(() => {

View File

@@ -0,0 +1,90 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
const logger = createLogger('CleanupStaleExecutions')
const STALE_THRESHOLD_MINUTES = 30
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'Stale execution cleanup')
if (authError) {
return authError
}
logger.info('Starting stale execution cleanup job')
const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000)
const staleExecutions = await db
.select({
id: workflowExecutionLogs.id,
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
startedAt: workflowExecutionLogs.startedAt,
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.status, 'running'),
lt(workflowExecutionLogs.startedAt, staleThreshold)
)
)
.limit(100)
logger.info(`Found ${staleExecutions.length} stale executions to clean up`)
let cleaned = 0
let failed = 0
for (const execution of staleExecutions) {
try {
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
await db
.update(workflowExecutionLogs)
.set({
status: 'failed',
endedAt: new Date(),
totalDurationMs: staleDurationMs,
executionData: sql`jsonb_set(
COALESCE(execution_data, '{}'::jsonb),
ARRAY['error'],
to_jsonb(${`Execution terminated: worker timeout or crash after ${staleDurationMinutes} minutes`}::text)
)`,
})
.where(eq(workflowExecutionLogs.id, execution.id))
logger.info(`Cleaned up stale execution ${execution.executionId}`, {
workflowId: execution.workflowId,
staleDurationMinutes,
})
cleaned++
} catch (error) {
logger.error(`Failed to clean up execution ${execution.executionId}:`, {
error: error instanceof Error ? error.message : String(error),
})
failed++
}
}
logger.info(`Stale execution cleanup completed. Cleaned: ${cleaned}, Failed: ${failed}`)
return NextResponse.json({
success: true,
found: staleExecutions.length,
cleaned,
failed,
thresholdMinutes: STALE_THRESHOLD_MINUTES,
})
} catch (error) {
logger.error('Error in stale execution cleanup job:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,176 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import {
renderBatchInvitationEmail,
renderCareersConfirmationEmail,
renderCareersSubmissionEmail,
renderCreditPurchaseEmail,
renderEnterpriseSubscriptionEmail,
renderFreeTierUpgradeEmail,
renderHelpConfirmationEmail,
renderInvitationEmail,
renderOTPEmail,
renderPasswordResetEmail,
renderPaymentFailedEmail,
renderPlanWelcomeEmail,
renderUsageThresholdEmail,
renderWelcomeEmail,
renderWorkspaceInvitationEmail,
} from '@/components/emails'
const emailTemplates = {
// Auth emails
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
welcome: () => renderWelcomeEmail('John'),
// Invitation emails
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
'batch-invitation': () =>
renderBatchInvitationEmail(
'Jane Doe',
'Acme Corp',
'admin',
[
{ workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' },
{ workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' },
],
'https://sim.ai/invite/abc123'
),
'workspace-invitation': () =>
renderWorkspaceInvitationEmail(
'John Smith',
'Engineering Team',
'https://sim.ai/workspace/invite/abc123'
),
// Support emails
'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2),
// Billing emails
'usage-threshold': () =>
renderUsageThresholdEmail({
userName: 'John',
planName: 'Pro',
percentUsed: 75,
currentUsage: 15,
limit: 20,
ctaLink: 'https://sim.ai/settings/billing',
}),
'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'),
'free-tier-upgrade': () =>
renderFreeTierUpgradeEmail({
userName: 'John',
percentUsed: 90,
currentUsage: 9,
limit: 10,
upgradeLink: 'https://sim.ai/settings/billing',
}),
'plan-welcome-pro': () =>
renderPlanWelcomeEmail({
planName: 'Pro',
userName: 'John',
loginLink: 'https://sim.ai/login',
}),
'plan-welcome-team': () =>
renderPlanWelcomeEmail({
planName: 'Team',
userName: 'John',
loginLink: 'https://sim.ai/login',
}),
'credit-purchase': () =>
renderCreditPurchaseEmail({
userName: 'John',
amount: 50,
newBalance: 75,
}),
'payment-failed': () =>
renderPaymentFailedEmail({
userName: 'John',
amountDue: 20,
lastFourDigits: '4242',
billingPortalUrl: 'https://sim.ai/settings/billing',
failureReason: 'Card declined',
}),
// Careers emails
'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'),
'careers-submission': () =>
renderCareersSubmissionEmail({
name: 'John Doe',
email: 'john@example.com',
phone: '+1 (555) 123-4567',
position: 'Senior Engineer',
linkedin: 'https://linkedin.com/in/johndoe',
portfolio: 'https://johndoe.dev',
experience: '5-10',
location: 'San Francisco, CA',
message:
'I have 10 years of experience building scalable distributed systems. Most recently, I led a team at a Series B startup where we scaled from 100K to 10M users.',
}),
} as const
type EmailTemplate = keyof typeof emailTemplates
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const template = searchParams.get('template') as EmailTemplate | null
if (!template) {
const categories = {
Auth: ['otp', 'reset-password', 'welcome'],
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
Support: ['help-confirmation'],
Billing: [
'usage-threshold',
'enterprise-subscription',
'free-tier-upgrade',
'plan-welcome-pro',
'plan-welcome-team',
'credit-purchase',
'payment-failed',
],
Careers: ['careers-confirmation', 'careers-submission'],
}
const categoryHtml = Object.entries(categories)
.map(
([category, templates]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
)
.join('')
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Email Previews</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
h1 { color: #333; margin-bottom: 32px; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Email Templates</h1>
${categoryHtml}
</body>
</html>`,
{ headers: { 'Content-Type': 'text/html' } }
)
}
if (!(template in emailTemplates)) {
return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 })
}
const html = await emailTemplates[template]()
return new NextResponse(html, {
headers: { 'Content-Type': 'text/html' },
})
}

View File

@@ -118,7 +118,6 @@ ${message}
// Send confirmation email to the user
try {
const confirmationHtml = await renderHelpConfirmationEmail(
email,
type as 'bug' | 'feedback' | 'feature_request' | 'other',
images.length
)

View File

@@ -136,16 +136,29 @@ vi.mock('@sim/db', () => {
},
}),
}),
delete: () => ({
where: () => Promise.resolve(),
}),
insert: () => ({
values: (records: any) => {
dbOps.order.push('insert')
dbOps.insertRecords.push(records)
return Promise.resolve()
},
}),
transaction: vi.fn(async (fn: any) => {
await fn({
insert: (table: any) => ({
delete: () => ({
where: () => Promise.resolve(),
}),
insert: () => ({
values: (records: any) => {
dbOps.order.push('insert')
dbOps.insertRecords.push(records)
return Promise.resolve()
},
}),
update: (table: any) => ({
update: () => ({
set: (payload: any) => ({
where: () => {
dbOps.updatePayloads.push(payload)

View File

@@ -16,7 +16,7 @@ import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
} from '@/components/emails/render-email'
} from '@/components/emails'
import { getSession } from '@/lib/auth'
import {
validateBulkInvitations,
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/${orgInvitation.id}`,
email
`${getBaseUrl()}/invite/${orgInvitation.id}`
)
emailResult = await sendEmail({

View File

@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/organization?id=${invitationId}`,
normalizedEmail
`${getBaseUrl()}/invite/organization?id=${invitationId}`
)
const emailResult = await sendEmail({

View File

@@ -21,14 +21,15 @@ export async function POST(
) {
const { workflowId, executionId, contextId } = await params
// Allow resume from dashboard without requiring deployment
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
const workflow = access.workflow!
const workflow = access.workflow
let payload: any = {}
let payload: Record<string, unknown> = {}
try {
payload = await request.json()
} catch {
@@ -148,6 +149,7 @@ export async function GET(
) {
const { workflowId, executionId, contextId } = await params
// Allow access without API key for browser-based UI (same as parent execution endpoint)
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })

View File

@@ -0,0 +1,101 @@
import { createLogger } from '@sim/logger'
import { ImapFlow } from 'imapflow'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
const logger = createLogger('ImapMailboxesAPI')
interface ImapMailboxRequest {
host: string
port: number
secure: boolean
rejectUnauthorized: boolean
username: string
password: string
}
export async function POST(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 })
}
try {
const body = (await request.json()) as ImapMailboxRequest
const { host, port, secure, rejectUnauthorized, username, password } = body
if (!host || !username || !password) {
return NextResponse.json(
{ success: false, message: 'Missing required fields: host, username, password' },
{ status: 400 }
)
}
const client = new ImapFlow({
host,
port: port || 993,
secure: secure ?? true,
auth: {
user: username,
pass: password,
},
tls: {
rejectUnauthorized: rejectUnauthorized ?? true,
},
logger: false,
})
try {
await client.connect()
const listResult = await client.list()
const mailboxes = listResult.map((mailbox) => ({
path: mailbox.path,
name: mailbox.name,
delimiter: mailbox.delimiter,
}))
await client.logout()
mailboxes.sort((a, b) => {
if (a.path === 'INBOX') return -1
if (b.path === 'INBOX') return 1
return a.path.localeCompare(b.path)
})
return NextResponse.json({
success: true,
mailboxes,
})
} catch (error) {
try {
await client.logout()
} catch {
// Ignore logout errors
}
throw error
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Error fetching IMAP mailboxes:', errorMessage)
let userMessage = 'Failed to connect to IMAP server'
if (
errorMessage.includes('AUTHENTICATIONFAILED') ||
errorMessage.includes('Invalid credentials')
) {
userMessage = 'Invalid username or password. For Gmail, use an App Password.'
} else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
userMessage = 'Could not find IMAP server. Please check the hostname.'
} else if (errorMessage.includes('ECONNREFUSED')) {
userMessage = 'Connection refused. Please check the port and SSL settings.'
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL')) {
userMessage =
'TLS/SSL error. Try disabling "Verify TLS Certificate" for self-signed certificates.'
} else if (errorMessage.includes('timeout')) {
userMessage = 'Connection timed out. Please check your network and server settings.'
}
return NextResponse.json({ success: false, message: userMessage }, { status: 500 })
}
}

View File

@@ -29,6 +29,10 @@
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
@@ -65,6 +69,8 @@ export {
unauthorizedResponse,
} from '@/app/api/v1/admin/responses'
export type {
AdminDeploymentVersion,
AdminDeployResult,
AdminErrorResponse,
AdminFolder,
AdminListResponse,
@@ -76,6 +82,7 @@ export type {
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
AdminUndeployResult,
AdminUser,
AdminUserBilling,
AdminUserBillingWithSubscription,

View File

@@ -599,3 +599,23 @@ export interface AdminSeatAnalytics {
lastActive: string | null
}>
}
export interface AdminDeploymentVersion {
id: string
version: number
name: string | null
isActive: boolean
createdAt: string
createdBy: string | null
deployedByName: string | null
}
export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
}
export interface AdminUndeployResult {
isDeployed: boolean
}

View File

@@ -0,0 +1,111 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
if (!scheduleResult.success) {
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to deploy workflow')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {
isDeployed: false,
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to undeploy workflow')
}
})

View File

@@ -0,0 +1,58 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
interface RouteParams {
id: string
versionId: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId, versionId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const versionNum = Number(versionId)
if (!Number.isFinite(versionNum) || versionNum < 1) {
return badRequestResponse('Invalid version number')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
})
} catch (error) {
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to activate deployment version')
}
})

View File

@@ -0,0 +1,52 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowVersionsAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const { versions } = await listWorkflowVersions(workflowId)
const response: AdminDeploymentVersion[] = versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
createdBy: v.createdBy,
deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
return singleResponse({ versions: response })
} catch (error) {
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to list deployment versions')
}
})

View File

@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { pollImapWebhooks } from '@/lib/webhooks/imap-polling-service'
const logger = createLogger('ImapPollingAPI')
export const dynamic = 'force-dynamic'
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
const LOCK_KEY = 'imap-polling-lock'
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
export async function GET(request: NextRequest) {
const requestId = nanoid()
logger.info(`IMAP webhook polling triggered (${requestId})`)
let lockValue: string | undefined
try {
const authError = verifyCronAuth(request, 'IMAP webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
if (!locked) {
return NextResponse.json(
{
success: true,
message: 'Polling already in progress skipped',
requestId,
status: 'skip',
},
{ status: 202 }
)
}
const results = await pollImapWebhooks()
return NextResponse.json({
success: true,
message: 'IMAP polling completed',
requestId,
status: 'completed',
...results,
})
} catch (error) {
logger.error(`Error during IMAP polling (${requestId}):`, error)
return NextResponse.json(
{
success: false,
message: 'IMAP polling failed',
error: error instanceof Error ? error.message : 'Unknown error',
requestId,
},
{ status: 500 }
)
} finally {
if (lockValue) {
await releaseLock(LOCK_KEY, lockValue).catch(() => {})
}
}
}

View File

@@ -581,6 +581,56 @@ export async function POST(request: NextRequest) {
}
// --- End RSS specific logic ---
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Grain',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Grain specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
@@ -947,3 +997,103 @@ async function createWebflowWebhookSubscription(
throw error
}
}
// Helper function to create the webhook subscription in Grain
async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
}
// Build include object based on configuration
const include: Record<string, boolean> = {}
if (includeHighlights) {
include.highlights = true
}
if (includeParticipants) {
include.participants = true
}
if (includeAiSummary) {
include.ai_summary = true
}
if (Object.keys(include).length > 0) {
requestBody.include = include
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
body: JSON.stringify(requestBody),
})
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
}
)
return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}

View File

@@ -5,6 +5,7 @@ import {
checkWebhookPreprocessing,
findWebhookAndWorkflow,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
queueWebhookExecution,
verifyProviderAuth,
@@ -123,6 +124,11 @@ export async function POST(
return authError
}
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
return reachabilityResponse
}
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)

View File

@@ -4,12 +4,12 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
createSchedulesForDeploy,
deleteSchedulesForWorkflow,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -207,21 +207,11 @@ export async function DELETE(
return createErrorResponse(error.message, error.status)
}
await db.transaction(async (tx) => {
await deleteSchedulesForWorkflow(id, tx)
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, id))
await tx
.update(workflow)
.set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, id))
})
// Remove all MCP tools that reference this workflow
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)

View File

@@ -1,9 +1,8 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -27,68 +26,24 @@ export async function POST(
const versionNum = Number(version)
if (!Number.isFinite(versionNum)) {
return createErrorResponse('Invalid version', 400)
return createErrorResponse('Invalid version number', 400)
}
const now = new Date()
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
// Get the state of the version being activated for MCP tool sync
const [versionData] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
const updated = await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({ id: workflowDeploymentVersion.id })
if (updated.length === 0) {
throw new Error('Deployment version not found')
}
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
// Sync MCP tools with the activated version's parameter schema
if (versionData?.state) {
if (result.state) {
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionData.state,
state: result.state,
context: 'activate',
})
}
return createSuccessResponse({ success: true, deployedAt: now })
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to activate deployment', 500)

View File

@@ -21,7 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message, error.status)
}
const versions = await db
const rawVersions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
@@ -36,6 +36,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.where(eq(workflowDeploymentVersion.workflowId, id))
.orderBy(desc(workflowDeploymentVersion.version))
const versions = rawVersions.map((v) => ({
...v,
deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
return createSuccessResponse({ versions })
} catch (error: any) {
logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error)

View File

@@ -12,7 +12,6 @@ import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { processInputFileFields } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -24,16 +23,17 @@ import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
import { normalizeName } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata, IterationContext } from '@/executor/execution/types'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
import type { SubflowType } from '@/stores/workflows/workflow/types'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const logger = createLogger('WorkflowExecuteAPI')
const ExecuteWorkflowSchema = z.object({
selectedOutputs: z.array(z.string()).optional().default([]),
triggerType: z.enum(ALL_TRIGGER_TYPES).optional(),
triggerType: z.enum(CORE_TRIGGER_TYPES).optional(),
stream: z.boolean().optional(),
useDraftState: z.boolean().optional(),
input: z.any().optional(),
@@ -541,11 +541,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockId: string,
blockName: string,
blockType: string,
iterationContext?: {
iterationCurrent: number
iterationTotal: number
iterationType: SubflowType
}
iterationContext?: IterationContext
) => {
logger.info(`[${requestId}] 🔷 onBlockStart called:`, { blockId, blockName, blockType })
sendEvent({
@@ -571,11 +567,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
blockName: string,
blockType: string,
callbackData: any,
iterationContext?: {
iterationCurrent: number
iterationTotal: number
iterationType: SubflowType
}
iterationContext?: IterationContext
) => {
const hasError = callbackData.output?.error
@@ -713,14 +705,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] Missing snapshot seed for paused execution`, {
executionId,
})
await loggingSession.markAsFailed('Missing snapshot seed for paused execution')
} else {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
try {
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints || [],
snapshotSeed: result.snapshotSeed,
executorUserId: result.metadata?.userId,
})
} catch (pauseError) {
logger.error(`[${requestId}] Failed to persist pause result`, {
executionId,
error: pauseError instanceof Error ? pauseError.message : String(pauseError),
})
await loggingSession.markAsFailed(
`Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}`
)
}
}
} else {
await PauseResumeManager.processQueuedResumes(executionId)

View File

@@ -6,14 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
const logger = createLogger('WorkspaceNotificationAPI')
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',

View File

@@ -7,15 +7,15 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { ALL_TRIGGER_TYPES } from '@/lib/logs/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
const logger = createLogger('WorkspaceNotificationsAPI')
const notificationTypeSchema = z.enum(['webhook', 'email', 'slack'])
const levelFilterSchema = z.array(z.enum(['info', 'error']))
const triggerFilterSchema = z.array(z.enum(ALL_TRIGGER_TYPES))
const triggerFilterSchema = z.array(z.enum(CORE_TRIGGER_TYPES))
const alertRuleSchema = z.enum([
'consecutive_failures',
@@ -81,7 +81,7 @@ const createNotificationSchema = z
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]),
allWorkflows: z.boolean().default(false),
levelFilter: levelFilterSchema.default(['info', 'error']),
triggerFilter: triggerFilterSchema.default([...ALL_TRIGGER_TYPES]),
triggerFilter: triggerFilterSchema.default([...CORE_TRIGGER_TYPES]),
includeFinalOutput: z.boolean().default(false),
includeTraceSpans: z.boolean().default(false),
includeRateLimits: z.boolean().default(false),

View File

@@ -11,7 +11,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
WorkspaceInvitationEmail: vi.fn(),
}))
vi.doMock('@/lib/core/config/env', () => ({
env: {
RESEND_API_KEY: 'test-resend-key',
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
},
}))
vi.doMock('@/lib/core/config/env', async () => {
const { createEnvMock } = await import('@sim/testing')
return createEnvMock()
})
vi.doMock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),

View File

@@ -12,7 +12,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -16,7 +16,7 @@ export function LinkWithPreview({ href, children }: { href: string; children: Re
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='truncate font-medium text-xs'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -8,35 +8,156 @@ import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('InviteById')
function getErrorMessage(reason: string): string {
switch (reason) {
case 'missing-token':
return 'The invitation link is invalid or missing a required parameter.'
case 'invalid-token':
return 'The invitation link is invalid or has already been used.'
case 'expired':
return 'This invitation has expired. Please ask for a new invitation.'
case 'already-processed':
return 'This invitation has already been accepted or declined.'
case 'email-mismatch':
return 'This invitation was sent to a different email address. Please log in with the correct account.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
return 'An unknown error occurred while processing your invitation.'
/** Error codes that can occur during invitation processing */
type InviteErrorCode =
| 'missing-token'
| 'invalid-token'
| 'expired'
| 'already-processed'
| 'email-mismatch'
| 'workspace-not-found'
| 'user-not-found'
| 'already-member'
| 'already-in-organization'
| 'invalid-invitation'
| 'missing-invitation-id'
| 'server-error'
| 'unauthorized'
| 'forbidden'
| 'network-error'
| 'unknown'
interface InviteError {
code: InviteErrorCode
message: string
requiresAuth?: boolean
canRetry?: boolean
}
/**
* Maps error codes to user-friendly error objects with contextual information
*/
function getInviteError(reason: string): InviteError {
const errorMap: Record<string, InviteError> = {
'missing-token': {
code: 'missing-token',
message: 'The invitation link is invalid or missing a required parameter.',
},
'invalid-token': {
code: 'invalid-token',
message: 'The invitation link is invalid or has already been used.',
},
expired: {
code: 'expired',
message: 'This invitation has expired. Please ask for a new invitation.',
},
'already-processed': {
code: 'already-processed',
message: 'This invitation has already been accepted or declined.',
},
'email-mismatch': {
code: 'email-mismatch',
message:
'This invitation was sent to a different email address. Please sign in with the correct account.',
requiresAuth: true,
},
'workspace-not-found': {
code: 'workspace-not-found',
message: 'The workspace associated with this invitation could not be found.',
},
'user-not-found': {
code: 'user-not-found',
message: 'Your user account could not be found. Please try signing out and signing back in.',
requiresAuth: true,
},
'already-member': {
code: 'already-member',
message: 'You are already a member of this organization or workspace.',
},
'already-in-organization': {
code: 'already-in-organization',
message:
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
},
'invalid-invitation': {
code: 'invalid-invitation',
message: 'This invitation is invalid or no longer exists.',
},
'missing-invitation-id': {
code: 'missing-invitation-id',
message:
'The invitation link is missing required information. Please use the original invitation link.',
},
'server-error': {
code: 'server-error',
message:
'An unexpected error occurred while processing your invitation. Please try again later.',
canRetry: true,
},
unauthorized: {
code: 'unauthorized',
message: 'You need to sign in to accept this invitation.',
requiresAuth: true,
},
forbidden: {
code: 'forbidden',
message:
'You do not have permission to accept this invitation. Please check you are signed in with the correct account.',
requiresAuth: true,
},
'network-error': {
code: 'network-error',
message:
'Unable to connect to the server. Please check your internet connection and try again.',
canRetry: true,
},
}
return (
errorMap[reason] || {
code: 'unknown',
message:
'An unexpected error occurred while processing your invitation. Please try again or contact support.',
canRetry: true,
}
)
}
/**
* Parses API error responses and extracts a standardized error code
*/
function parseApiError(error: unknown, statusCode?: number): InviteErrorCode {
// Handle network/fetch errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return 'network-error'
}
// Handle error message patterns first (more specific matching)
const errorMessage =
typeof error === 'string' ? error.toLowerCase() : (error as Error)?.message?.toLowerCase() || ''
// Check specific patterns before falling back to status codes
// Order matters: more specific patterns must come first
if (errorMessage.includes('already a member of an organization')) return 'already-in-organization'
if (errorMessage.includes('already a member')) return 'already-member'
if (errorMessage.includes('email mismatch') || errorMessage.includes('different email'))
return 'email-mismatch'
if (errorMessage.includes('already processed')) return 'already-processed'
if (errorMessage.includes('unauthorized')) return 'unauthorized'
if (errorMessage.includes('forbidden') || errorMessage.includes('permission')) return 'forbidden'
if (errorMessage.includes('not found') || errorMessage.includes('expired'))
return 'invalid-invitation'
// Handle HTTP status codes as fallback
if (statusCode) {
if (statusCode === 401) return 'unauthorized'
if (statusCode === 403) return 'forbidden'
if (statusCode === 404) return 'invalid-invitation'
if (statusCode === 409) return 'already-in-organization'
if (statusCode >= 500) return 'server-error'
}
return 'unknown'
}
export default function Invite() {
@@ -47,7 +168,7 @@ export default function Invite() {
const { data: session, isPending } = useSession()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<InviteError | null>(null)
const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false)
@@ -59,7 +180,7 @@ export default function Invite() {
const errorReason = searchParams.get('error')
if (errorReason) {
setError(getErrorMessage(errorReason))
setError(getInviteError(errorReason))
setIsLoading(false)
return
}
@@ -99,11 +220,37 @@ export default function Invite() {
return
}
// Handle workspace invitation errors with specific status codes
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
const errorCode = parseApiError(null, workspaceInviteResponse.status)
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
logger.error('Workspace invitation fetch failed:', {
status: workspaceInviteResponse.status,
error: errorData,
})
// Refine error code based on response body if available
if (errorData.error) {
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
setError(getInviteError(refinedCode))
} else {
setError(getInviteError(errorCode))
}
setIsLoading(false)
return
}
try {
const { data } = await client.organization.getInvitation({
const { data, error: orgError } = await client.organization.getInvitation({
query: { id: inviteId },
})
if (orgError) {
logger.error('Organization invitation fetch error:', orgError)
const errorCode = parseApiError(orgError.message || orgError)
throw { code: errorCode, original: orgError }
}
if (data) {
setInvitationType('organization')
@@ -115,7 +262,7 @@ export default function Invite() {
if (activeOrgResponse?.data) {
// User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name)
setError('already-in-organization')
setError(getInviteError('already-in-organization'))
setIsLoading(false)
return
}
@@ -139,14 +286,19 @@ export default function Invite() {
}
}
} else {
throw new Error('Invitation not found or has expired')
throw { code: 'invalid-invitation' }
}
} catch (_err) {
throw new Error('Invitation not found or has expired')
} catch (orgErr: any) {
// If this is our structured error, use it directly
if (orgErr.code) {
throw orgErr
}
throw { code: parseApiError(orgErr) }
}
} catch (err: any) {
logger.error('Error fetching invitation:', err)
setError(err.message || 'Failed to load invitation details')
const errorCode = err.code || parseApiError(err)
setError(getInviteError(errorCode))
} finally {
setIsLoading(false)
}
@@ -168,7 +320,9 @@ export default function Invite() {
const orgId = invitationDetails?.data?.organizationId
if (!orgId) {
throw new Error('Organization ID not found')
setError(getInviteError('invalid-invitation'))
setIsAccepting(false)
return
}
// Use our custom API endpoint that handles Pro usage snapshot
@@ -182,8 +336,15 @@ export default function Invite() {
})
if (!response.ok) {
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' }))
throw new Error(data.error || 'Failed to accept invitation')
const data = await response.json().catch(() => ({}))
const errorCode = parseApiError(data.error || '', response.status)
logger.error('Failed to accept organization invitation:', {
status: response.status,
error: data,
})
setError(getInviteError(errorCode))
setIsAccepting(false)
return
}
// Set the organization as active
@@ -202,13 +363,8 @@ export default function Invite() {
// Reset accepted state on error
setAccepted(false)
// Check if it's a 409 conflict (already in an organization)
if (err.status === 409 || err.message?.includes('already a member of an organization')) {
setError('already-in-organization')
} else {
setError(err.message || 'Failed to accept invitation')
}
const errorCode = parseApiError(err)
setError(getInviteError(errorCode))
setIsAccepting(false)
}
}
@@ -279,12 +435,10 @@ export default function Invite() {
}
if (error) {
const errorReason = searchParams.get('error')
const isExpiredError = errorReason === 'expired'
const isAlreadyInOrg = error === 'already-in-organization'
const callbackUrl = encodeURIComponent(getCallbackUrl())
// Special handling for already in organization
if (isAlreadyInOrg) {
if (error.code === 'already-in-organization') {
return (
<InviteLayout>
<InviteStatusCard
@@ -293,7 +447,7 @@ export default function Invite() {
description={
currentOrgName
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
: error.message
}
icon='users'
actions={[
@@ -313,24 +467,96 @@ export default function Invite() {
)
}
// Use getErrorMessage for consistent error messages
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error)
// Handle email mismatch - user needs to sign in with a different account
if (error.code === 'email-mismatch') {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Wrong Account'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in with a different account',
onClick: async () => {
await client.signOut()
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
},
variant: 'default' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle auth-related errors - prompt user to sign in
if (error.requiresAuth) {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Authentication Required'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in to continue',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'default' as const,
},
{
label: 'Create an account',
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'outline' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle retryable errors
const actions: Array<{
label: string
onClick: () => void
variant?: 'default' | 'outline' | 'ghost'
}> = []
if (error.canRetry) {
actions.push({
label: 'Try Again',
onClick: () => window.location.reload(),
variant: 'default' as const,
})
}
actions.push({
label: 'Return to Home',
onClick: () => router.push('/'),
variant: error.canRetry ? ('ghost' as const) : ('default' as const),
})
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation Error'
description={errorMessage}
description={error.message}
icon='error'
isExpiredError={isExpiredError}
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'default' as const,
},
]}
isExpiredError={error.code === 'expired'}
actions={actions}
/>
</InviteLayout>
)

View File

@@ -1,5 +1,6 @@
'use client'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav'
interface InviteLayoutProps {
@@ -8,12 +9,13 @@ interface InviteLayoutProps {
export default function InviteLayout({ children }: InviteLayoutProps) {
return (
<div className='relative min-h-screen'>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
<Nav variant='auth' />
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
<div className='w-full max-w-[410px]'>{children}</div>
</div>
</div>
<AuthBackground>
<main className='relative flex min-h-screen flex-col text-foreground'>
<Nav hideAuthButtons={true} variant='auth' />
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-lg px-4'>{children}</div>
</div>
</main>
</AuthBackground>
)
}

View File

@@ -1,16 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Loader2,
Mail,
RotateCcw,
ShieldX,
UserPlus,
Users2,
} from 'lucide-react'
import { useState } from 'react'
import { ArrowRight, ChevronRight, Loader2, RotateCcw } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding'
@@ -32,104 +23,53 @@ interface InviteStatusCardProps {
isExpiredError?: boolean
}
const iconMap = {
userPlus: UserPlus,
mail: Mail,
users: Users2,
error: ShieldX,
success: CheckCircle2,
warning: AlertCircle,
}
const iconColorMap = {
userPlus: 'text-[var(--brand-primary-hex)]',
mail: 'text-[var(--brand-primary-hex)]',
users: 'text-[var(--brand-primary-hex)]',
error: 'text-red-500 dark:text-red-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-500',
}
const iconBgMap = {
userPlus: 'bg-[var(--brand-primary-hex)]/10',
mail: 'bg-[var(--brand-primary-hex)]/10',
users: 'bg-[var(--brand-primary-hex)]/10',
error: 'bg-red-50 dark:bg-red-950/20',
success: 'bg-green-50 dark:bg-green-950/20',
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
}
export function InviteStatusCard({
type,
title,
description,
icon,
icon: _icon,
actions = [],
isExpiredError = false,
}: InviteStatusCardProps) {
const router = useRouter()
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null)
const brandConfig = useBrandConfig()
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
if (type === 'loading') {
return (
<div className={`${soehne.className} space-y-6`}>
<>
<div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>Loading</h1>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description}
</p>
</div>
<div className='flex w-full items-center justify-center py-8'>
<Loader2 className='h-8 w-8 animate-spin text-[var(--brand-primary-hex)]' />
<div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div>
<div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
Need help?{' '}
<a
href='mailto:help@sim.ai'
href={`mailto:${brandConfig.supportEmail}`}
className='auth-link underline-offset-4 transition hover:underline'
>
Contact support
</a>
</div>
</div>
</>
)
}
const IconComponent = icon ? iconMap[icon] : null
const iconColor = icon ? iconColorMap[icon] : ''
const iconBg = icon ? iconBgMap[icon] : ''
return (
<div className={`${soehne.className} space-y-6`}>
<>
<div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>{title}</h1>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
{title}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description}
</p>
@@ -148,28 +88,55 @@ export function InviteStatusCard({
</Button>
)}
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'default'}
className={
(action.variant || 'default') === 'default'
? `${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`
: action.variant === 'outline'
{actions.map((action, index) => {
const isPrimary = (action.variant || 'default') === 'default'
const isHovered = hoveredButtonIndex === index
if (isPrimary) {
return (
<Button
key={index}
onMouseEnter={() => setHoveredButtonIndex(index)}
onMouseLeave={() => setHoveredButtonIndex(null)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
<span className='flex items-center gap-1'>
{action.loading ? `${action.label}...` : action.label}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
)
}
return (
<Button
key={index}
variant={action.variant}
className={
action.variant === 'outline'
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
}
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{action.loading ? `${action.label}...` : action.label}
</Button>
))}
}
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{action.loading ? `${action.label}...` : action.label}
</Button>
)
})}
</div>
</div>
<div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`}
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
>
Need help?{' '}
<a
@@ -179,6 +146,6 @@ export function InviteStatusCard({
Contact support
</a>
</div>
</div>
</>
)
}

View File

@@ -74,6 +74,7 @@ import {
TableHeader,
TableRow,
Textarea,
TimePicker,
Tooltip,
Trash,
Trash2,
@@ -125,6 +126,7 @@ export default function PlaygroundPage() {
const [switchValue, setSwitchValue] = useState(false)
const [checkboxValue, setCheckboxValue] = useState(false)
const [sliderValue, setSliderValue] = useState([50])
const [timeValue, setTimeValue] = useState('09:30')
const [activeTab, setActiveTab] = useState('profile')
const [isDarkMode, setIsDarkMode] = useState(false)
@@ -491,6 +493,31 @@ export default function PlaygroundPage() {
</VariantRow>
</Section>
{/* TimePicker */}
<Section title='TimePicker'>
<VariantRow label='default'>
<div className='w-48'>
<TimePicker value={timeValue} onChange={setTimeValue} placeholder='Select time' />
</div>
<span className='text-[var(--text-secondary)] text-sm'>{timeValue}</span>
</VariantRow>
<VariantRow label='size sm'>
<div className='w-48'>
<TimePicker value='14:00' onChange={() => {}} placeholder='Small size' size='sm' />
</div>
</VariantRow>
<VariantRow label='no value'>
<div className='w-48'>
<TimePicker placeholder='Select time...' onChange={() => {}} />
</div>
</VariantRow>
<VariantRow label='disabled'>
<div className='w-48'>
<TimePicker value='09:00' disabled />
</div>
</VariantRow>
</Section>
{/* Breadcrumb */}
<Section title='Breadcrumb'>
<Breadcrumb

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface ChunkContextMenuProps {
isOpen: boolean
@@ -39,11 +45,24 @@ interface ChunkContextMenuProps {
* Whether add chunk is disabled
*/
disableAddChunk?: boolean
/**
* Number of selected chunks (for batch operations)
*/
selectedCount?: number
/**
* Number of enabled chunks in selection
*/
enabledCount?: number
/**
* Number of disabled chunks in selection
*/
disabledCount?: number
}
/**
* Context menu for chunks table.
* Shows chunk actions when right-clicking a row, or "Create chunk" when right-clicking empty space.
* Supports batch operations when multiple chunks are selected.
*/
export function ChunkContextMenu({
isOpen,
@@ -61,7 +80,20 @@ export function ChunkContextMenu({
disableToggleEnabled = false,
disableDelete = false,
disableAddChunk = false,
selectedCount = 1,
enabledCount = 0,
disabledCount = 0,
}: ChunkContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isChunkEnabled ? 'Disable' : 'Enable'
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
@@ -76,7 +108,8 @@ export function ChunkContextMenu({
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{hasChunk ? (
<>
{onOpenInNewTab && (
{/* Navigation */}
{!isMultiSelect && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
@@ -86,7 +119,10 @@ export function ChunkContextMenu({
Open in new tab
</PopoverItem>
)}
{onEdit && (
{!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
{/* Edit and copy actions */}
{!isMultiSelect && onEdit && (
<PopoverItem
onClick={() => {
onEdit()
@@ -96,7 +132,7 @@ export function ChunkContextMenu({
Edit
</PopoverItem>
)}
{onCopyContent && (
{!isMultiSelect && onCopyContent && (
<PopoverItem
onClick={() => {
onCopyContent()
@@ -106,6 +142,9 @@ export function ChunkContextMenu({
Copy content
</PopoverItem>
)}
{!isMultiSelect && (onEdit || onCopyContent) && <PopoverDivider />}
{/* State toggle */}
{onToggleEnabled && (
<PopoverItem
disabled={disableToggleEnabled}
@@ -114,9 +153,16 @@ export function ChunkContextMenu({
onClose()
}}
>
{isChunkEnabled ? 'Disable' : 'Enable'}
{getToggleLabel()}
</PopoverItem>
)}
{/* Destructive action */}
{onDelete &&
((!isMultiSelect && onOpenInNewTab) ||
(!isMultiSelect && onEdit) ||
(!isMultiSelect && onCopyContent) ||
onToggleEnabled) && <PopoverDivider />}
{onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -15,6 +15,7 @@ import {
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Badge,
Breadcrumb,
Button,
Checkbox,
@@ -107,14 +108,31 @@ interface DocumentProps {
documentName?: string
}
function getStatusBadgeStyles(enabled: boolean) {
return enabled
? 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
function truncateContent(content: string, maxLength = 150): string {
function truncateContent(content: string, maxLength = 150, searchQuery = ''): string {
if (content.length <= maxLength) return content
if (searchQuery.trim()) {
const searchTerms = searchQuery
.trim()
.split(/\s+/)
.filter((term) => term.length > 0)
.map((term) => term.toLowerCase())
for (const term of searchTerms) {
const matchIndex = content.toLowerCase().indexOf(term)
if (matchIndex !== -1) {
const contextBefore = 30
const start = Math.max(0, matchIndex - contextBefore)
const end = Math.min(content.length, start + maxLength)
let result = content.substring(start, end)
if (start > 0) result = `...${result}`
if (end < content.length) result = `${result}...`
return result
}
}
}
return `${content.substring(0, maxLength)}...`
}
@@ -655,13 +673,21 @@ export function Document({
/**
* Handle right-click on a chunk row
* If right-clicking on an unselected chunk, select only that chunk
* If right-clicking on a selected chunk with multiple selections, keep all selections
*/
const handleChunkContextMenu = useCallback(
(e: React.MouseEvent, chunk: ChunkData) => {
const isCurrentlySelected = selectedChunks.has(chunk.id)
if (!isCurrentlySelected) {
setSelectedChunks(new Set([chunk.id]))
}
setContextMenuChunk(chunk)
baseHandleContextMenu(e)
},
[baseHandleContextMenu]
[selectedChunks, baseHandleContextMenu]
)
/**
@@ -946,106 +972,114 @@ export function Document({
</TableCell>
</TableRow>
) : (
displayChunks.map((chunk: ChunkData) => (
<TableRow
key={chunk.id}
className='cursor-pointer hover:bg-[var(--surface-2)]'
onClick={() => handleChunkClick(chunk)}
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
>
<TableCell
className='w-[52px] py-[8px]'
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
displayChunks.map((chunk: ChunkData) => {
const isSelected = selectedChunks.has(chunk.id)
return (
<TableRow
key={chunk.id}
className={`${
isSelected
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
} cursor-pointer`}
onClick={() => handleChunkClick(chunk)}
onContextMenu={(e) => handleChunkContextMenu(e, chunk)}
>
<div className='flex items-center'>
<Checkbox
size='sm'
checked={selectedChunks.has(chunk.id)}
onCheckedChange={(checked) =>
handleSelectChunk(chunk.id, checked as boolean)
}
disabled={!userPermissions.canEdit}
aria-label={`Select chunk ${chunk.chunkIndex}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
</TableCell>
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
<TableCell
className='w-[52px] py-[8px]'
style={{ paddingLeft: '20.5px', paddingRight: 0 }}
>
<SearchHighlight
text={truncateContent(chunk.content)}
searchQuery={searchQuery}
/>
</span>
</TableCell>
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
{chunk.tokenCount > 1000
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
: chunk.tokenCount}
</TableCell>
<TableCell className='w-[12%] px-[12px] py-[8px]'>
<div className={getStatusBadgeStyles(chunk.enabled)}>
{chunk.enabled ? 'Enabled' : 'Disabled'}
</div>
</TableCell>
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
>
{chunk.enabled ? (
<Circle className='h-[14px] w-[14px]' />
) : (
<CircleOff className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to modify chunks'
: chunk.enabled
? 'Disable Chunk'
: 'Enable Chunk'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteChunk(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to delete chunks'
: 'Delete Chunk'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
))
<div className='flex items-center'>
<Checkbox
size='sm'
checked={selectedChunks.has(chunk.id)}
onCheckedChange={(checked) =>
handleSelectChunk(chunk.id, checked as boolean)
}
disabled={!userPermissions.canEdit}
aria-label={`Select chunk ${chunk.chunkIndex}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
</TableCell>
<TableCell className='w-[60px] py-[8px] pr-[12px] pl-[15px] font-mono text-[14px] text-[var(--text-primary)]'>
{chunk.chunkIndex}
</TableCell>
<TableCell className='px-[12px] py-[8px]'>
<span
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
title={chunk.content}
>
<SearchHighlight
text={truncateContent(chunk.content, 150, searchQuery)}
searchQuery={searchQuery}
/>
</span>
</TableCell>
<TableCell className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
{chunk.tokenCount > 1000
? `${(chunk.tokenCount / 1000).toFixed(1)}k`
: chunk.tokenCount.toLocaleString()}
</TableCell>
<TableCell className='w-[12%] px-[12px] py-[8px]'>
<Badge variant={chunk.enabled ? 'green' : 'gray'} size='sm'>
{chunk.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</TableCell>
<TableCell className='w-[14%] py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)] disabled:opacity-50'
>
{chunk.enabled ? (
<Circle className='h-[14px] w-[14px]' />
) : (
<CircleOff className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to modify chunks'
: chunk.enabled
? 'Disable Chunk'
: 'Enable Chunk'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteChunk(chunk.id)
}}
disabled={!userPermissions.canEdit}
className='h-[28px] w-[28px] p-0 text-[var(--text-muted)] hover:text-[var(--text-error)] disabled:opacity-50'
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{!userPermissions.canEdit
? 'Write permission required to delete chunks'
: 'Delete Chunk'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
@@ -1206,8 +1240,11 @@ export function Document({
onClose={handleContextMenuClose}
hasChunk={contextMenuChunk !== null}
isChunkEnabled={contextMenuChunk?.enabled ?? true}
selectedCount={selectedChunks.size}
enabledCount={enabledCount}
disabledCount={disabledCount}
onOpenInNewTab={
contextMenuChunk
contextMenuChunk && selectedChunks.size === 1
? () => {
const url = `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}/${documentId}?chunk=${contextMenuChunk.id}`
window.open(url, '_blank')
@@ -1215,7 +1252,7 @@ export function Document({
: undefined
}
onEdit={
contextMenuChunk
contextMenuChunk && selectedChunks.size === 1
? () => {
setSelectedChunk(contextMenuChunk)
setIsModalOpen(true)
@@ -1223,7 +1260,7 @@ export function Document({
: undefined
}
onCopyContent={
contextMenuChunk
contextMenuChunk && selectedChunks.size === 1
? () => {
navigator.clipboard.writeText(contextMenuChunk.content)
}
@@ -1231,12 +1268,22 @@ export function Document({
}
onToggleEnabled={
contextMenuChunk && userPermissions.canEdit
? () => handleToggleEnabled(contextMenuChunk.id)
? selectedChunks.size > 1
? () => {
if (disabledCount > 0) {
handleBulkEnable()
} else {
handleBulkDisable()
}
}
: () => handleToggleEnabled(contextMenuChunk.id)
: undefined
}
onDelete={
contextMenuChunk && userPermissions.canEdit
? () => handleDeleteChunk(contextMenuChunk.id)
? selectedChunks.size > 1
? handleBulkDelete
: () => handleDeleteChunk(contextMenuChunk.id)
: undefined
}
onAddChunk={

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import {
AlertCircle,
@@ -47,10 +48,12 @@ import {
AddDocumentsModal,
BaseTagsModal,
DocumentContextMenu,
RenameDocumentModal,
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { knowledgeKeys } from '@/hooks/queries/knowledge'
import {
useKnowledgeBase,
useKnowledgeBaseDocuments,
@@ -404,6 +407,7 @@ export function KnowledgeBase({
id,
knowledgeBaseName: passedKnowledgeBaseName,
}: KnowledgeBaseProps) {
const queryClient = useQueryClient()
const params = useParams()
const workspaceId = params.workspaceId as string
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
@@ -432,6 +436,8 @@ export function KnowledgeBase({
const [sortBy, setSortBy] = useState<DocumentSortField>('uploadedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const {
isOpen: isContextMenuOpen,
@@ -447,6 +453,8 @@ export function KnowledgeBase({
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
const {
documents,
pagination,
@@ -462,6 +470,7 @@ export function KnowledgeBase({
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
sortBy,
sortOrder,
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
})
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
@@ -528,25 +537,15 @@ export function KnowledgeBase({
)
useEffect(() => {
const hasProcessingDocuments = documents.some(
const processing = documents.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
setHasProcessingDocuments(processing)
if (!hasProcessingDocuments) return
const refreshInterval = setInterval(async () => {
try {
if (!isDeleting) {
await checkForDeadProcesses()
await refreshDocuments()
}
} catch (error) {
logger.error('Error refreshing documents:', error)
}
}, 3000)
return () => clearInterval(refreshInterval)
}, [documents, refreshDocuments, isDeleting])
if (processing) {
checkForDeadProcesses()
}
}, [documents])
/**
* Checks for documents with stale processing states and marks them as failed
@@ -666,25 +665,6 @@ export function KnowledgeBase({
await refreshDocuments()
let refreshAttempts = 0
const maxRefreshAttempts = 3
const refreshInterval = setInterval(async () => {
try {
refreshAttempts++
await refreshDocuments()
if (refreshAttempts >= maxRefreshAttempts) {
clearInterval(refreshInterval)
}
} catch (error) {
logger.error('Error refreshing documents after retry:', error)
clearInterval(refreshInterval)
}
}, 1000)
setTimeout(() => {
clearInterval(refreshInterval)
}, 4000)
logger.info(`Document retry initiated successfully for: ${docId}`)
} catch (err) {
logger.error('Error retrying document:', err)
@@ -699,6 +679,60 @@ export function KnowledgeBase({
}
}
/**
* Opens the rename document modal
*/
const handleRenameDocument = (doc: DocumentData) => {
setDocumentToRename(doc)
setShowRenameModal(true)
}
/**
* Saves the renamed document
*/
const handleSaveRename = async (documentId: string, newName: string) => {
const currentDoc = documents.find((doc) => doc.id === documentId)
const previousName = currentDoc?.filename
updateDocument(documentId, { filename: newName })
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
previous ? { ...previous, filename: newName } : previous
)
try {
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filename: newName }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to rename document')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to rename document')
}
logger.info(`Document renamed: ${documentId}`)
} catch (err) {
if (previousName !== undefined) {
updateDocument(documentId, { filename: previousName })
queryClient.setQueryData<DocumentData>(
knowledgeKeys.document(id, documentId),
(previous) => (previous ? { ...previous, filename: previousName } : previous)
)
}
logger.error('Error renaming document:', err)
throw err
}
}
/**
* Opens the delete document confirmation modal
*/
@@ -968,13 +1002,21 @@ export function KnowledgeBase({
/**
* Handle right-click on a document row
* If right-clicking on an unselected document, select only that document
* If right-clicking on a selected document with multiple selections, keep all selections
*/
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, doc: DocumentData) => {
const isCurrentlySelected = selectedDocuments.has(doc.id)
if (!isCurrentlySelected) {
setSelectedDocuments(new Set([doc.id]))
}
setContextMenuDocument(doc)
baseHandleContextMenu(e)
},
[baseHandleContextMenu]
[selectedDocuments, baseHandleContextMenu]
)
/**
@@ -1211,7 +1253,9 @@ export function KnowledgeBase({
<TableRow
key={doc.id}
className={`${
isSelected ? 'bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
isSelected
? 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
: 'hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]'
} ${doc.processingStatus === 'completed' ? 'cursor-pointer' : 'cursor-default'}`}
onClick={() => {
if (doc.processingStatus === 'completed') {
@@ -1558,6 +1602,17 @@ export function KnowledgeBase({
chunkingConfig={knowledgeBase?.chunkingConfig}
/>
{/* Rename Document Modal */}
{documentToRename && (
<RenameDocumentModal
open={showRenameModal}
onOpenChange={setShowRenameModal}
documentId={documentToRename.id}
initialName={documentToRename.filename}
onSave={handleSaveRename}
/>
)}
<ActionBar
selectedCount={selectedDocuments.size}
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
@@ -1580,8 +1635,11 @@ export function KnowledgeBase({
? getDocumentTags(contextMenuDocument, tagDefinitions).length > 0
: false
}
selectedCount={selectedDocuments.size}
enabledCount={enabledCount}
disabledCount={disabledCount}
onOpenInNewTab={
contextMenuDocument
contextMenuDocument && selectedDocuments.size === 1
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
@@ -1594,13 +1652,26 @@ export function KnowledgeBase({
}
: undefined
}
onRename={
contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
? () => handleRenameDocument(contextMenuDocument)
: undefined
}
onToggleEnabled={
contextMenuDocument && userPermissions.canEdit
? () => handleToggleEnabled(contextMenuDocument.id)
? selectedDocuments.size > 1
? () => {
if (disabledCount > 0) {
handleBulkEnable()
} else {
handleBulkDisable()
}
}
: () => handleToggleEnabled(contextMenuDocument.id)
: undefined
}
onViewTags={
contextMenuDocument
contextMenuDocument && selectedDocuments.size === 1
? () => {
const urlParams = new URLSearchParams({
kbName: knowledgeBaseName,
@@ -1614,7 +1685,9 @@ export function KnowledgeBase({
}
onDelete={
contextMenuDocument && userPermissions.canEdit
? () => handleDeleteDocument(contextMenuDocument.id)
? selectedDocuments.size > 1
? handleBulkDelete
: () => handleDeleteDocument(contextMenuDocument.id)
: undefined
}
onAddDocument={userPermissions.canEdit ? handleAddDocuments : undefined}

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface DocumentContextMenuProps {
isOpen: boolean
@@ -11,6 +17,7 @@ interface DocumentContextMenuProps {
* Document-specific actions (shown when right-clicking on a document)
*/
onOpenInNewTab?: () => void
onRename?: () => void
onToggleEnabled?: () => void
onViewTags?: () => void
onDelete?: () => void
@@ -42,11 +49,24 @@ interface DocumentContextMenuProps {
* Whether add document is disabled
*/
disableAddDocument?: boolean
/**
* Number of selected documents (for batch operations)
*/
selectedCount?: number
/**
* Number of enabled documents in selection
*/
enabledCount?: number
/**
* Number of disabled documents in selection
*/
disabledCount?: number
}
/**
* Context menu for documents table.
* Shows document actions when right-clicking a row, or "Add Document" when right-clicking empty space.
* Supports batch operations when multiple documents are selected.
*/
export function DocumentContextMenu({
isOpen,
@@ -54,6 +74,7 @@ export function DocumentContextMenu({
menuRef,
onClose,
onOpenInNewTab,
onRename,
onToggleEnabled,
onViewTags,
onDelete,
@@ -64,7 +85,20 @@ export function DocumentContextMenu({
disableToggleEnabled = false,
disableDelete = false,
disableAddDocument = false,
selectedCount = 1,
enabledCount = 0,
disabledCount = 0,
}: DocumentContextMenuProps) {
const isMultiSelect = selectedCount > 1
const getToggleLabel = () => {
if (isMultiSelect) {
if (disabledCount > 0) return 'Enable'
return 'Disable'
}
return isDocumentEnabled ? 'Disable' : 'Enable'
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
@@ -79,7 +113,8 @@ export function DocumentContextMenu({
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{hasDocument ? (
<>
{onOpenInNewTab && (
{/* Navigation */}
{!isMultiSelect && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
@@ -89,7 +124,20 @@ export function DocumentContextMenu({
Open in new tab
</PopoverItem>
)}
{hasTags && onViewTags && (
{!isMultiSelect && onOpenInNewTab && <PopoverDivider />}
{/* Edit and view actions */}
{!isMultiSelect && onRename && (
<PopoverItem
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{!isMultiSelect && hasTags && onViewTags && (
<PopoverItem
onClick={() => {
onViewTags()
@@ -99,6 +147,9 @@ export function DocumentContextMenu({
View tags
</PopoverItem>
)}
{!isMultiSelect && (onRename || (hasTags && onViewTags)) && <PopoverDivider />}
{/* State toggle */}
{onToggleEnabled && (
<PopoverItem
disabled={disableToggleEnabled}
@@ -107,9 +158,16 @@ export function DocumentContextMenu({
onClose()
}}
>
{isDocumentEnabled ? 'Disable' : 'Enable'}
{getToggleLabel()}
</PopoverItem>
)}
{/* Destructive action */}
{onDelete &&
((!isMultiSelect && onOpenInNewTab) ||
(!isMultiSelect && onRename) ||
(!isMultiSelect && hasTags && onViewTags) ||
onToggleEnabled) && <PopoverDivider />}
{onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -2,3 +2,4 @@ export { ActionBar } from './action-bar/action-bar'
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
export { DocumentContextMenu } from './document-context-menu'
export { RenameDocumentModal } from './rename-document-modal'

View File

@@ -0,0 +1 @@
export { RenameDocumentModal } from './rename-document-modal'

View File

@@ -0,0 +1,136 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
const logger = createLogger('RenameDocumentModal')
interface RenameDocumentModalProps {
open: boolean
onOpenChange: (open: boolean) => void
documentId: string
initialName: string
onSave: (documentId: string, newName: string) => Promise<void>
}
/**
* Modal for renaming a document.
* Only changes the display name, not the underlying storage key.
*/
export function RenameDocumentModal({
open,
onOpenChange,
documentId,
initialName,
onSave,
}: RenameDocumentModalProps) {
const [name, setName] = useState(initialName)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setName(initialName)
setError(null)
}
}, [open, initialName])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedName = name.trim()
if (!trimmedName) {
setError('Name is required')
return
}
if (trimmedName === initialName) {
onOpenChange(false)
return
}
setIsSubmitting(true)
setError(null)
try {
await onSave(documentId, trimmedName)
onOpenChange(false)
} catch (err) {
logger.error('Error renaming document:', err)
setError(err instanceof Error ? err.message : 'Failed to rename document')
} finally {
setIsSubmitting(false)
}
}
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>Rename Document</ModalHeader>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='document-name'>Name</Label>
<Input
id='document-name'
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
placeholder='Enter document name'
className={cn(error && 'border-[var(--text-error)]')}
disabled={isSubmitting}
autoFocus
maxLength={255}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<div className='flex w-full items-center justify-between gap-[12px]'>
{error ? (
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
{error}
</p>
) : (
<div />
)}
<div className='flex flex-shrink-0 gap-[8px]'>
<Button
variant='default'
onClick={() => onOpenChange(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface KnowledgeBaseContextMenuProps {
/**
@@ -104,6 +110,7 @@ export function KnowledgeBaseContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
onClick={() => {
@@ -114,6 +121,9 @@ export function KnowledgeBaseContextMenu({
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* View and copy actions */}
{showViewTags && onViewTags && (
<PopoverItem
onClick={() => {
@@ -134,6 +144,9 @@ export function KnowledgeBaseContextMenu({
Copy ID
</PopoverItem>
)}
{((showViewTags && onViewTags) || onCopyId) && <PopoverDivider />}
{/* Edit action */}
{showEdit && onEdit && (
<PopoverItem
disabled={disableEdit}
@@ -145,6 +158,14 @@ export function KnowledgeBaseContextMenu({
Edit
</PopoverItem>
)}
{/* Destructive action */}
{showDelete &&
onDelete &&
((showOpenInNewTab && onOpenInNewTab) ||
(showViewTags && onViewTags) ||
onCopyId ||
(showEdit && onEdit)) && <PopoverDivider />}
{showDelete && onDelete && (
<PopoverItem
disabled={disableDelete}

View File

@@ -3,5 +3,6 @@ export { LogDetails } from './log-details'
export { FileCards } from './log-details/components/file-download'
export { FrozenCanvas } from './log-details/components/frozen-canvas'
export { TraceSpans } from './log-details/components/trace-spans'
export { LogRowContextMenu } from './log-row-context-menu'
export { LogsList } from './logs-list'
export { AutocompleteSearch, LogsToolbar, NotificationSettings } from './logs-toolbar'

View File

@@ -0,0 +1 @@
export { LogRowContextMenu } from './log-row-context-menu'

View File

@@ -0,0 +1,108 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
log: WorkflowLog | null
onCopyExecutionId: () => void
onOpenWorkflow: () => void
onToggleWorkflowFilter: () => void
onClearAllFilters: () => void
isFilteredByThisWorkflow: boolean
hasActiveFilters: boolean
}
/**
* Context menu for log rows.
* Provides quick actions for copying data, navigation, and filtering.
*/
export function LogRowContextMenu({
isOpen,
position,
menuRef,
onClose,
log,
onCopyExecutionId,
onOpenWorkflow,
onToggleWorkflowFilter,
onClearAllFilters,
isFilteredByThisWorkflow,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasExecutionId = Boolean(log?.executionId)
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<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}>
{/* Copy action */}
<PopoverItem
disabled={!hasExecutionId}
onClick={() => {
onCopyExecutionId()
onClose()
}}
>
Copy Execution ID
</PopoverItem>
{/* Navigation */}
<PopoverDivider />
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onOpenWorkflow()
onClose()
}}
>
Open Workflow
</PopoverItem>
{/* Filter actions */}
<PopoverDivider />
{!isFilteredByThisWorkflow && (
<PopoverItem
disabled={!hasWorkflow}
onClick={() => {
onToggleWorkflowFilter()
onClose()
}}
>
Filter by Workflow
</PopoverItem>
)}
{hasActiveFilters && (
<PopoverItem
onClick={() => {
onClearAllFilters()
onClose()
}}
>
Clear Filters
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -10,6 +10,7 @@ import {
formatDate,
formatDuration,
getDisplayStatus,
LOG_COLUMNS,
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
@@ -21,6 +22,7 @@ interface LogRowProps {
log: WorkflowLog
isSelected: boolean
onClick: (log: WorkflowLog) => void
onContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null> | null
}
@@ -29,11 +31,21 @@ interface LogRowProps {
* Uses shallow comparison for the log object.
*/
const LogRow = memo(
function LogRow({ log, isSelected, onClick, selectedRowRef }: LogRowProps) {
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const handleClick = useCallback(() => onClick(log), [onClick, log])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (onContextMenu) {
e.preventDefault()
onContextMenu(e, log)
}
},
[onContextMenu, log]
)
return (
<div
ref={isSelected ? selectedRowRef : null}
@@ -42,25 +54,28 @@ const LogRow = memo(
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<div className='flex flex-1 items-center'>
{/* Date */}
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.date.width} ${LOG_COLUMNS.date.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactDate}
</span>
{/* Time */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.time.width} ${LOG_COLUMNS.time.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{formattedDate.compactTime}
</span>
{/* Status */}
<div className='w-[12%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.status.width} ${LOG_COLUMNS.status.minWidth}`}>
<StatusBadge status={getDisplayStatus(log.status)} />
</div>
{/* Workflow */}
<div className='flex w-[22%] min-w-[140px] items-center gap-[8px] pr-[8px]'>
<div
className={`flex ${LOG_COLUMNS.workflow.width} ${LOG_COLUMNS.workflow.minWidth} items-center gap-[8px] pr-[8px]`}
>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
@@ -70,13 +85,13 @@ const LogRow = memo(
</span>
</div>
{/* Cost */}
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-primary)]'>
<span
className={`${LOG_COLUMNS.cost.width} ${LOG_COLUMNS.cost.minWidth} font-medium text-[12px] text-[var(--text-primary)]`}
>
{typeof log.cost?.total === 'number' ? `$${log.cost.total.toFixed(4)}` : '—'}
</span>
{/* Trigger */}
<div className='w-[14%] min-w-[110px]'>
<div className={`${LOG_COLUMNS.trigger.width} ${LOG_COLUMNS.trigger.minWidth}`}>
{log.trigger ? (
<TriggerBadge trigger={log.trigger} />
) : (
@@ -84,8 +99,7 @@ const LogRow = memo(
)}
</div>
{/* Duration */}
<div className='w-[20%] min-w-[100px]'>
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration) || '—'}
</Badge>
@@ -125,6 +139,7 @@ interface RowProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
isFetchingNextPage: boolean
loaderRef: React.RefObject<HTMLDivElement | null>
@@ -140,11 +155,11 @@ function Row({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}: RowComponentProps<RowProps>) {
// Show loader for the last item if loading more
if (index >= logs.length) {
return (
<div style={style} className='flex items-center justify-center'>
@@ -171,6 +186,7 @@ function Row({
log={log}
isSelected={isSelected}
onClick={onLogClick}
onContextMenu={onLogContextMenu}
selectedRowRef={isSelected ? selectedRowRef : null}
/>
</div>
@@ -181,6 +197,7 @@ export interface LogsListProps {
logs: WorkflowLog[]
selectedLogId: string | null
onLogClick: (log: WorkflowLog) => void
onLogContextMenu?: (e: React.MouseEvent, log: WorkflowLog) => void
selectedRowRef: React.RefObject<HTMLTableRowElement | null>
hasNextPage: boolean
isFetchingNextPage: boolean
@@ -198,6 +215,7 @@ export function LogsList({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
hasNextPage,
isFetchingNextPage,
@@ -208,7 +226,6 @@ export function LogsList({
const containerRef = useRef<HTMLDivElement>(null)
const [listHeight, setListHeight] = useState(400)
// Measure container height for virtualization
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -226,7 +243,6 @@ export function LogsList({
return () => ro.disconnect()
}, [])
// Handle infinite scroll when nearing the end of the list
const handleRowsRendered = useCallback(
({ stopIndex }: { startIndex: number; stopIndex: number }) => {
const threshold = logs.length - 10
@@ -237,20 +253,27 @@ export function LogsList({
[logs.length, hasNextPage, isFetchingNextPage, onLoadMore]
)
// Calculate total item count including loader row
const itemCount = hasNextPage ? logs.length + 1 : logs.length
// Row props passed to each row component
const rowProps = useMemo<RowProps>(
() => ({
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
}),
[logs, selectedLogId, onLogClick, selectedRowRef, isFetchingNextPage, loaderRef]
[
logs,
selectedLogId,
onLogClick,
onLogContextMenu,
selectedRowRef,
isFetchingNextPage,
loaderRef,
]
)
return (

View File

@@ -22,7 +22,6 @@ import {
import { SlackIcon } from '@/components/icons'
import { Skeleton } from '@/components/ui'
import { cn } from '@/lib/core/utils/cn'
import { ALL_TRIGGER_TYPES, type TriggerType } from '@/lib/logs/types'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import {
type NotificationSubscription,
@@ -34,6 +33,7 @@ import {
} from '@/hooks/queries/notifications'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -133,7 +133,7 @@ export function NotificationSettings({
workflowIds: [] as string[],
allWorkflows: true,
levelFilter: ['info', 'error'] as LogLevel[],
triggerFilter: [...ALL_TRIGGER_TYPES] as TriggerType[],
triggerFilter: [...CORE_TRIGGER_TYPES] as CoreTriggerType[],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -185,6 +185,10 @@ export function NotificationSettings({
const hasSubscriptions = filteredSubscriptions.length > 0
// Compute form visibility synchronously to avoid empty state flash
// Show form if user explicitly opened it OR if loading is complete with no subscriptions
const displayForm = showForm || (!isLoading && !hasSubscriptions && !editingId)
const getSubscriptionsForTab = useCallback(
(tab: NotificationType) => {
return subscriptions.filter((s) => s.notificationType === tab)
@@ -192,18 +196,12 @@ export function NotificationSettings({
[subscriptions]
)
useEffect(() => {
if (!isLoading && !hasSubscriptions && !editingId) {
setShowForm(true)
}
}, [isLoading, hasSubscriptions, editingId, activeTab])
const resetForm = useCallback(() => {
setFormData({
workflowIds: [],
allWorkflows: true,
levelFilter: ['info', 'error'],
triggerFilter: [...ALL_TRIGGER_TYPES],
triggerFilter: [...CORE_TRIGGER_TYPES],
includeFinalOutput: false,
includeTraceSpans: false,
includeRateLimits: false,
@@ -516,7 +514,7 @@ export function NotificationSettings({
workflowIds: subscription.workflowIds || [],
allWorkflows: subscription.allWorkflows,
levelFilter: subscription.levelFilter as LogLevel[],
triggerFilter: subscription.triggerFilter as TriggerType[],
triggerFilter: subscription.triggerFilter as CoreTriggerType[],
includeFinalOutput: subscription.includeFinalOutput,
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
@@ -849,14 +847,14 @@ export function NotificationSettings({
<div className='flex flex-col gap-[8px]'>
<Label className='text-[var(--text-secondary)]'>Trigger Type Filters</Label>
<Combobox
options={ALL_TRIGGER_TYPES.map((trigger) => ({
options={CORE_TRIGGER_TYPES.map((trigger) => ({
label: trigger.charAt(0).toUpperCase() + trigger.slice(1),
value: trigger,
}))}
multiSelect
multiSelectValues={formData.triggerFilter}
onMultiSelectChange={(values) => {
setFormData({ ...formData, triggerFilter: values as TriggerType[] })
setFormData({ ...formData, triggerFilter: values as CoreTriggerType[] })
setFormErrors({ ...formErrors, triggerFilter: '' })
}}
placeholder='Select trigger types...'
@@ -1210,7 +1208,7 @@ export function NotificationSettings({
)
const renderTabContent = () => {
if (showForm) {
if (displayForm) {
return renderForm()
}
@@ -1279,7 +1277,7 @@ export function NotificationSettings({
</ModalTabs>
<ModalFooter>
{showForm ? (
{displayForm ? (
<>
{hasSubscriptions && (
<Button

View File

@@ -17,15 +17,15 @@ import {
} from '@/components/emcn'
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
import { cn } from '@/lib/core/utils/cn'
import { hasActiveFilters } from '@/lib/logs/filters'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { useFolderStore } from '@/stores/folders/store'
import { useFilterStore } from '@/stores/logs/filters/store'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { AutocompleteSearch } from './components/search'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
@@ -182,6 +182,7 @@ export function LogsToolbar({
endDate,
setDateRange,
clearDateRange,
resetFilters,
} = useFilterStore()
const [datePickerOpen, setDatePickerOpen] = useState(false)
@@ -346,23 +347,23 @@ export function LogsToolbar({
setDatePickerOpen(false)
}, [timeRange, startDate, previousTimeRange, setTimeRange])
const hasActiveFilters = useMemo(() => {
return (
level !== 'all' ||
workflowIds.length > 0 ||
folderIds.length > 0 ||
triggers.length > 0 ||
timeRange !== 'All time'
)
}, [level, workflowIds, folderIds, triggers, timeRange])
const filtersActive = useMemo(
() =>
hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery,
}),
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
)
const handleClearFilters = useCallback(() => {
setLevel('all')
setWorkflowIds([])
setFolderIds([])
setTriggers([])
clearDateRange()
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
resetFilters()
onSearchQueryChange('')
}, [resetFilters, onSearchQueryChange])
return (
<div className='flex flex-col gap-[19px]'>
@@ -462,7 +463,7 @@ export function LogsToolbar({
</div>
<div className='ml-auto flex items-center gap-[8px]'>
{/* Clear Filters Button */}
{hasActiveFilters && (
{filtersActive && (
<Button
variant='active'
onClick={handleClearFilters}

View File

@@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import {
getEndDateFromTimeRange,
getStartDateFromTimeRange,
hasActiveFilters,
} from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useFolders } from '@/hooks/queries/folders'
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
@@ -12,7 +16,15 @@ import { useDebounce } from '@/hooks/use-debounce'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useUserPermissionsContext } from '../providers/workspace-permissions-provider'
import { Dashboard, LogDetails, LogsList, LogsToolbar, NotificationSettings } from './components'
import {
Dashboard,
LogDetails,
LogRowContextMenu,
LogsList,
LogsToolbar,
NotificationSettings,
} from './components'
import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils'
const LOGS_PER_PAGE = 50 as const
const REFRESH_SPINNER_DURATION_MS = 1000 as const
@@ -35,10 +47,12 @@ export default function Logs() {
level,
workflowIds,
folderIds,
setWorkflowIds,
setSearchQuery: setStoreSearchQuery,
triggers,
viewMode,
setViewMode,
resetFilters,
} = useFilterStore()
useEffect(() => {
@@ -71,6 +85,11 @@ export default function Logs() {
const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const logFilters = useMemo(
() => ({
timeRange,
@@ -216,6 +235,56 @@ export default function Logs() {
prevSelectedLogRef.current = null
}, [])
const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuLog(log)
setContextMenuOpen(true)
}, [])
const handleCopyExecutionId = useCallback(() => {
if (contextMenuLog?.executionId) {
navigator.clipboard.writeText(contextMenuLog.executionId)
}
}, [contextMenuLog])
const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (wfId) {
window.open(`/workspace/${workspaceId}/w/${wfId}`, '_blank')
}
}, [contextMenuLog, workspaceId])
const handleToggleWorkflowFilter = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (!wfId) return
if (workflowIds.length === 1 && workflowIds[0] === wfId) {
setWorkflowIds([])
} else {
setWorkflowIds([wfId])
}
}, [contextMenuLog, workflowIds, setWorkflowIds])
const handleClearAllFilters = useCallback(() => {
resetFilters()
setSearchQuery('')
}, [resetFilters, setSearchQuery])
const contextMenuWorkflowId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
const isFilteredByThisWorkflow = Boolean(
contextMenuWorkflowId && workflowIds.length === 1 && workflowIds[0] === contextMenuWorkflowId
)
const filtersActive = hasActiveFilters({
timeRange,
level,
workflowIds,
folderIds,
triggers,
searchQuery: debouncedSearchQuery,
})
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
@@ -400,27 +469,17 @@ export default function Logs() {
{/* Table header */}
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px] dark:bg-[var(--surface-3)]'>
<div className='flex items-center'>
<span className='w-[8%] min-w-[70px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Date
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Time
</span>
<span className='w-[12%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Status
</span>
<span className='w-[22%] min-w-[140px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</span>
<span className='w-[12%] min-w-[90px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Cost
</span>
<span className='w-[14%] min-w-[110px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Trigger
</span>
<span className='w-[20%] min-w-[100px] font-medium text-[12px] text-[var(--text-tertiary)]'>
Duration
</span>
{LOG_COLUMN_ORDER.map((key) => {
const col = LOG_COLUMNS[key]
return (
<span
key={key}
className={`${col.width} ${col.minWidth} font-medium text-[12px] text-[var(--text-tertiary)]`}
>
{col.label}
</span>
)
})}
</div>
</div>
@@ -452,6 +511,7 @@ export default function Logs() {
logs={logs}
selectedLogId={selectedLog?.id ?? null}
onLogClick={handleLogClick}
onLogContextMenu={handleLogContextMenu}
selectedRowRef={selectedRowRef}
hasNextPage={logsQuery.hasNextPage ?? false}
isFetchingNextPage={logsQuery.isFetchingNextPage}
@@ -481,6 +541,20 @@ export default function Logs() {
open={isNotificationSettingsOpen}
onOpenChange={setIsNotificationSettingsOpen}
/>
<LogRowContextMenu
isOpen={contextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={() => setContextMenuOpen(false)}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onOpenWorkflow={handleOpenWorkflow}
onToggleWorkflowFilter={handleToggleWorkflowFilter}
onClearAllFilters={handleClearAllFilters}
isFilteredByThisWorkflow={isFilteredByThisWorkflow}
hasActiveFilters={filtersActive}
/>
</div>
)
}

View File

@@ -3,8 +3,32 @@ import { format } from 'date-fns'
import { Badge } from '@/components/emcn'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
/** Column configuration for logs table - shared between header and rows */
export const LOG_COLUMNS = {
date: { width: 'w-[8%]', minWidth: 'min-w-[70px]', label: 'Date' },
time: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Time' },
status: { width: 'w-[12%]', minWidth: 'min-w-[100px]', label: 'Status' },
workflow: { width: 'w-[22%]', minWidth: 'min-w-[140px]', label: 'Workflow' },
cost: { width: 'w-[12%]', minWidth: 'min-w-[90px]', label: 'Cost' },
trigger: { width: 'w-[14%]', minWidth: 'min-w-[110px]', label: 'Trigger' },
duration: { width: 'w-[20%]', minWidth: 'min-w-[100px]', label: 'Duration' },
} as const
/** Type-safe column key derived from LOG_COLUMNS */
export type LogColumnKey = keyof typeof LOG_COLUMNS
/** Ordered list of column keys for rendering table headers */
export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'date',
'time',
'status',
'workflow',
'cost',
'trigger',
'duration',
] as const
/** Possible execution status values for workflow logs */
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'

View File

@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
{formattedContent && !formattedContent.startsWith('Uploaded') && (
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-gray-100 text-sm leading-[1.25rem]'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} />
</div>
</div>
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
return (
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#E8E8E8] text-sm leading-[1.25rem]'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} />
{message.isStreaming && <StreamingIndicator />}
</div>

View File

@@ -7,8 +7,8 @@ import {
Badge,
Popover,
PopoverContent,
PopoverDivider,
PopoverItem,
PopoverSection,
PopoverTrigger,
} from '@/components/emcn'
import {
@@ -468,7 +468,7 @@ export function OutputSelect({
disablePortal={disablePopoverPortal}
>
<div className='space-y-[2px]'>
{Object.entries(groupedOutputs).map(([blockName, outputs]) => {
{Object.entries(groupedOutputs).map(([blockName, outputs], groupIndex, groupArray) => {
const startIndex = flattenedOutputs.findIndex((o) => o.blockName === blockName)
const firstOutput = outputs[0]
@@ -489,12 +489,10 @@ export function OutputSelect({
return (
<div key={blockName}>
<PopoverSection>
<div className='flex items-center gap-1.5'>
<TagIcon icon={blockIcon} color={blockColor} />
<span>{blockName}</span>
</div>
</PopoverSection>
<div className='flex items-center gap-1.5 px-[6px] py-[4px]'>
<TagIcon icon={blockIcon} color={blockColor} />
<span className='font-medium text-[13px]'>{blockName}</span>
</div>
<div className='flex flex-col gap-[2px]'>
{outputs.map((output, localIndex) => {
@@ -509,14 +507,13 @@ export function OutputSelect({
onClick={() => handleOutputSelection(output.label)}
onMouseEnter={() => setHighlightedIndex(globalIndex)}
>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)]'>
{output.path}
</span>
<span className='min-w-0 flex-1 truncate'>{output.path}</span>
{isSelectedValue(output) && <Check className='h-3 w-3 flex-shrink-0' />}
</PopoverItem>
)
})}
</div>
{groupIndex < groupArray.length - 1 && <PopoverDivider />}
</div>
)
})}

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
@@ -48,7 +54,13 @@ export function BlockContextMenu({
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -59,7 +71,7 @@ export function BlockContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy */}
{/* Clipboard actions */}
<PopoverItem
className='group'
onClick={() => {
@@ -70,8 +82,6 @@ export function BlockContextMenu({
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -83,8 +93,6 @@ export function BlockContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Duplicate - hide for starter blocks */}
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
@@ -97,20 +105,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Delete */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
{/* Enable/Disable - hide if all blocks are notes */}
{/* Toggle and edit actions */}
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -122,8 +118,6 @@ export function BlockContextMenu({
{getToggleEnabledLabel()}
</PopoverItem>
)}
{/* Flip Handles - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
@@ -135,8 +129,6 @@ export function BlockContextMenu({
Flip Handles
</PopoverItem>
)}
{/* Remove from Subflow - only show when applicable */}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -149,7 +141,8 @@ export function BlockContextMenu({
</PopoverItem>
)}
{/* Rename - only for single block, not subflows */}
{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
{isSingleBlock && !isSubflow && (
<PopoverItem
disabled={disableEdit}
@@ -161,8 +154,6 @@ export function BlockContextMenu({
Rename
</PopoverItem>
)}
{/* Open Editor - only for single block */}
{isSingleBlock && (
<PopoverItem
onClick={() => {
@@ -173,6 +164,20 @@ export function BlockContextMenu({
Open Editor
</PopoverItem>
)}
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
</PopoverContent>
</Popover>
)

View File

@@ -1,6 +1,12 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
@@ -28,7 +34,13 @@ export function PaneContextMenu({
canRedo = false,
}: PaneContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<Popover
open={isOpen}
onOpenChange={onClose}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
@@ -39,7 +51,7 @@ export function PaneContextMenu({
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Undo */}
{/* History actions */}
<PopoverItem
className='group'
disabled={disableEdit || !canUndo}
@@ -51,8 +63,6 @@ export function PaneContextMenu({
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Redo */}
<PopoverItem
className='group'
disabled={disableEdit || !canRedo}
@@ -65,7 +75,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Paste */}
{/* Edit and creation actions */}
<PopoverDivider />
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
@@ -77,8 +88,6 @@ export function PaneContextMenu({
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Add Block */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -90,8 +99,6 @@ export function PaneContextMenu({
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
</PopoverItem>
{/* Auto-layout */}
<PopoverItem
className='group'
disabled={disableEdit}
@@ -104,7 +111,8 @@ export function PaneContextMenu({
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Logs */}
{/* Navigation actions */}
<PopoverDivider />
<PopoverItem
className='group'
onClick={() => {
@@ -115,8 +123,6 @@ export function PaneContextMenu({
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Variables */}
<PopoverItem
onClick={() => {
onOpenVariables()
@@ -125,8 +131,6 @@ export function PaneContextMenu({
>
Variables
</PopoverItem>
{/* Open Chat */}
<PopoverItem
onClick={() => {
onOpenChat()
@@ -136,7 +140,8 @@ export function PaneContextMenu({
Open Chat
</PopoverItem>
{/* Invite to Workspace - admin only */}
{/* Admin action */}
<PopoverDivider />
<PopoverItem
disabled={disableAdmin}
onClick={() => {

View File

@@ -89,7 +89,7 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea
{children}
</a>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm p-3'>
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
<span className='text-sm'>{href}</span>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Constants for ComboBox component behavior
@@ -48,6 +51,19 @@ interface ComboBoxProps {
placeholder?: string
/** Configuration for the sub-block */
config: SubBlockConfig
/** Async function to fetch options dynamically */
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
}
export function ComboBox({
@@ -61,23 +77,89 @@ export function ComboBox({
disabled,
placeholder = 'Type or select an option...',
config,
fetchOptions,
fetchOptionById,
dependsOn,
}: ComboBoxProps) {
// Hooks and context
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const reactFlowInstance = useReactFlow()
// Dependency tracking for fetchOptions
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOnFields, activeWorkflowId, blockId]
)
)
// State management
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousDependencyValuesRef = useRef<string>('')
/**
* Fetches options from the async fetchOptions function if provided
*/
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
setFetchError(errorMessage)
setFetchedOptions([])
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
// Determine the active value based on mode (preview vs. controlled vs. store)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Evaluate options if provided as a function
const evaluatedOptions = useMemo(() => {
// Evaluate static options if provided as a function
const staticOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
// Normalize fetched options to match ComboBoxOption format
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
}, [fetchedOptions])
// Merge static and fetched options - fetched options take priority when available
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
let opts: ComboBoxOption[] =
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
}
return opts
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
// Convert options to Combobox format
const comboboxOptions = useMemo((): ComboboxOption[] => {
return evaluatedOptions.map((option) => {
@@ -160,6 +242,94 @@ export function ComboBox({
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {
if (fetchOptions && dependsOnFields.length > 0) {
const currentDependencyValuesStr = JSON.stringify(dependencyValues)
const previousDependencyValuesStr = previousDependencyValuesRef.current
if (
previousDependencyValuesStr &&
currentDependencyValuesStr !== previousDependencyValuesStr
) {
setFetchedOptions([])
setHydratedOption(null)
}
previousDependencyValuesRef.current = currentDependencyValuesStr
}
}, [dependencyValues, fetchOptions, dependsOnFields.length])
// Fetch options when needed (on mount, when enabled, or when dependencies change)
useEffect(() => {
if (
fetchOptions &&
!isPreview &&
!disabled &&
fetchedOptions.length === 0 &&
!isLoadingOptions &&
!fetchError
) {
fetchOptionsIfNeeded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [
fetchOptions,
isPreview,
disabled,
fetchedOptions.length,
isLoadingOptions,
fetchError,
dependencyValues,
])
// Hydrate the stored value's label by fetching it individually
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
const valueToHydrate = value as string | null | undefined
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = staticOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
value,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
staticOptions,
hydratedOption?.id,
])
/**
* Handles wheel event for ReactFlow zoom control
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +417,13 @@ export function ComboBox({
return option.id === newValue
})
if (!matchedOption) {
return
}
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
}}
isPreview={isPreview}
@@ -293,6 +465,13 @@ export function ComboBox({
onWheel: handleWheel,
autoComplete: 'off',
}}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
/>
)}
</SubBlockInputController>

View File

@@ -12,6 +12,7 @@ import {
getCodeEditorProps,
highlight,
languages,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
@@ -74,6 +75,8 @@ interface ConditionInputProps {
previewValue?: string | null
/** Whether the component is disabled */
disabled?: boolean
/** Mode: 'condition' for code editor, 'router' for text input */
mode?: 'condition' | 'router'
}
/**
@@ -101,7 +104,9 @@ export function ConditionInput({
isPreview = false,
previewValue,
disabled = false,
mode = 'condition',
}: ConditionInputProps) {
const isRouterMode = mode === 'router'
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
@@ -161,32 +166,50 @@ export function ConditionInput({
const shouldPersistRef = useRef<boolean>(false)
/**
* Creates default if/else conditional blocks with stable IDs.
* Creates default blocks with stable IDs.
* For conditions: if/else blocks. For router: one route block.
*
* @returns Array of two default blocks (if and else)
* @returns Array of default blocks
*/
const createDefaultBlocks = (): ConditionalBlock[] => [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
const createDefaultBlocks = (): ConditionalBlock[] => {
if (isRouterMode) {
return [
{
id: generateStableId(blockId, 'route1'),
title: 'route1',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
return [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
// Initialize with a loading state instead of default blocks
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
@@ -270,10 +293,13 @@ export function ConditionInput({
const parsedBlocks = safeParseJSON(effectiveValueStr)
if (parsedBlocks) {
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
// For router mode, keep original titles. For condition mode, assign if/else if/else
const blocksWithCorrectTitles = isRouterMode
? parsedBlocks
: parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
setConditionalBlocks(blocksWithCorrectTitles)
hasInitializedRef.current = true
@@ -573,12 +599,17 @@ export function ConditionInput({
/**
* Updates block titles based on their position in the array.
* First block is always 'if', last is 'else', middle ones are 'else if'.
* For conditions: First block is 'if', last is 'else', middle ones are 'else if'.
* For router: Titles are user-editable and not auto-updated.
*
* @param blocks - Array of conditional blocks
* @returns Updated blocks with correct titles
*/
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
if (isRouterMode) {
// For router mode, don't change titles - they're user-editable
return blocks
}
return blocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
@@ -590,13 +621,15 @@ export function ConditionInput({
if (isPreview || disabled) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
if (conditionalBlocks[blockIndex]?.title === 'else') return
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`)
const newBlockId = isRouterMode
? generateStableId(blockId, `route-${Date.now()}`)
: generateStableId(blockId, `else-if-${Date.now()}`)
const newBlock: ConditionalBlock = {
id: newBlockId,
title: '',
title: isRouterMode ? `route-${Date.now()}` : '',
value: '',
showTags: false,
showEnvVars: false,
@@ -710,13 +743,15 @@ export function ConditionInput({
<div
className={cn(
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
isRouterMode
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
: block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
)}
>
<span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
{block.title}
{isRouterMode ? `Route ${index + 1}` : block.title}
</span>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
@@ -724,7 +759,7 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => addBlock(block.id)}
disabled={isPreview || disabled || block.title === 'else'}
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
className='h-auto p-0'
>
<Plus className='h-[14px] w-[14px]' />
@@ -739,7 +774,12 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => moveBlock(block.id, 'up')}
disabled={isPreview || index === 0 || disabled || block.title === 'else'}
disabled={
isPreview ||
index === 0 ||
disabled ||
(!isRouterMode && block.title === 'else')
}
className='h-auto p-0'
>
<ChevronUp className='h-[14px] w-[14px]' />
@@ -758,8 +798,8 @@ export function ConditionInput({
isPreview ||
disabled ||
index === conditionalBlocks.length - 1 ||
conditionalBlocks[index + 1]?.title === 'else' ||
block.title === 'else'
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
(!isRouterMode && block.title === 'else')
}
className='h-auto p-0'
>
@@ -775,18 +815,122 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => removeBlock(block.id)}
disabled={isPreview || conditionalBlocks.length === 1 || disabled}
disabled={isPreview || disabled || conditionalBlocks.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Block</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete Condition</Tooltip.Content>
<Tooltip.Content>
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{block.title !== 'else' &&
{/* Router mode: show description textarea with tag/env var support */}
{isRouterMode && (
<div
className='relative'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(block.id, e)}
>
<Textarea
data-router-block-id={block.id}
value={block.value}
onChange={(e) => {
if (!isPreview && !disabled) {
const newValue = e.target.value
const pos = e.target.selectionStart ?? 0
const tagTrigger = checkTagTrigger(newValue, pos)
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
shouldPersistRef.current = true
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
value: newValue,
showTags: tagTrigger.show,
showEnvVars: envVarTrigger.show,
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
cursorPosition: pos,
}
: b
)
)
}
}}
onBlur={() => {
setTimeout(() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id ? { ...b, showTags: false, showEnvVars: false } : b
)
)
}, 150)
}}
placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2}
/>
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
searchTerm={block.searchTerm}
inputValue={block.value}
cursorPosition={block.cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showEnvVars: false,
searchTerm: '',
}
: b
)
)
}}
/>
)}
{block.showTags && (
<TagDropdown
visible={block.showTags}
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
blockId={blockId}
activeSourceBlockId={block.activeSourceBlockId}
inputValue={block.value}
cursorPosition={block.cursorPosition}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showTags: false,
activeSourceBlockId: null,
}
: b
)
)
}}
/>
)}
</div>
)}
{/* Condition mode: show code editor */}
{!isRouterMode &&
block.title !== 'else' &&
(() => {
const blockLineCount = block.value.split('\n').length
const blockGutterWidth = calculateGutterWidth(blockLineCount)

View File

@@ -44,8 +44,16 @@ interface DropdownProps {
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
/** Enable search input in dropdown */
searchable?: boolean
}
/**
@@ -69,7 +77,9 @@ export function Dropdown({
placeholder = 'Select an option...',
multiSelect = false,
fetchOptions,
fetchOptionById,
dependsOn,
searchable = false,
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string | string[]>(blockId, subBlockId) as [
string | string[] | null | undefined,
@@ -95,6 +105,7 @@ export function Dropdown({
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousModeRef = useRef<string | null>(null)
const previousDependencyValuesRef = useRef<string>('')
@@ -113,7 +124,13 @@ export function Dropdown({
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
const singleValue = multiSelect ? null : (value as string | null | undefined)
const multiValues = multiSelect ? (value as string[] | null | undefined) || [] : null
const multiValues = multiSelect
? Array.isArray(value)
? value
: value
? [value as string]
: []
: null
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
@@ -141,11 +158,23 @@ export function Dropdown({
}, [fetchedOptions])
const availableOptions = useMemo(() => {
if (fetchOptions && normalizedFetchedOptions.length > 0) {
return normalizedFetchedOptions
let opts: DropdownOption[] =
fetchOptions && normalizedFetchedOptions.length > 0
? normalizedFetchedOptions
: evaluatedOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
}
return evaluatedOptions
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions])
return opts
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
/**
* Convert dropdown options to Combobox format
@@ -301,7 +330,7 @@ export function Dropdown({
)
/**
* Effect to clear fetched options when dependencies actually change
* Effect to clear fetched options and hydrated option when dependencies actually change
* This ensures options are refetched with new dependency values (e.g., new credentials)
*/
useEffect(() => {
@@ -314,6 +343,7 @@ export function Dropdown({
currentDependencyValuesStr !== previousDependencyValuesStr
) {
setFetchedOptions([])
setHydratedOption(null)
}
previousDependencyValuesRef.current = currentDependencyValuesStr
@@ -329,18 +359,72 @@ export function Dropdown({
!isPreview &&
!disabled &&
fetchedOptions.length === 0 &&
!isLoadingOptions
!isLoadingOptions &&
!fetchError
) {
fetchOptionsIfNeeded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [
fetchOptions,
isPreview,
disabled,
fetchedOptions.length,
isLoadingOptions,
fetchOptionsIfNeeded,
dependencyValues, // Refetch when dependencies change
fetchError,
dependencyValues,
])
/**
* Effect to hydrate the stored value's label by fetching it individually
* This ensures the correct label is shown before the full options list loads
*/
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
// Get the value to hydrate (single value only, not multi-select)
const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined)
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = evaluatedOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
singleValue,
multiSelect,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
evaluatedOptions,
hydratedOption?.id,
])
/**
@@ -363,7 +447,7 @@ export function Dropdown({
)
}, [multiSelect, multiValues, optionMap])
const isSearchable = subBlockId === 'operation'
const isSearchable = searchable || (subBlockId === 'operation' && comboboxOptions.length > 5)
return (
<Combobox
@@ -385,7 +469,7 @@ export function Dropdown({
isLoading={isLoadingOptions}
error={fetchError}
searchable={isSearchable}
searchPlaceholder='Search operations...'
searchPlaceholder='Search...'
/>
)
}

View File

@@ -8,6 +8,7 @@ import {
PopoverAnchor,
PopoverBackButton,
PopoverContent,
PopoverDivider,
PopoverFolder,
PopoverItem,
PopoverScrollArea,
@@ -754,6 +755,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
}
} else if (sourceBlock.type === 'human_in_the_loop') {
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const isSelfReference = activeSourceBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
// For self-reference, only show url and resumeEndpoint (not response format fields)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
} else {
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
@@ -1073,7 +1092,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
}
} else if (accessibleBlock.type === 'human_in_the_loop') {
blockTags = [`${normalizedBlockName}.url`]
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
const isSelfReference = accessibleBlockId === blockId
if (dynamicOutputs.length > 0) {
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
// For self-reference, only show url and resumeEndpoint (not response format fields)
blockTags = isSelfReference
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
: allTags
} else {
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
}
} else {
const operationValue =
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
@@ -1426,7 +1457,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
return (
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()}>
<Popover open={visible} onOpenChange={(open) => !open && onClose?.()} colorScheme='inverted'>
<PopoverAnchor asChild>
<div
className={cn('pointer-events-none', className)}
@@ -1502,23 +1533,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
<span className='flex-1 truncate'>
{tag.startsWith(TAG_PREFIXES.VARIABLE)
? tag.substring(TAG_PREFIXES.VARIABLE.length)
: tag}
</span>
{variableInfo && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{variableInfo.type}
</span>
)}
</PopoverItem>
)
})}
{nestedBlockTagGroups.length > 0 && <PopoverDivider rootOnly />}
</>
)}
{nestedBlockTagGroups.map((group: NestedBlockTagGroup) => {
{nestedBlockTagGroups.map((group: NestedBlockTagGroup, groupIndex: number) => {
const blockConfig = getBlock(group.blockType)
let blockColor = blockConfig?.bgColor || BLOCK_COLORS.DEFAULT
@@ -1565,9 +1597,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}}
>
<TagIcon icon={tagIcon} color={blockColor} />
<span className='flex-1 truncate font-medium text-[var(--text-primary)]'>
{group.blockName}
</span>
<span className='flex-1 truncate font-medium'>{group.blockName}</span>
</PopoverItem>
{group.nestedTags.map((nestedTag) => {
if (nestedTag.fullTag === rootTag) {
@@ -1650,11 +1680,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{child.display}
</span>
<span className='flex-1 truncate'>{child.display}</span>
{childType && childType !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{childType}
</span>
)}
@@ -1722,17 +1750,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}}
>
<span className='flex-1 truncate text-[var(--text-primary)]'>
{nestedTag.display}
</span>
<span className='flex-1 truncate'>{nestedTag.display}</span>
{tagDescription && tagDescription !== 'any' && (
<span className='ml-auto text-[10px] text-[var(--text-secondary)]'>
<span className='ml-auto text-[10px] text-[var(--text-muted-inverse)]'>
{tagDescription}
</span>
)}
</PopoverItem>
)
})}
{groupIndex < nestedBlockTagGroups.length - 1 && <PopoverDivider rootOnly />}
</div>
)
})}

View File

@@ -1,8 +1,6 @@
'use client'
import * as React from 'react'
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { TimePicker } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface TimeInputProps {
@@ -15,6 +13,10 @@ interface TimeInputProps {
disabled?: boolean
}
/**
* Time input wrapper for sub-block editor.
* Connects the EMCN TimePicker to the sub-block store.
*/
export function TimeInput({
blockId,
subBlockId,
@@ -26,143 +28,20 @@ export function TimeInput({
}: TimeInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
const [isOpen, setIsOpen] = React.useState(false)
// Convert 24h time string to display format (12h with AM/PM)
const formatDisplayTime = (time: string) => {
if (!time) return ''
const [hours, minutes] = time.split(':')
const hour = Number.parseInt(hours, 10)
const ampm = hour >= 12 ? 'PM' : 'AM'
const displayHour = hour % 12 || 12
return `${displayHour}:${minutes} ${ampm}`
}
// Convert display time to 24h format for storage
const formatStorageTime = (hour: number, minute: number, ampm: string) => {
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
}
const [hour, setHour] = React.useState<string>('12')
const [minute, setMinute] = React.useState<string>('00')
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>('AM')
// Update the time when any component changes
const updateTime = (newHour?: string, newMinute?: string, newAmpm?: 'AM' | 'PM') => {
const handleChange = (newValue: string) => {
if (isPreview || disabled) return
const h = Number.parseInt(newHour ?? hour) || 12
const m = Number.parseInt(newMinute ?? minute) || 0
const p = newAmpm ?? ampm
setStoreValue(formatStorageTime(h, m, p))
}
// Initialize from existing value
React.useEffect(() => {
if (value) {
const [hours, minutes] = value.split(':')
const hour24 = Number.parseInt(hours, 10)
const _minute = Number.parseInt(minutes, 10)
const isAM = hour24 < 12
setHour((hour24 % 12 || 12).toString())
setMinute(minutes)
setAmpm(isAM ? 'AM' : 'PM')
}
}, [value])
const handleBlur = () => {
updateTime()
setIsOpen(false)
setStoreValue(newValue)
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open)
if (!open) {
handleBlur()
}
}}
>
<PopoverTrigger asChild>
<div className='relative w-full cursor-pointer'>
<Input
readOnly
disabled={isPreview || disabled}
value={value ? formatDisplayTime(value) : ''}
placeholder={placeholder || 'Select time'}
autoComplete='off'
className={cn('cursor-pointer', !value && 'text-muted-foreground', className)}
/>
</div>
</PopoverTrigger>
<PopoverContent className='w-auto p-4'>
<div className='flex items-center space-x-2'>
<Input
className='w-[4rem]'
value={hour}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '')
if (val === '') {
setHour('')
return
}
const numVal = Number.parseInt(val)
if (!Number.isNaN(numVal)) {
const newHour = Math.min(12, Math.max(1, numVal)).toString()
setHour(newHour)
updateTime(newHour)
}
}}
onBlur={() => {
const numVal = Number.parseInt(hour) || 12
setHour(numVal.toString())
updateTime(numVal.toString())
}}
type='text'
autoComplete='off'
/>
<span className='text-[var(--text-primary)]'>:</span>
<Input
className='w-[4rem]'
value={minute}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '')
if (val === '') {
setMinute('')
return
}
const numVal = Number.parseInt(val)
if (!Number.isNaN(numVal)) {
const newMinute = Math.min(59, Math.max(0, numVal)).toString().padStart(2, '0')
setMinute(newMinute)
updateTime(undefined, newMinute)
}
}}
onBlur={() => {
const numVal = Number.parseInt(minute) || 0
setMinute(numVal.toString().padStart(2, '0'))
updateTime(undefined, numVal.toString())
}}
type='text'
autoComplete='off'
/>
<Button
variant='outline'
className='w-[4rem]'
onClick={() => {
const newAmpm = ampm === 'AM' ? 'PM' : 'AM'
setAmpm(newAmpm)
updateTime(undefined, undefined, newAmpm)
}}
>
{ampm}
</Button>
</div>
</PopoverContent>
</Popover>
<TimePicker
value={value || undefined}
onChange={handleChange}
placeholder={placeholder || 'Select time'}
disabled={isPreview || disabled}
className={className}
/>
)
}

View File

@@ -760,6 +760,7 @@ function CodeEditorSyncWrapper({
* in the tool selection dropdown.
*/
const BUILT_IN_TOOL_TYPES = new Set([
'api',
'file',
'function',
'knowledge',
@@ -772,6 +773,7 @@ const BUILT_IN_TOOL_TYPES = new Set([
'tts',
'stt',
'memory',
'webhook_request',
'workflow',
])
@@ -926,6 +928,8 @@ export function ToolInput({
const toolBlocks = getAllBlocks().filter(
(block) =>
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'workflow' ||
block.type === 'knowledge' ||
block.type === 'function') &&

View File

@@ -38,6 +38,27 @@ const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
isExisting: false,
}
/**
* Parses a value that might be a JSON string or already an array of VariableAssignment.
* This handles the case where workflows are imported with stringified values.
*/
function parseVariableAssignments(value: unknown): VariableAssignment[] {
if (!value) return []
if (Array.isArray(value)) return value as VariableAssignment[]
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed as VariableAssignment[]
} catch {
// Not valid JSON, return empty array
}
}
}
return []
}
export function VariablesInput({
blockId,
subBlockId,
@@ -64,8 +85,8 @@ export function VariablesInput({
(v: Variable) => v.workflowId === workflowId
)
const value = isPreview ? previewValue : storeValue
const assignments: VariableAssignment[] = value || []
const rawValue = isPreview ? previewValue : storeValue
const assignments: VariableAssignment[] = parseVariableAssignments(rawValue)
const isReadOnly = isPreview || disabled
const getAvailableVariablesFor = (currentAssignmentId: string) => {

View File

@@ -460,7 +460,9 @@ function SubBlockComponent({
disabled={isDisabled}
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
</div>
)
@@ -478,6 +480,9 @@ function SubBlockComponent({
previewValue={previewValue as any}
disabled={isDisabled}
config={config}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
/>
</div>
)
@@ -604,6 +609,18 @@ function SubBlockComponent({
/>
)
case 'router-input':
return (
<ConditionInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
mode='router'
/>
)
case 'eval-input':
return (
<EvalInput

View File

@@ -1 +1,3 @@
export { LogRowContextMenu } from './log-row-context-menu'
export { OutputContextMenu } from './output-context-menu'
export { PrettierOutput } from './prettier-output'

View File

@@ -0,0 +1,145 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { ConsoleEntry } from '@/stores/terminal'
interface ContextMenuPosition {
x: number
y: number
}
interface TerminalFilters {
blockIds: Set<string>
statuses: Set<'error' | 'info'>
runIds: Set<string>
}
interface LogRowContextMenuProps {
isOpen: boolean
position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
entry: ConsoleEntry | null
filters: TerminalFilters
onFilterByBlock: (blockId: string) => void
onFilterByStatus: (status: 'error' | 'info') => void
onFilterByRunId: (runId: string) => void
onClearFilters: () => void
onClearConsole: () => void
hasActiveFilters: boolean
}
/**
* Context menu for terminal log rows (left side).
* Displays filtering options based on the selected row's properties.
*/
export function LogRowContextMenu({
isOpen,
position,
menuRef,
onClose,
entry,
filters,
onFilterByBlock,
onFilterByStatus,
onFilterByRunId,
onClearFilters,
onClearConsole,
hasActiveFilters,
}: LogRowContextMenuProps) {
const hasRunId = entry?.executionId != null
const isBlockFiltered = entry ? filters.blockIds.has(entry.blockId) : false
const entryStatus = entry?.success ? 'info' : 'error'
const isStatusFiltered = entry ? filters.statuses.has(entryStatus) : false
const isRunIdFiltered = entry?.executionId ? filters.runIds.has(entry.executionId) : false
return (
<Popover
open={isOpen}
onOpenChange={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}>
{/* Clear filters at top when active */}
{hasActiveFilters && (
<>
<PopoverItem
onClick={() => {
onClearFilters()
onClose()
}}
>
Clear All Filters
</PopoverItem>
{entry && <PopoverDivider />}
</>
)}
{/* Filter actions */}
{entry && (
<>
<PopoverItem
showCheck={isBlockFiltered}
onClick={() => {
onFilterByBlock(entry.blockId)
onClose()
}}
>
Filter by Block
</PopoverItem>
<PopoverItem
showCheck={isStatusFiltered}
onClick={() => {
onFilterByStatus(entryStatus)
onClose()
}}
>
Filter by Status
</PopoverItem>
{hasRunId && (
<PopoverItem
showCheck={isRunIdFiltered}
onClick={() => {
onFilterByRunId(entry.executionId!)
onClose()
}}
>
Filter by Run ID
</PopoverItem>
)}
</>
)}
{/* Destructive action */}
{(entry || hasActiveFilters) && <PopoverDivider />}
<PopoverItem
onClick={() => {
onClearConsole()
onClose()
}}
>
Clear Console
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface ContextMenuPosition {
x: number
y: number
}
interface OutputContextMenuProps {
isOpen: boolean
position: ContextMenuPosition
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopySelection: () => void
onCopyAll: () => void
onSearch: () => void
wrapText: boolean
onToggleWrap: () => void
openOnRun: boolean
onToggleOpenOnRun: () => void
onClearConsole: () => void
hasSelection: boolean
}
/**
* Context menu for terminal output panel (right side).
* Displays copy, search, and display options for the code viewer.
*/
export function OutputContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopySelection,
onCopyAll,
onSearch,
wrapText,
onToggleWrap,
openOnRun,
onToggleOpenOnRun,
onClearConsole,
hasSelection,
}: OutputContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={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}>
{/* Copy and search actions */}
<PopoverItem
disabled={!hasSelection}
onClick={() => {
onCopySelection()
onClose()
}}
>
Copy Selection
</PopoverItem>
<PopoverItem
onClick={() => {
onCopyAll()
onClose()
}}
>
Copy All
</PopoverItem>
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
{/* Display settings - toggles don't close menu */}
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
<PopoverItem showCheck={openOnRun} onClick={onToggleOpenOnRun}>
Open on Run
</PopoverItem>
{/* Destructive action */}
<PopoverDivider />
<PopoverItem
onClick={() => {
onClearConsole()
onClose()
}}
>
Clear Console
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -38,11 +38,16 @@ import {
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import {
LogRowContextMenu,
OutputContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components'
import {
useOutputPanelResize,
useTerminalFilters,
useTerminalResize,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { getBlock } from '@/blocks'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
@@ -365,6 +370,28 @@ 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,
menuRef: logRowMenuRef,
handleContextMenu: handleLogRowContextMenu,
closeMenu: closeLogRowMenu,
} = useContextMenu()
const {
isOpen: isOutputMenuOpen,
position: outputMenuPosition,
menuRef: outputMenuRef,
handleContextMenu: handleOutputContextMenu,
closeMenu: closeOutputMenu,
} = useContextMenu()
/**
* Expands the terminal to its last meaningful height, with safeguards:
* - Never expands below {@link DEFAULT_EXPANDED_HEIGHT}.
@@ -511,15 +538,11 @@ export function Terminal() {
const handleRowClick = useCallback((entry: ConsoleEntry) => {
setSelectedEntry((prev) => {
const isDeselecting = prev?.id === entry.id
// Re-enable auto-select when deselecting, disable when selecting
setAutoSelectEnabled(isDeselecting)
return isDeselecting ? null : entry
})
}, [])
/**
* Handle header click - toggle between expanded and collapsed
*/
const handleHeaderClick = useCallback(() => {
if (isExpanded) {
setIsToggling(true)
@@ -529,16 +552,10 @@ export function Terminal() {
}
}, [expandToLastHeight, isExpanded, setTerminalHeight])
/**
* Handle transition end - reset toggling state
*/
const handleTransitionEnd = useCallback(() => {
setIsToggling(false)
}, [])
/**
* Handle copy output to clipboard
*/
const handleCopy = useCallback(() => {
if (!selectedEntry) return
@@ -560,9 +577,6 @@ export function Terminal() {
}
}, [activeWorkflowId, clearWorkflowConsole])
/**
* Activates output search and focuses the search input.
*/
const activateOutputSearch = useCallback(() => {
setIsOutputSearchActive(true)
setTimeout(() => {
@@ -570,9 +584,6 @@ export function Terminal() {
}, 0)
}, [])
/**
* Closes output search and clears the query.
*/
const closeOutputSearch = useCallback(() => {
setIsOutputSearchActive(false)
setOutputSearchQuery('')
@@ -604,9 +615,6 @@ export function Terminal() {
setCurrentMatchIndex(0)
}, [])
/**
* Handle clear console for current workflow via mouse interaction.
*/
const handleClearConsole = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -615,10 +623,6 @@ export function Terminal() {
[clearCurrentWorkflowConsole]
)
/**
* Handle export of console entries for the current workflow via mouse interaction.
* Mirrors the visibility and interaction behavior of the clear console action.
*/
const handleExportConsole = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -629,9 +633,60 @@ export function Terminal() {
[activeWorkflowId, exportConsoleCSV]
)
/**
* Handle training button click - toggle training state or open modal
*/
const handleCopySelection = useCallback(() => {
if (storedSelectionText) {
navigator.clipboard.writeText(storedSelectionText)
setShowCopySuccess(true)
}
}, [storedSelectionText])
const handleOutputPanelContextMenu = useCallback(
(e: React.MouseEvent) => {
const selection = window.getSelection()
const selectionText = selection?.toString() || ''
setStoredSelectionText(selectionText)
setHasSelection(selectionText.length > 0)
handleOutputContextMenu(e)
},
[handleOutputContextMenu]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, entry: ConsoleEntry) => {
setContextMenuEntry(entry)
handleLogRowContextMenu(e)
},
[handleLogRowContextMenu]
)
const handleFilterByBlock = useCallback(
(blockId: string) => {
toggleBlock(blockId)
closeLogRowMenu()
},
[toggleBlock, closeLogRowMenu]
)
const handleFilterByStatus = useCallback(
(status: 'error' | 'info') => {
toggleStatus(status)
closeLogRowMenu()
},
[toggleStatus, closeLogRowMenu]
)
const handleFilterByRunId = useCallback(
(runId: string) => {
toggleRunId(runId)
closeLogRowMenu()
},
[toggleRunId, closeLogRowMenu]
)
const handleClearConsoleFromMenu = useCallback(() => {
clearCurrentWorkflowConsole()
}, [clearCurrentWorkflowConsole])
const handleTrainingClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -644,9 +699,6 @@ export function Terminal() {
[isTraining, stopTraining, toggleTrainingModal]
)
/**
* Whether training controls should be visible
*/
const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls
/**
@@ -721,6 +773,23 @@ export function Terminal() {
}
}, [showCopySuccess])
/**
* Track text selection state for context menu.
* Skip updates when the context menu is open to prevent the selection
* state from changing mid-click (which would disable the copy button).
*/
useEffect(() => {
const handleSelectionChange = () => {
if (isOutputMenuOpen) return
const selection = window.getSelection()
setHasSelection(Boolean(selection && selection.toString().length > 0))
}
document.addEventListener('selectionchange', handleSelectionChange)
return () => document.removeEventListener('selectionchange', handleSelectionChange)
}, [isOutputMenuOpen])
/**
* Auto-select the latest entry when new logs arrive
* Re-enables auto-selection when all entries are cleared
@@ -1311,6 +1380,7 @@ export function Terminal() {
isSelected && 'bg-[var(--surface-6)] dark:bg-[var(--surface-4)]'
)}
onClick={() => handleRowClick(entry)}
onContextMenu={(e) => handleRowContextMenu(e, entry)}
>
{/* Block */}
<div
@@ -1327,7 +1397,13 @@ export function Terminal() {
</div>
{/* Status */}
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS)}>
<div
className={clsx(
COLUMN_WIDTHS.STATUS,
COLUMN_BASE_CLASS,
'flex items-center'
)}
>
{statusInfo ? (
<Badge variant={statusInfo.isError ? 'red' : 'gray'} dot>
{statusInfo.label}
@@ -1719,7 +1795,10 @@ export function Terminal() {
)}
{/* Content */}
<div className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}>
<div
className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}
onContextMenu={handleOutputPanelContextMenu}
>
{shouldShowCodeDisplay ? (
<OutputCodeContent
code={selectedEntry.input.code}
@@ -1748,6 +1827,42 @@ export function Terminal() {
)}
</div>
</aside>
{/* Log Row Context Menu */}
<LogRowContextMenu
isOpen={isLogRowMenuOpen}
position={logRowMenuPosition}
menuRef={logRowMenuRef}
onClose={closeLogRowMenu}
entry={contextMenuEntry}
filters={filters}
onFilterByBlock={handleFilterByBlock}
onFilterByStatus={handleFilterByStatus}
onFilterByRunId={handleFilterByRunId}
onClearFilters={() => {
clearFilters()
closeLogRowMenu()
}}
onClearConsole={handleClearConsoleFromMenu}
hasActiveFilters={hasActiveFilters}
/>
{/* Output Panel Context Menu */}
<OutputContextMenu
isOpen={isOutputMenuOpen}
position={outputMenuPosition}
menuRef={outputMenuRef}
onClose={closeOutputMenu}
onCopySelection={handleCopySelection}
onCopyAll={handleCopy}
onSearch={activateOutputSearch}
wrapText={wrapText}
onToggleWrap={() => setWrapText(!wrapText)}
openOnRun={openOnRun}
onToggleOpenOnRun={() => setOpenOnRun(!openOnRun)}
onClearConsole={handleClearConsoleFromMenu}
hasSelection={hasSelection}
/>
</>
)
}

View File

@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
]
}, [type, subBlockState, id])
/**
* Compute per-route rows (id/value) for router_v2 blocks so we can render
* one row per route with its own output handle.
* Uses same structure as conditions: { id, title, value }
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] as { id: string; value: string }[]
const routesValue = subBlockState.routes?.value
const raw = typeof routesValue === 'string' ? routesValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `${id}-route-${index}`,
value: routeItem?.value ?? '',
}
})
}
}
} catch (error) {
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
return [{ id: `${id}-route-route1`, value: '' }]
}, [type, subBlockState, id])
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -857,6 +888,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
// +1 for context row, plus route rows
rowsCount = 1 + routerRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
@@ -879,6 +913,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
routerRows.length,
horizontalHandles,
],
})
@@ -1025,7 +1060,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px] p-4'>
<Tooltip.Content side='top' className='max-w-[300px]'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{getProviderName(webhookProvider)} Webhook</p>
@@ -1073,32 +1108,45 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition'
? conditionRows.map((cond) => (
{type === 'condition' ? (
conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
))
) : type === 'router_v2' ? (
<>
<SubBlockRow
key='context'
title='Context'
value={getDisplayValue(subBlockState.context?.value)}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={cond.id}
title={cond.title}
value={getDisplayValue(cond.value)}
key={route.id}
title={`Route ${index + 1}`}
value={getDisplayValue(route.value)}
/>
))
: subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)}
))}
</>
) : (
subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)
)}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
</div>
)}
@@ -1153,7 +1201,58 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</>
)}
{type !== 'condition' && type !== 'response' && (
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
// +1 row offset for context row at the top
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={getHandleClasses('right')}
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
data-nodeid={id}
data-handleid={`router-${route.id}`}
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
</>
)}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'

View File

@@ -24,11 +24,9 @@ export function useFloatBoundarySync({
const positionRef = useRef(position)
const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 })
// Keep position ref up to date
positionRef.current = position
const checkAndUpdatePosition = useCallback(() => {
// Get current layout dimensions
const sidebarWidth = Number.parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
)
@@ -39,20 +37,17 @@ export function useFloatBoundarySync({
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
)
// Check if dimensions actually changed
const prev = previousDimensionsRef.current
if (
prev.sidebarWidth === sidebarWidth &&
prev.panelWidth === panelWidth &&
prev.terminalHeight === terminalHeight
) {
return // No change, skip update
return
}
// Update previous dimensions
previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight }
// Calculate bounds
const minX = sidebarWidth
const maxX = window.innerWidth - panelWidth - width
const minY = 0
@@ -60,9 +55,7 @@ export function useFloatBoundarySync({
const currentPos = positionRef.current
// Check if current position is out of bounds
if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) {
// Constrain to new bounds
const newPosition = {
x: Math.max(minX, Math.min(maxX, currentPos.x)),
y: Math.max(minY, Math.min(maxY, currentPos.y)),
@@ -75,30 +68,24 @@ export function useFloatBoundarySync({
if (!isOpen) return
const handleResize = () => {
// Cancel any pending animation frame
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
}
// Schedule update on next animation frame for smooth 60fps updates
rafIdRef.current = requestAnimationFrame(() => {
checkAndUpdatePosition()
rafIdRef.current = null
})
}
// Listen for window resize
window.addEventListener('resize', handleResize)
// Create MutationObserver to watch for CSS variable changes
// This fires immediately when sidebar/panel/terminal resize
const observer = new MutationObserver(handleResize)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style'],
})
// Initial check
checkAndUpdatePosition()
return () => {

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