Compare commits

...

60 Commits

Author SHA1 Message Date
Waleed
6d00d6bf2c fix(modals): center modals in visible content area and remove open/close animation (#3937)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption

* fix(modals): remove open/close animation from modal content
2026-04-03 20:06:10 -07:00
Waleed
3267d8cc24 fix(modals): center modals in visible content area accounting for sidebar and panel (#3934)
* fix(modals): center modals in visible content area accounting for sidebar and panel

* fix(modals): address pr feedback — comment clarity and document panel assumption
2026-04-03 19:19:36 -07:00
Theodore Li
2e69f85364 Fix "fix in copilot" button (#3931)
* Fix "fix in copilot" button

* Auto send message to copilot for fix in copilot

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 22:11:45 -04:00
Waleed
57e5bac121 fix(mcp): resolve userId before JWT generation for agent block auth (#3932)
* fix(mcp): resolve userId before JWT generation for agent block auth

* test(mcp): add regression test for agent block JWT userId resolution
2026-04-03 19:05:10 -07:00
Theodore Li
8ce0299400 fix(ui) Fix oauth redirect on connector modal (#3926)
* Fix oauth redirect on connector modal

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-03 21:58:42 -04:00
Vikhyath Mondreti
a0796f088b improvement(mothership): workflow edits via sockets (#3927)
* improvement(mothership): workflow edits via sockets

* make embedded view join room

* fix cursor positioning bug
2026-04-03 18:44:14 -07:00
Waleed
98fe4cd40b refactor(stores): consolidate variables stores into stores/variables/ (#3930)
* refactor(stores): consolidate variables stores into stores/variables/

Move variable data store from stores/panel/variables/ to stores/variables/
since the panel variables tab no longer exists. Rename the modal UI store
to useVariablesModalStore to eliminate naming collision with the data store.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused workflowId variable in deleteVariable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:43:47 -07:00
Waleed
34d210c66c chore(stores): remove Zustand environment store and dead init scaffolding (#3929) 2026-04-03 17:54:49 -07:00
Waleed
2334f2dca4 fix(loading): remove jarring workflow loading spinners (#3928)
* fix(loading): remove jarring workflow loading spinners

* fix(loading): remove home page skeleton loading state

* fix(loading): remove plain spinner loading states from task and file view
2026-04-03 17:45:30 -07:00
Waleed
65fc138bfc improvement(stores): remove deployment state from Zustand in favor of React Query (#3923) 2026-04-03 17:44:10 -07:00
Waleed
e8f7fe0989 v0.6.22: agentmail, rootly, landing fixes, analytics, credentials block 2026-04-03 01:14:36 -07:00
Waleed
ace87791d8 feat(analytics): add PostHog product analytics (#3910)
* feat(analytics): add PostHog product analytics

* fix(posthog): fix workspace group via URL params, type errors, and clean up comments

* fix(posthog): address PR review - fix pre-tx event, auth_method, paused executions, enterprise cancellation, settings double-fire

* chore(posthog): remove unused identifyServerPerson

* fix(posthog): isolate processQueuedResumes errors, simplify settings posthog deps

* fix(posthog): correctly classify SSO auth_method, fix phantom empty-string workspace groups

* fix(posthog): remove usePostHog from memo'd TemplateCard, fix copilot chat phantom workspace group

* fix(posthog): eliminate all remaining phantom empty-string workspace groups

* fix(posthog): fix cancel route phantom group, remove redundant workspaceId shadow in catch block

* fix(posthog): use ids.length for block_removed guard to handle container blocks with descendants

* chore(posthog): remove unused removedBlockTypes variable

* fix(posthog): remove phantom $set person properties from subscription events

* fix(posthog): add passedKnowledgeBaseName to knowledge_base_opened effect deps

* fix(posthog): capture currentWorkflowId synchronously before async import to avoid stale closure

* fix(posthog): add typed captureEvent wrapper for React components, deduplicate copilot_panel_opened

* feat(posthog): add task_created and task_message_sent events, remove copilot_panel_opened

* feat(posthog): track task_renamed, task_deleted, task_marked_read, task_marked_unread

* feat(analytics): expand posthog event coverage with source tracking and lifecycle events

* fix(analytics): flush posthog events on SIGTERM before ECS task termination

* fix(analytics): fix posthog in useCallback deps and fire block events for bulk operations
2026-04-03 01:00:35 -07:00
Waleed
74af452175 feat(blocks): add Credential block (#3907)
* feat(blocks): add Credential block

* fix(blocks): explicit workspaceId guard in credential handler, clarify hasOAuthSelection

* feat(credential): add list operation with type/provider filters

* feat(credential): restrict to OAuth only, remove env vars and service accounts

* docs(credential): update screenshots

* fix(credential): remove stale isServiceAccount dep from overlayContent memo

* fix(credential): filter to oauth-only in handleComboboxChange matchedCred lookup
2026-04-02 23:15:15 -07:00
Waleed
ec51f73596 feat(email): abandoned checkout email, 80% free tier warning, credits exhausted email (#3908)
* feat(email): send plain personal email on abandoned checkout

* feat(email): lower free tier warning to 80% and add credits exhausted email

* feat(email): use wordmark in email header instead of icon-only logo

* fix(email): restore accidentally deleted social icons in email footer

* fix(email): prevent double email for free users at 80%, fix subject line

* improvement(emails): extract shared plain email styles and proFeatures constant, fix double email on 100% usage

* fix(email): filter subscription-mode checkout, skip already-subscribed users, fix preview text

* fix(email): use notifications type for onboarding followup to respect unsubscribe preferences

* fix(email): use limit instead of currentUsage in credits exhausted email body

* fix(email): use notifications type for abandoned checkout, clarify crosses80 comment

* chore(email): rename _constants.ts to constants.ts

* fix(email): use isProPlan to catch org-level subscriptions in abandoned checkout guard

* fix(email): align onboarding followup delay to 5 days for email/password users
2026-04-02 19:31:29 -07:00
Theodore Li
6866da590c fix(tools) Directly query db for custom tool id (#3875)
* Directly query db for custom tool id

* Switch back to inline imports

* Fix lint

* Fix test

* Fix greptile comments

* Fix lint

* Make userId and workspaceId required

* Add back nullable userId and workspaceId fields

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 22:13:37 -04:00
Waleed
f0d1950477 v0.6.21: concurrency FF, blog theme 2026-04-02 13:08:59 -07:00
Waleed
0fdd8ffb55 v0.6.20: oauth default credential name, models pages, new models, rippling and rootly integrations 2026-04-02 11:44:24 -07:00
Waleed
d581009099 v0.6.19: vllm fixes, loading improevments, reactquery standardization, new gpt 5.4 models, fireworks provider support, launchdarkly, tailscale, extend integrations 2026-03-31 20:17:00 -07:00
Waleed
7d0fdefb22 v0.6.18: file operations block, profound integration, edge connection improvements, copy logs, knowledgebase robustness 2026-03-30 21:35:41 -07:00
Waleed
73e00f53e1 v0.6.17: trigger.dev CI, workers FF 2026-03-30 09:33:30 -07:00
Vikhyath Mondreti
1d7ae906bc v0.6.16: bullmq optionality 2026-03-30 00:12:21 -07:00
Waleed
560fa75155 v0.6.15: workers, security hardening, sidebar improvements, chat fixes, profound 2026-03-29 23:02:19 -07:00
Waleed
14089f7dbb v0.6.14: performance improvements, connectors UX, collapsed sidebar actions 2026-03-27 13:07:59 -07:00
Waleed
e615816dce v0.6.13: emcn standardization, granola and ketch integrations, security hardening, connectors improvements 2026-03-27 00:16:37 -07:00
Waleed
ca87d7ce29 v0.6.12: billing, blogs UI 2026-03-26 01:19:23 -07:00
Waleed
6bebbc5e29 v0.6.11: billing fixes, rippling, hubspot, UI improvements, demo modal 2026-03-25 22:54:56 -07:00
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Siddharth Ganesan
c78c870fda v0.6.8: mothership tool loop
v0.6.8: mothership tool loop
2026-03-24 04:06:19 -07:00
Waleed
19442f19e2 v0.6.7: kb improvements, edge z index fix, captcha, new trust center, block classifications 2026-03-21 12:43:33 -07:00
Waleed
1731a4d7f0 v0.6.6: landing improvements, styling consistency, mothership table renaming 2026-03-19 23:58:30 -07:00
Waleed
9fcd02fd3b v0.6.5: email validation, integrations page, mothership and custom tool fixes 2026-03-19 16:08:30 -07:00
Waleed
ff7b5b528c v0.6.4: subflows, docusign, ashby new tools, box, workday, billing bug fixes 2026-03-18 23:12:36 -07:00
Waleed
30f2d1a0fc v0.6.3: hubspot integration, kb block improvements 2026-03-18 11:19:55 -07:00
Waleed
4bd0731871 v0.6.2: mothership stability, chat iframe embedding, KB upserts, new blog post 2026-03-18 03:29:39 -07:00
Waleed
4f3bc37fe4 v0.6.1: added better auth admin plugin 2026-03-17 15:16:16 -07:00
Waleed
84d6fdc423 v0.6: mothership, tables, connectors 2026-03-17 12:21:15 -07:00
Vikhyath Mondreti
4c12914d35 v0.5.113: jira, ashby, google ads, grain updates 2026-03-12 22:54:25 -07:00
Waleed
e9bdc57616 v0.5.112: trace spans improvements, fathom integration, jira fixes, canvas navigation updates 2026-03-12 13:30:20 -07:00
Vikhyath Mondreti
36612ae42a v0.5.111: non-polling webhook execs off trigger.dev, gmail subject headers, webhook trigger configs (#3530) 2026-03-11 17:47:28 -07:00
Waleed
1c2c2c65d4 v0.5.110: webhook execution speedups, SSRF patches 2026-03-11 15:00:24 -07:00
Waleed
ecd3536a72 v0.5.109: obsidian and evernote integrations, slack fixes, remove memory instrumentation 2026-03-09 10:40:37 -07:00
Vikhyath Mondreti
8c0a2e04b1 v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks 2026-03-06 21:02:25 -08:00
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
178 changed files with 3206 additions and 2002 deletions

View File

@@ -0,0 +1,150 @@
---
title: Credential
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
The Credential block has two operations: **Select Credential** picks a single OAuth credential and outputs its ID reference for downstream blocks; **List Credentials** returns all OAuth credentials in the workspace (optionally filtered by provider) as an array for iteration.
<div className="flex justify-center">
<Image
src="/static/blocks/credential.png"
alt="Credential Block"
width={400}
height={300}
className="my-6"
/>
</div>
<Callout>
The Credential block outputs credential **ID references**, not secrets. Downstream blocks receive the ID and resolve the actual OAuth token securely during their own execution.
</Callout>
## Configuration Options
### Operation
| Value | Description |
|---|---|
| **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks |
| **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop |
### Credential (Select operation)
Select an OAuth credential from your workspace. The dropdown shows all connected OAuth accounts (Google, GitHub, Slack, etc.).
In advanced mode, paste a credential ID directly. You can copy a credential ID from your workspace's Credentials settings page.
### Provider (List operation)
Filter the returned OAuth credentials by provider. Select one or more providers from the dropdown — only providers you have credentials for will appear. Leave empty to return all OAuth credentials.
| Example | Returns |
|---|---|
| Gmail | Gmail credentials only |
| Slack | Slack credentials only |
| Gmail + Slack | Gmail and Slack credentials |
## Outputs
<Tabs items={['Select Credential', 'List Credentials']}>
<Tab>
| Output | Type | Description |
|---|---|---|
| `credentialId` | `string` | The credential ID — pipe this into other blocks' credential fields |
| `displayName` | `string` | Human-readable name (e.g. "waleed@company.com") |
| `providerId` | `string` | OAuth provider ID (e.g. `google-email`, `slack`) |
</Tab>
<Tab>
| Output | Type | Description |
|---|---|---|
| `credentials` | `json` | Array of OAuth credential objects (see shape below) |
| `count` | `number` | Number of credentials returned |
Each object in the `credentials` array:
| Field | Type | Description |
|---|---|---|
| `credentialId` | `string` | The credential ID |
| `displayName` | `string` | Human-readable name |
| `providerId` | `string` | OAuth provider ID |
</Tab>
</Tabs>
## Example Use Cases
**Shared credential across multiple blocks** — Define once, use everywhere
```
Credential (Select, Google) → Gmail (Send) & Google Drive (Upload) & Google Calendar (Create)
```
**Multi-account workflows** — Route to different credentials based on logic
```
Agent (Determine account) → Condition → Credential A or Credential B → Slack (Post)
```
**Iterate over all Gmail accounts**
```
Credential (List, Provider: Gmail) → ForEach Loop → Gmail (Send) using <loop.currentItem.credentialId>
```
<div className="flex justify-center">
<Image
src="/static/blocks/credential-loop.png"
alt="Credential List wired into a ForEach Loop"
width={900}
height={400}
className="my-6"
/>
</div>
## How to wire a Credential block
### Select Credential
1. Drop a **Credential** block and select your OAuth credential from the picker
2. In the downstream block, switch to **advanced mode** on its credential field
3. Enter `<credentialBlockName.credentialId>` as the value
<Tabs items={['Gmail', 'Slack']}>
<Tab>
In the Gmail block's credential field (advanced mode):
```
<myCredential.credentialId>
```
</Tab>
<Tab>
In the Slack block's credential field (advanced mode):
```
<myCredential.credentialId>
```
</Tab>
</Tabs>
### List Credentials
1. Drop a **Credential** block, set Operation to **List Credentials**
2. Optionally select one or more **Providers** to narrow results (only your connected providers appear)
3. Wire `<credentialBlockName.credentials>` into a **ForEach Loop** as the items source
4. Inside the loop, reference `<loop.currentItem.credentialId>` in downstream blocks' credential fields
## Best Practices
- **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `<credential.credentialId>` instead of selecting the account five times
- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs
- **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable
- **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference
- **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop
- **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown
<FAQ items={[
{ question: "Does the Credential block expose my secret or token?", answer: "No. The block outputs a credential ID (a UUID), not the actual OAuth token. Downstream blocks receive the ID and resolve the token securely in their own execution context. Secrets never appear in workflow state, logs, or the canvas." },
{ question: "What credential types does it support?", answer: "OAuth connected accounts only (Google, GitHub, Slack, etc.). Environment variables and service accounts cannot be resolved by ID in downstream blocks, so they are not supported." },
{ question: "How is Select different from just copying a credential ID into advanced mode?", answer: "Functionally identical — both pass the same credential ID to the downstream block. The Credential block adds value when you need to use one credential in many blocks (change it once), or when you want to select between credentials dynamically using a Condition block." },
{ question: "Can I list all OAuth credentials in my workspace?", answer: "Yes. Set the Operation to 'List Credentials'. Optionally filter by provider using the Provider multiselect. Wire the credentials output into a ForEach loop to process each credential individually." },
{ question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference <credential.credentialId> in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." },
{ question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." },
]} />

View File

@@ -4,6 +4,7 @@
"agent",
"api",
"condition",
"credential",
"evaluator",
"function",
"guardrails",

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,16 +1,18 @@
'use client'
import { Suspense, useMemo, useRef, useState } from 'react'
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Input, Label } from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { captureEvent } from '@/lib/posthog/client'
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
@@ -81,7 +83,12 @@ function SignupFormContent({
const router = useRouter()
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
captureEvent(posthog, 'signup_page_viewed', {})
}, [posthog])
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])

View File

@@ -0,0 +1,15 @@
'use client'
import { useEffect } from 'react'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
export function LandingAnalytics() {
const posthog = usePostHog()
useEffect(() => {
captureEvent(posthog, 'landing_page_viewed', {})
}, [posthog])
return null
}

View File

@@ -13,6 +13,7 @@ import {
Templates,
Testimonials,
} from '@/app/(home)/components'
import { LandingAnalytics } from '@/app/(home)/landing-analytics'
/**
* Landing page root component.
@@ -45,6 +46,7 @@ export default async function Landing() {
>
Skip to main content
</a>
<LandingAnalytics />
<StructuredData />
<header>
<Navbar blogPosts={blogPosts} />

View File

@@ -10,7 +10,7 @@
* @see stores/constants.ts for the source of truth
*/
:root {
--sidebar-width: 248px; /* SIDEBAR_WIDTH.DEFAULT */
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
--toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */

View File

@@ -7,6 +7,7 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c
import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
import { captureServerEvent } from '@/lib/posthog/server'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -180,6 +181,17 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
logger.info(`Deleted A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_deleted',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting agent:', error)
@@ -251,6 +263,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
}
logger.info(`Published A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_published',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true, isPublished: true })
}
@@ -273,6 +295,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
}
logger.info(`Unpublished A2A agent: ${agentId}`)
captureServerEvent(
auth.userId,
'a2a_agent_unpublished',
{
agent_id: agentId,
workflow_id: existingAgent.workflowId,
workspace_id: existingAgent.workspaceId,
},
{ groups: { workspace: existingAgent.workspaceId } }
)
return NextResponse.json({ success: true, isPublished: false })
}

View File

@@ -14,6 +14,7 @@ import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card'
import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants'
import { sanitizeAgentName } from '@/lib/a2a/utils'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { captureServerEvent } from '@/lib/posthog/server'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -201,6 +202,16 @@ export async function POST(request: NextRequest) {
logger.info(`Created A2A agent ${agentId} for workflow ${workflowId}`)
captureServerEvent(
auth.userId,
'a2a_agent_created',
{ agent_id: agentId, workflow_id: workflowId, workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
setOnce: { first_a2a_agent_created_at: new Date().toISOString() },
}
)
return NextResponse.json({ success: true, agent }, { status: 201 })
} catch (error) {
logger.error('Error creating agent:', error)

View File

@@ -17,6 +17,7 @@ import {
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('SwitchPlan')
@@ -173,6 +174,13 @@ export async function POST(request: NextRequest) {
interval: targetInterval,
})
captureServerEvent(
userId,
'subscription_changed',
{ from_plan: sub.plan ?? 'unknown', to_plan: targetPlanName, interval: targetInterval },
{ set: { plan: targetPlanName } }
)
return NextResponse.json({ success: true, plan: targetPlanName, interval: targetInterval })
} catch (error) {
logger.error('Failed to switch subscription', {

View File

@@ -27,6 +27,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { captureServerEvent } from '@/lib/posthog/server'
import {
authorizeWorkflowByWorkspacePermission,
resolveWorkflowIdForUser,
@@ -188,6 +189,22 @@ export async function POST(req: NextRequest) {
.warn('Failed to resolve workspaceId from workflow')
}
captureServerEvent(
authenticatedUserId,
'copilot_chat_sent',
{
workflow_id: workflowId,
workspace_id: resolvedWorkspaceId ?? '',
has_file_attachments: Array.isArray(fileAttachments) && fileAttachments.length > 0,
has_contexts: Array.isArray(contexts) && contexts.length > 0,
mode,
},
{
groups: resolvedWorkspaceId ? { workspace: resolvedWorkspaceId } : undefined,
setOnce: { first_copilot_use_at: new Date().toISOString() },
}
)
const userMessageIdToUse = userMessageId || crypto.randomUUID()
const reqLogger = logger.withMetadata({
requestId: tracker.requestId,

View File

@@ -304,7 +304,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
},
}
@@ -349,7 +348,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
},
},
@@ -370,7 +368,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: true,
deploymentStatuses: { production: 'deployed' },
lastSaved: 1640995200000,
}),
}
@@ -473,7 +470,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
edges: undefined,
loops: null,
parallels: undefined,
deploymentStatuses: null,
},
}
@@ -508,7 +504,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
loops: {},
parallels: {},
isDeployed: false,
deploymentStatuses: {},
lastSaved: 1640995200000,
})
})
@@ -768,10 +763,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
},
}
@@ -816,10 +807,6 @@ describe('Copilot Checkpoints Revert API Route', () => {
parallel1: { branches: ['branch1', 'branch2'] },
},
isDeployed: true,
deploymentStatuses: {
production: 'deployed',
staging: 'pending',
},
deployedAt: '2024-01-01T10:00:00.000Z',
lastSaved: 1640995200000,
})

View File

@@ -82,7 +82,6 @@ export async function POST(request: NextRequest) {
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
lastSaved: Date.now(),
...(checkpointState?.deployedAt &&
checkpointState.deployedAt !== null &&

View File

@@ -11,6 +11,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CopilotFeedbackAPI')
@@ -76,6 +77,12 @@ export async function POST(req: NextRequest) {
duration: tracker.getDuration(),
})
captureServerEvent(authenticatedUserId, 'copilot_feedback_submitted', {
is_positive: isPositiveFeedback,
has_text_feedback: !!feedback,
has_workflow_yaml: !!workflowYaml,
})
return NextResponse.json({
success: true,
feedbackId: feedbackRecord.feedbackId,

View File

@@ -11,6 +11,7 @@ import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('CredentialByIdAPI')
@@ -236,6 +237,17 @@ export async function DELETE(
envKeys: Object.keys(current),
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'env_personal',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
return NextResponse.json({ success: true }, { status: 200 })
}
@@ -278,10 +290,33 @@ export async function DELETE(
actingUserId: session.user.id,
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'env_workspace',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: access.credential.type as 'oauth' | 'service_account',
provider_id: access.credential.providerId ?? id,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)

View File

@@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
@@ -600,6 +601,16 @@ export async function POST(request: NextRequest) {
.where(eq(credential.id, credentialId))
.limit(1)
captureServerEvent(
session.user.id,
'credential_connected',
{ credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
setOnce: { first_credential_connected_at: new Date().toISOString() },
}
)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {

View File

@@ -16,6 +16,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
@@ -351,6 +352,19 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_removed',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: existingConnector[0].connectorType,
documents_deleted: deleteDocuments ? docCount : 0,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,

View File

@@ -7,6 +7,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorManualSyncAPI')
@@ -55,6 +56,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_synced',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: connectorRows[0].connectorType,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,

View File

@@ -11,6 +11,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { getCredential } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
@@ -227,6 +228,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_added',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: connectorType,
sync_interval_minutes: syncIntervalMinutes,
},
{
groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined,
setOnce: { first_connector_added_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,

View File

@@ -16,6 +16,7 @@ import {
type TagFilterCondition,
} from '@/lib/knowledge/documents/service'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { captureServerEvent } from '@/lib/posthog/server'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
@@ -214,6 +215,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
if (body.bulk === true) {
try {
const validatedData = BulkCreateDocumentsSchema.parse(body)
@@ -240,6 +243,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Silently fail
}
captureServerEvent(
userId,
'knowledge_base_document_uploaded',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
document_count: createdDocuments.length,
upload_type: 'bulk',
},
{
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
setOnce: { first_document_uploaded_at: new Date().toISOString() },
}
)
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
@@ -314,6 +332,21 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Silently fail
}
captureServerEvent(
userId,
'knowledge_base_document_uploaded',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
document_count: 1,
upload_type: 'single',
},
{
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
setOnce: { first_document_uploaded_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,

View File

@@ -11,6 +11,7 @@ import {
KnowledgeBaseConflictError,
type KnowledgeBaseScope,
} from '@/lib/knowledge/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('KnowledgeBaseAPI')
@@ -115,6 +116,20 @@ export async function POST(req: NextRequest) {
// Telemetry should not fail the operation
}
captureServerEvent(
session.user.id,
'knowledge_base_created',
{
knowledge_base_id: newKnowledgeBase.id,
workspace_id: validatedData.workspaceId,
name: validatedData.name,
},
{
groups: { workspace: validatedData.workspaceId },
setOnce: { first_kb_created_at: new Date().toISOString() },
}
)
logger.info(
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
)

View File

@@ -18,6 +18,7 @@ import {
createMcpSuccessResponse,
generateMcpServerId,
} from '@/lib/mcp/utils'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('McpServersAPI')
@@ -180,6 +181,20 @@ export const POST = withMcpAuth('write')(
// Silently fail
}
const sourceParam = body.source as string | undefined
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
captureServerEvent(
userId,
'mcp_server_connected',
{ workspace_id: workspaceId, server_name: body.name, transport: body.transport, source },
{
groups: { workspace: workspaceId },
setOnce: { first_mcp_connected_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId,
actorId: userId,
@@ -214,6 +229,9 @@ export const DELETE = withMcpAuth('admin')(
try {
const { searchParams } = new URL(request.url)
const serverId = searchParams.get('serverId')
const sourceParam = searchParams.get('source')
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!serverId) {
return createMcpErrorResponse(
@@ -242,6 +260,13 @@ export const DELETE = withMcpAuth('admin')(
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
captureServerEvent(
userId,
'mcp_server_disconnected',
{ workspace_id: workspaceId, server_name: deletedServer.name, source },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: userId,

View File

@@ -13,6 +13,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { taskPubSub } from '@/lib/copilot/task-events'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('MothershipChatAPI')
@@ -142,12 +143,41 @@ export async function PATCH(
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (title !== undefined && updatedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
if (updatedChat.workspaceId) {
if (title !== undefined) {
taskPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
captureServerEvent(
userId,
'task_renamed',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
if (isUnread === false) {
captureServerEvent(
userId,
'task_marked_read',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
} else if (isUnread === true) {
captureServerEvent(
userId,
'task_marked_unread',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
}
return NextResponse.json({ success: true })
@@ -203,6 +233,14 @@ export async function DELETE(
chatId,
type: 'deleted',
})
captureServerEvent(
userId,
'task_deleted',
{ workspace_id: deletedChat.workspaceId },
{
groups: { workspace: deletedChat.workspaceId },
}
)
}
return NextResponse.json({ success: true })

View File

@@ -11,6 +11,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { taskPubSub } from '@/lib/copilot/task-events'
import { captureServerEvent } from '@/lib/posthog/server'
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MothershipChatsAPI')
@@ -95,6 +96,15 @@ export async function POST(request: NextRequest) {
taskPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' })
captureServerEvent(
userId,
'task_created',
{ workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
}
)
return NextResponse.json({ success: true, id: chat.id })
} catch (error) {
if (error instanceof z.ZodError) {

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -23,6 +24,7 @@ const SkillSchema = z.object({
})
),
workspaceId: z.string().optional(),
source: z.enum(['settings', 'tool_input']).optional(),
})
/** GET - Fetch all skills for a workspace */
@@ -75,7 +77,7 @@ export async function POST(req: NextRequest) {
const body = await req.json()
try {
const { skills, workspaceId } = SkillSchema.parse(body)
const { skills, workspaceId, source } = SkillSchema.parse(body)
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`)
@@ -107,6 +109,12 @@ export async function POST(req: NextRequest) {
resourceName: skill.name,
description: `Created/updated skill "${skill.name}"`,
})
captureServerEvent(
userId,
'skill_created',
{ skill_id: skill.id, skill_name: skill.name, workspace_id: workspaceId, source },
{ groups: { workspace: workspaceId } }
)
}
return NextResponse.json({ success: true, data: resultSkills })
@@ -137,6 +145,9 @@ export async function DELETE(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const skillId = searchParams.get('id')
const workspaceId = searchParams.get('workspaceId')
const sourceParam = searchParams.get('source')
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -180,6 +191,13 @@ export async function DELETE(request: NextRequest) {
description: `Deleted skill`,
})
captureServerEvent(
userId,
'skill_deleted',
{ skill_id: skillId, workspace_id: workspaceId, source },
{ groups: { workspace: workspaceId } }
)
logger.info(`[${requestId}] Deleted skill: ${skillId}`)
return NextResponse.json({ success: true })
} catch (error) {

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import {
deleteTable,
NAME_PATTERN,
@@ -183,6 +184,13 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams)
await deleteTable(tableId, requestId)
captureServerEvent(
authResult.userId,
'table_deleted',
{ table_id: tableId, workspace_id: table.workspaceId },
{ groups: { workspace: table.workspaceId } }
)
return NextResponse.json({
success: true,
data: {

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import {
createTable,
getWorkspaceTableLimits,
@@ -141,6 +142,20 @@ export async function POST(request: NextRequest) {
requestId
)
captureServerEvent(
authResult.userId,
'table_created',
{
table_id: table.id,
workspace_id: params.workspaceId,
column_count: params.schema.columns.length,
},
{
groups: { workspace: params.workspaceId },
setOnce: { first_table_created_at: new Date().toISOString() },
}
)
return NextResponse.json({
success: true,
data: {

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -34,6 +35,7 @@ const CustomToolSchema = z.object({
})
),
workspaceId: z.string().optional(),
source: z.enum(['settings', 'tool_input']).optional(),
})
// GET - Fetch all custom tools for the workspace
@@ -135,7 +137,7 @@ export async function POST(req: NextRequest) {
try {
// Validate the request body
const { tools, workspaceId } = CustomToolSchema.parse(body)
const { tools, workspaceId, source } = CustomToolSchema.parse(body)
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`)
@@ -168,6 +170,16 @@ export async function POST(req: NextRequest) {
})
for (const tool of resultTools) {
captureServerEvent(
userId,
'custom_tool_saved',
{ tool_id: tool.id, workspace_id: workspaceId, tool_name: tool.title, source },
{
groups: { workspace: workspaceId },
setOnce: { first_custom_tool_saved_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId,
actorId: userId,
@@ -205,6 +217,9 @@ export async function DELETE(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const toolId = searchParams.get('id')
const workspaceId = searchParams.get('workspaceId')
const sourceParam = searchParams.get('source')
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
if (!toolId) {
logger.warn(`[${requestId}] Missing tool ID for deletion`)
@@ -278,6 +293,14 @@ export async function DELETE(request: NextRequest) {
// Delete the tool
await db.delete(customTools).where(eq(customTools.id, toolId))
const toolWorkspaceId = tool.workspaceId ?? workspaceId ?? ''
captureServerEvent(
userId,
'custom_tool_deleted',
{ tool_id: toolId, workspace_id: toolWorkspaceId, source },
toolWorkspaceId ? { groups: { workspace: toolWorkspaceId } } : undefined
)
recordAudit({
workspaceId: tool.workspaceId || undefined,
actorId: userId,

View File

@@ -8,6 +8,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -274,6 +275,19 @@ export async function DELETE(
request,
})
const wsId = webhookData.workflow.workspaceId || undefined
captureServerEvent(
userId,
'webhook_trigger_deleted',
{
webhook_id: id,
workflow_id: webhookData.workflow.id,
provider: foundWebhook.provider || 'generic',
workspace_id: wsId ?? '',
},
wsId ? { groups: { workspace: wsId } } : undefined
)
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting webhook`, {

View File

@@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { captureServerEvent } from '@/lib/posthog/server'
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import {
cleanupExternalWebhook,
@@ -763,6 +764,19 @@ export async function POST(request: NextRequest) {
metadata: { provider, workflowId },
request,
})
const wsId = workflowRecord.workspaceId || undefined
captureServerEvent(
userId,
'webhook_trigger_created',
{
webhook_id: savedWebhook.id,
workflow_id: workflowId,
provider: provider || 'generic',
workspace_id: wsId ?? '',
},
wsId ? { groups: { workspace: wsId } } : undefined
)
}
const status = targetWebhookId ? 200 : 201

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import {
@@ -96,6 +97,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
captureServerEvent(
actorUserId,
'workflow_deployed',
{ workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' },
{
groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined,
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
}
)
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -118,7 +129,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -148,6 +163,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
const wsId = workflowData?.workspaceId
captureServerEvent(
session!.user.id,
'workflow_public_api_toggled',
{ workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({ isPublicApi })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
@@ -164,7 +187,11 @@ export async function DELETE(
const { id } = await params
try {
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -179,6 +206,14 @@ export async function DELETE(
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
const wsId = workflowData?.workspaceId
captureServerEvent(
session!.user.id,
'workflow_undeployed',
{ workflow_id: id, workspace_id: wsId ?? '' },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({
isDeployed: false,
deployedAt: null,

View File

@@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -78,7 +79,6 @@ export async function POST(
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved: Date.now(),
deploymentStatuses: deployedState.deploymentStatuses || {},
})
if (!saveResult.success) {
@@ -104,6 +104,19 @@ export async function POST(
logger.error('Error sending workflow reverted event to socket server', e)
}
captureServerEvent(
session!.user.id,
'workflow_deployment_reverted',
{
workflow_id: id,
workspace_id: workflowRecord?.workspaceId ?? '',
version,
},
workflowRecord?.workspaceId
? { groups: { workspace: workflowRecord.workspaceId } }
: undefined
)
recordAudit({
workspaceId: workflowRecord?.workspaceId ?? null,
actorId: session!.user.id,

View File

@@ -4,6 +4,7 @@ import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -174,6 +175,14 @@ export async function PATCH(
}
}
const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId
captureServerEvent(
actorUserId,
'deployment_version_activated',
{ workflow_id: id, workspace_id: wsId ?? '', version: versionNum },
wsId ? { groups: { workspace: wsId } } : undefined
)
return createSuccessResponse({
success: true,
deployedAt: activateResult.deployedAt,

View File

@@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
const logger = createLogger('WorkflowDuplicateAPI')
@@ -60,6 +61,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Telemetry should not fail the operation
}
captureServerEvent(
userId,
'workflow_duplicated',
{
source_workflow_id: sourceWorkflowId,
new_workflow_id: result.id,
workspace_id: workspaceId ?? '',
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { captureServerEvent } from '@/lib/posthog/server'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
const logger = createLogger('CancelExecutionAPI')
@@ -60,6 +61,16 @@ export async function POST(
})
}
if (cancellation.durablyRecorded || locallyAborted) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
captureServerEvent(
auth.userId,
'workflow_execution_cancelled',
{ workflow_id: workflowId, workspace_id: workspaceId ?? '' },
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
return NextResponse.json({
success: cancellation.durablyRecorded || locallyAborted,
executionId,

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { restoreWorkflow } from '@/lib/workflows/lifecycle'
import { getWorkflowById } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -58,6 +59,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
request,
})
captureServerEvent(
auth.userId,
'workflow_restored',
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)

View File

@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
@@ -88,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -114,7 +114,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const emptyWorkflowData = {
...workflowData,
state: {
deploymentStatuses: {},
blocks: {},
edges: [],
loops: {},
@@ -225,6 +224,13 @@ export async function DELETE(
return NextResponse.json({ error: result.error }, { status })
}
captureServerEvent(
userId,
'workflow_deleted',
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
)
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)

View File

@@ -8,7 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import type { Variable } from '@/stores/panel/variables/types'
import type { Variable } from '@/stores/variables/types'
const logger = createLogger('WorkflowVariablesAPI')

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
@@ -274,6 +275,16 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`)
captureServerEvent(
userId,
'workflow_created',
{ workflow_id: workflowId, workspace_id: workspaceId ?? '', name },
{
groups: workspaceId ? { workspace: workspaceId } : undefined,
setOnce: { first_workflow_created_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId,
actorId: userId,

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceApiKeyAPI')
@@ -145,6 +146,13 @@ export async function DELETE(
const deletedKey = deletedRows[0]
captureServerEvent(
userId,
'api_key_revoked',
{ workspace_id: workspaceId, key_name: deletedKey.name },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: userId,

View File

@@ -10,12 +10,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceApiKeysAPI')
const CreateKeySchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
source: z.enum(['settings', 'deploy_modal']).optional(),
})
const DeleteKeysSchema = z.object({
@@ -101,7 +103,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const body = await request.json()
const { name } = CreateKeySchema.parse(body)
const { name, source } = CreateKeySchema.parse(body)
const existingKey = await db
.select()
@@ -158,6 +160,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Telemetry should not fail the operation
}
captureServerEvent(
userId,
'api_key_created',
{ workspace_id: workspaceId, key_name: name, source },
{
groups: { workspace: workspaceId },
setOnce: { first_api_key_created_at: new Date().toISOString() },
}
)
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
recordAudit({

View File

@@ -9,6 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceBYOKKeysAPI')
@@ -201,6 +202,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
captureServerEvent(
userId,
'byok_key_added',
{ workspace_id: workspaceId, provider_id: providerId },
{
groups: { workspace: workspaceId },
setOnce: { first_byok_key_added_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId,
actorId: userId,
@@ -272,6 +283,13 @@ export async function DELETE(
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
captureServerEvent(
userId,
'byok_key_removed',
{ workspace_id: workspaceId, provider_id: providerId },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: userId,

View File

@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import {
FileConflictError,
listWorkspaceFiles,
@@ -116,6 +117,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
captureServerEvent(
session.user.id,
'file_uploaded',
{ workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants'
@@ -342,6 +343,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
request,
})
captureServerEvent(
session.user.id,
'notification_channel_deleted',
{
notification_id: notificationId,
notification_type: deletedSubscription.notificationType,
workspace_id: workspaceId,
},
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting notification', { error })

View File

@@ -8,6 +8,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { captureServerEvent } from '@/lib/posthog/server'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants'
@@ -256,6 +257,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
type: data.notificationType,
})
captureServerEvent(
session.user.id,
'notification_channel_created',
{
workspace_id: workspaceId,
notification_type: data.notificationType,
alert_rule: data.alertConfig?.rule ?? null,
},
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -8,6 +8,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { captureServerEvent } from '@/lib/posthog/server'
import {
getUsersWithPermissions,
hasWorkspaceAdminAccess,
@@ -188,6 +189,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const updatedUsers = await getUsersWithPermissions(workspaceId)
for (const update of body.updates) {
captureServerEvent(
session.user.id,
'workspace_member_role_changed',
{ workspace_id: workspaceId, new_role: update.permissions },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { captureServerEvent } from '@/lib/posthog/server'
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
const logger = createLogger('WorkspaceByIdAPI')
@@ -292,6 +293,13 @@ export async function DELETE(
request,
})
captureServerEvent(
session.user.id,
'workspace_deleted',
{ workspace_id: workspaceId, workflow_count: workflowIds.length },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`Error deleting workspace ${workspaceId}:`, error)

View File

@@ -19,6 +19,7 @@ import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
InvitationsNotAllowedError,
@@ -214,6 +215,16 @@ export async function POST(req: NextRequest) {
// Telemetry should not fail the operation
}
captureServerEvent(
session.user.id,
'workspace_member_invited',
{ workspace_id: workspaceId, invitee_role: permission },
{
groups: { workspace: workspaceId },
setOnce: { first_invitation_sent_at: new Date().toISOString() },
}
)
await sendInvitationEmail({
to: email,
inviterName: session.user.name || session.user.email || 'A user',

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceMemberAPI')
@@ -105,6 +106,13 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
await revokeWorkspaceCredentialMemberships(workspaceId, userId)
captureServerEvent(
session.user.id,
'workspace_member_removed',
{ workspace_id: workspaceId, is_self_removal: isSelf },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
@@ -96,6 +97,16 @@ export async function POST(req: Request) {
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow, color)
captureServerEvent(
session.user.id,
'workspace_created',
{ workspace_id: newWorkspace.id, name: newWorkspace.name },
{
groups: { workspace: newWorkspace.id },
setOnce: { first_workspace_created_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: newWorkspace.id,
actorId: session.user.id,

View File

@@ -90,6 +90,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
}
// Sidebar width
var defaultSidebarWidth = '248px';
try {
var stored = localStorage.getItem('sidebar-state');
if (stored) {
@@ -108,11 +109,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
}
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
} catch (e) {
// Fallback handled by CSS defaults
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
}
// Panel width and active tab

View File

@@ -108,8 +108,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -16,7 +16,7 @@ import {
} from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -59,8 +59,8 @@ type OAuthModalConnectProps = OAuthModalBaseProps & {
workspaceId: string
credentialCount: number
} & (
| { workflowId: string; knowledgeBaseId?: never }
| { workflowId?: never; knowledgeBaseId: string }
| { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
| { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
)
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
@@ -81,6 +81,7 @@ export function OAuthModal(props: OAuthModalProps) {
const workspaceId = isConnect ? props.workspaceId : ''
const workflowId = isConnect ? props.workflowId : undefined
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
const connectorType = isConnect ? props.connectorType : undefined
const toolName = !isConnect ? props.toolName : ''
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
@@ -172,7 +173,7 @@ export function OAuthModal(props: OAuthModalProps) {
}
const returnContext: OAuthReturnContext = knowledgeBaseId
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
writeOAuthReturnContext(returnContext)
@@ -205,7 +206,11 @@ export function OAuthModal(props: OAuthModalProps) {
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
const callbackURL = new URL(window.location.href)
if (connectorType) {
callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
}
await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })

View File

@@ -26,6 +26,7 @@ export function NavTour() {
steps: navTourSteps,
triggerEvent: START_NAV_TOUR_EVENT,
tourName: 'Navigation tour',
tourType: 'nav',
disabled: isWorkflowPage,
})

View File

@@ -2,7 +2,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { usePostHog } from 'posthog-js/react'
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
import { captureEvent } from '@/lib/posthog/client'
const logger = createLogger('useTour')
@@ -16,6 +18,8 @@ interface UseTourOptions {
triggerEvent?: string
/** Identifier for logging */
tourName?: string
/** Analytics tour type for PostHog events */
tourType?: 'nav' | 'workflow'
/** When true, stops a running tour (e.g. navigating away from the relevant page) */
disabled?: boolean
}
@@ -45,8 +49,10 @@ export function useTour({
steps,
triggerEvent,
tourName = 'tour',
tourType,
disabled = false,
}: UseTourOptions): UseTourReturn {
const posthog = usePostHog()
const [run, setRun] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [tourKey, setTourKey] = useState(0)
@@ -152,6 +158,9 @@ export function useTour({
setRun(true)
logger.info(`${tourName} triggered via event`)
scheduleReveal()
if (tourType) {
captureEvent(posthog, 'tour_started', { tour_type: tourType })
}
}, 50)
}
@@ -181,6 +190,13 @@ export function useTour({
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
stopTour()
logger.info(`${tourName} ended`, { status })
if (tourType) {
if (status === STATUS.FINISHED) {
captureEvent(posthog, 'tour_completed', { tour_type: tourType })
} else {
captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
}
}
return
}
@@ -188,6 +204,9 @@ export function useTour({
if (action === ACTIONS.CLOSE) {
stopTour()
logger.info(`${tourName} closed by user`)
if (tourType) {
captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
}
return
}
@@ -203,7 +222,7 @@ export function useTour({
transitionToStep(nextIndex)
}
},
[stopTour, transitionToStep, steps, tourName]
[stopTour, transitionToStep, steps, tourName, tourType, posthog]
)
return {

View File

@@ -26,6 +26,7 @@ export function WorkflowTour() {
steps: workflowTourSteps,
triggerEvent: START_WORKFLOW_TOUR_EVENT,
tourName: 'Workflow tour',
tourType: 'workflow',
})
const tourState = useMemo<TourState>(

View File

@@ -1,9 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function FileViewLoading() {
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
)
}

View File

@@ -353,7 +353,17 @@ const TemplateCard = memo(function TemplateCard({ template, onSelect }: Template
return (
<button
type='button'
onClick={() => onSelect(template.prompt)}
onClick={() => {
import('@/lib/posthog/client')
.then(({ captureClientEvent }) => {
captureClientEvent('template_used', {
template_title: template.title,
template_modules: template.modules.join(' '),
})
})
.catch(() => {})
onSelect(template.prompt)
}}
aria-label={`Select template: ${template.title}`}
className='group flex cursor-pointer flex-col text-left'
>

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { PanelLeft } from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import {
@@ -10,6 +11,7 @@ import {
type LandingWorkflowSeed,
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { captureEvent } from '@/lib/posthog/client'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
@@ -27,6 +29,8 @@ export function Home({ chatId }: HomeProps = {}) {
const { workspaceId } = useParams<{ workspaceId: string }>()
const router = useRouter()
const { data: session } = useSession()
const posthog = usePostHog()
const posthogRef = useRef(posthog)
const [initialPrompt, setInitialPrompt] = useState('')
const hasCheckedLandingStorageRef = useRef(false)
const initialViewInputRef = useRef<HTMLDivElement>(null)
@@ -199,11 +203,21 @@ export function Home({ chatId }: HomeProps = {}) {
return () => cancelAnimationFrame(id)
}, [resources])
useEffect(() => {
posthogRef.current = posthog
}, [posthog])
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
captureEvent(posthogRef.current, 'task_message_sent', {
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
has_contexts: !!(contexts && contexts.length > 0),
is_new_task: !chatId,
})
if (initialViewInputRef.current) {
setIsInputEntering(true)
}

View File

@@ -1407,17 +1407,6 @@ export function useChat(
const output = tc.result?.output as Record<string, unknown> | undefined
const deployedWorkflowId = (output?.workflowId as string) ?? undefined
if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') {
const isDeployed = output.isDeployed as boolean
const serverDeployedAt = output.deployedAt
? new Date(output.deployedAt as string)
: undefined
useWorkflowRegistry
.getState()
.setDeploymentStatus(
deployedWorkflowId,
isDeployed,
isDeployed ? (serverDeployedAt ?? new Date()) : undefined
)
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(deployedWorkflowId),
})

View File

@@ -1,22 +0,0 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_LINE_COUNT = 4
export default function HomeLoading() {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='min-h-0 flex-1 overflow-hidden px-6 py-4'>
<div className='mx-auto max-w-[42rem] space-y-[10px] pt-3'>
{Array.from({ length: SKELETON_LINE_COUNT }).map((_, i) => (
<Skeleton key={i} className='h-[16px]' style={{ width: `${120 + (i % 4) * 48}px` }} />
))}
</div>
</div>
<div className='flex-shrink-0 px-[24px] pb-[16px]'>
<div className='mx-auto max-w-[42rem]'>
<Skeleton className='h-[48px] w-full rounded-[12px]' />
</div>
</div>
</div>
)
}

View File

@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { AlertCircle, Loader2, Pencil, Plus, Tag, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Badge,
Button,
@@ -24,10 +25,12 @@ import {
import { Database, DatabaseX } from '@/components/emcn/icons'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { cn } from '@/lib/core/utils/cn'
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { captureEvent } from '@/lib/posthog/client'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import type {
BreadcrumbItem,
@@ -190,6 +193,19 @@ export function KnowledgeBase({
}: KnowledgeBaseProps) {
const params = useParams()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const router = useRouter()
const searchParams = useSearchParams()
const pathname = usePathname()
const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM)
const posthog = usePostHog()
useEffect(() => {
captureEvent(posthog, 'knowledge_base_opened', {
knowledge_base_id: id,
knowledge_base_name: passedKnowledgeBaseName ?? 'Unknown',
})
}, [id, passedKnowledgeBaseName, posthog])
useOAuthReturnForKBConnectors(id)
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
const userPermissions = useUserPermissionsContext()
@@ -267,7 +283,29 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
const [showAddConnectorModal, setShowAddConnectorModal] = useState(false)
const showAddConnectorModal = addConnectorParam != null
const searchParamsRef = useRef(searchParams)
searchParamsRef.current = searchParams
const updateAddConnectorParam = useCallback(
(value: string | null) => {
const current = searchParamsRef.current
const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM)
if (value === currentValue || (value === null && currentValue === null)) return
const next = new URLSearchParams(current.toString())
if (value === null) {
next.delete(ADD_CONNECTOR_SEARCH_PARAM)
} else {
next.set(ADD_CONNECTOR_SEARCH_PARAM, value)
}
const qs = next.toString()
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
},
[pathname, router]
)
const setShowAddConnectorModal = useCallback(
(open: boolean) => updateAddConnectorParam(open ? '' : null),
[updateAddConnectorParam]
)
const {
isOpen: isContextMenuOpen,
@@ -329,8 +367,6 @@ export function KnowledgeBase({
prevHadSyncingRef.current = hasSyncingConnectors
}, [hasSyncingConnectors, refreshKnowledgeBase, refreshDocuments])
const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
const error = knowledgeBaseError || documentsError
@@ -1243,7 +1279,13 @@ export function KnowledgeBase({
/>
{showAddConnectorModal && (
<AddConnectorModal open onOpenChange={setShowAddConnectorModal} knowledgeBaseId={id} />
<AddConnectorModal
open
onOpenChange={setShowAddConnectorModal}
onConnectorTypeChange={updateAddConnectorParam}
knowledgeBaseId={id}
initialConnectorType={addConnectorParam || undefined}
/>
)}
{documentToRename && (

View File

@@ -44,14 +44,22 @@ const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
interface AddConnectorModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConnectorTypeChange?: (connectorType: string | null) => void
knowledgeBaseId: string
initialConnectorType?: string | null
}
type Step = 'select-type' | 'configure'
export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddConnectorModalProps) {
const [step, setStep] = useState<Step>('select-type')
const [selectedType, setSelectedType] = useState<string | null>(null)
export function AddConnectorModal({
open,
onOpenChange,
onConnectorTypeChange,
knowledgeBaseId,
initialConnectorType,
}: AddConnectorModalProps) {
const [step, setStep] = useState<Step>(() => (initialConnectorType ? 'configure' : 'select-type'))
const [selectedType, setSelectedType] = useState<string | null>(initialConnectorType ?? null)
const [sourceConfig, setSourceConfig] = useState<Record<string, string>>({})
const [syncInterval, setSyncInterval] = useState(1440)
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
@@ -151,6 +159,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
setError(null)
setSearchTerm('')
setStep('configure')
onConnectorTypeChange?.(type)
}
const handleFieldChange = useCallback(
@@ -286,7 +295,10 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
<Button
variant='ghost'
className='mr-2 h-6 w-6 p-0'
onClick={() => setStep('select-type')}
onClick={() => {
setStep('select-type')
onConnectorTypeChange?.('')
}}
>
<ArrowLeft className='h-4 w-4' />
</Button>
@@ -565,6 +577,7 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
workspaceId={workspaceId}
knowledgeBaseId={knowledgeBaseId}
credentialCount={credentials.length}
connectorType={selectedType ?? undefined}
/>
)}
</>

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
@@ -11,6 +12,12 @@ export function WorkspaceScopeSync() {
const { workspaceId } = useParams<{ workspaceId: string }>()
const hydrationWorkspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId)
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
const posthog = usePostHog()
useEffect(() => {
if (!workspaceId) return
posthog?.group('workspace', workspaceId)
}, [posthog, workspaceId])
useEffect(() => {
if (!workspaceId || hydrationWorkspaceId === workspaceId) {

View File

@@ -1,9 +1,12 @@
'use client'
import { useEffect } from 'react'
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Skeleton } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { captureEvent } from '@/lib/posthog/client'
import { AdminSkeleton } from '@/app/workspace/[workspaceId]/settings/components/admin/admin-skeleton'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
@@ -160,6 +163,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
const searchParams = useSearchParams()
const mcpServerId = searchParams.get('mcpServerId')
const { data: session, isPending: sessionLoading } = useSession()
const posthog = usePostHog()
const isAdminRole = session?.user?.role === 'admin'
const effectiveSection =
@@ -174,6 +178,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
useEffect(() => {
if (sessionLoading) return
captureEvent(posthog, 'settings_tab_viewed', { section: effectiveSection })
}, [effectiveSection, sessionLoading, posthog])
return (
<div>
<h2 className='mb-7 font-medium text-[22px] text-[var(--text-primary)]'>{label}</h2>

View File

@@ -26,6 +26,7 @@ interface CreateApiKeyModalProps {
allowPersonalApiKeys?: boolean
canManageWorkspaceKeys?: boolean
defaultKeyType?: 'personal' | 'workspace'
source?: 'settings' | 'deploy_modal'
onKeyCreated?: (key: ApiKey) => void
}
@@ -41,6 +42,7 @@ export function CreateApiKeyModal({
allowPersonalApiKeys = true,
canManageWorkspaceKeys = false,
defaultKeyType = 'personal',
source = 'settings',
onKeyCreated,
}: CreateApiKeyModalProps) {
const [keyName, setKeyName] = useState('')
@@ -74,6 +76,7 @@ export function CreateApiKeyModal({
workspaceId,
name: trimmedName,
keyType,
source,
})
setNewKey(data.key)

View File

@@ -3,6 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { GripVertical } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Button,
Checkbox,
@@ -39,6 +40,7 @@ import {
TypeText,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, Filter, SortDirection, TableRow as TableRowType } from '@/lib/table'
import type { ColumnOption, SortConfig } from '@/app/workspace/[workspaceId]/components'
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
@@ -177,6 +179,12 @@ export function Table({
const router = useRouter()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const tableId = propTableId || (params.tableId as string)
const posthog = usePostHog()
useEffect(() => {
if (!tableId || !workspaceId) return
captureEvent(posthog, 'table_opened', { table_id: tableId, workspace_id: workspaceId })
}, [tableId, workspaceId, posthog])
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,

View File

@@ -1,13 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function TaskLoading() {
return (
<div className='flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-0 flex-1 flex-col'>
<div className='flex min-h-0 flex-1 items-center justify-center'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
</div>
)
}

View File

@@ -111,8 +111,6 @@ function normalizeWorkflowState(input?: any): WorkflowState | null {
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}

View File

@@ -40,7 +40,6 @@ import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -90,10 +89,7 @@ export function DeployModal({
const params = useParams()
const workspaceId = params?.workspaceId as string
const { navigateToSettings } = useSettingsNavigation()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(workflowId)
)
const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp
const isDeployed = isDeployedProp
const { data: workflowMap = {} } = useWorkflowMap(workspaceId)
const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
@@ -381,8 +377,6 @@ export function DeployModal({
invalidateDeploymentQueries(queryClient, workflowId)
useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false)
if (chatSuccessTimeoutRef.current) {
clearTimeout(chatSuccessTimeoutRef.current)
}
@@ -915,6 +909,7 @@ export function DeployModal({
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
source='deploy_modal'
/>
{workflowId && (

View File

@@ -9,7 +9,7 @@ import {
useDeployment,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import { useDeployedWorkflowState } from '@/hooks/queries/deployments'
import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -25,10 +25,10 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading'
const { hasBlocks } = useCurrentWorkflow()
const deploymentStatus = useWorkflowRegistry((state) =>
state.getWorkflowDeploymentStatus(activeWorkflowId)
)
const isDeployed = deploymentStatus?.isDeployed || false
const { data: deploymentInfo } = useDeploymentInfo(activeWorkflowId, {
enabled: !isRegistryLoading,
})
const isDeployed = deploymentInfo?.isDeployed ?? false
const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading
const {

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { hasWorkflowChanged } from '@/lib/workflows/comparison'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

View File

@@ -59,14 +59,10 @@ interface ComboBoxProps {
/** Configuration for the sub-block */
config: SubBlockConfig
/** Async function to fetch options dynamically */
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
fetchOptions?: (blockId: 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 */
@@ -135,7 +131,7 @@ export const ComboBox = memo(function ComboBox({
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
const options = await fetchOptions(blockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
@@ -144,7 +140,7 @@ export const ComboBox = memo(function ComboBox({
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
}, [fetchOptions, blockId, isPreview, disabled])
// Determine the active value based on mode (preview vs. controlled vs. store)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
@@ -363,7 +359,7 @@ export const ComboBox = memo(function ComboBox({
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
fetchOptionById(blockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
@@ -378,7 +374,6 @@ export const ComboBox = memo(function ComboBox({
fetchOptionById,
value,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,

View File

@@ -1,7 +1,7 @@
'use client'
import { createElement, useCallback, useMemo, useState } from 'react'
import { ExternalLink, Users } from 'lucide-react'
import { ExternalLink, KeyRound, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionAccessState } from '@/lib/billing/client'
@@ -22,7 +22,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
@@ -60,6 +60,7 @@ export function CredentialSelector({
const requiredScopes = subBlock.requiredScopes || []
const label = subBlock.placeholder || 'Select credential'
const serviceId = subBlock.serviceId || ''
const isAllCredentials = !serviceId
const supportsCredentialSets = subBlock.supportsCredentialSets || false
const { data: organizationsData } = useOrganizations()
@@ -101,14 +102,22 @@ export function CredentialSelector({
const {
data: rawCredentials = [],
isFetching: credentialsLoading,
isFetching: oauthCredentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
enabled: !isAllCredentials && Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const {
data: allWorkspaceCredentials = [],
isFetching: allCredentialsLoading,
refetch: refetchAllCredentials,
} = useWorkspaceCredentials({ workspaceId, enabled: isAllCredentials })
const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading
const credentials = useMemo(
() =>
isTriggerMode
@@ -122,9 +131,17 @@ export function CredentialSelector({
[credentials, selectedId]
)
const selectedAllCredential = useMemo(
() =>
isAllCredentials ? (allWorkspaceCredentials.find((c) => c.id === selectedId) ?? null) : null,
[isAllCredentials, allWorkspaceCredentials, selectedId]
)
const isServiceAccount = useMemo(
() => selectedCredential?.type === 'service_account',
[selectedCredential]
() =>
selectedCredential?.type === 'service_account' ||
selectedAllCredential?.type === 'service_account',
[selectedCredential, selectedAllCredential]
)
const selectedCredentialSet = useMemo(
@@ -134,37 +151,45 @@ export function CredentialSelector({
const { data: inaccessibleCredential } = useWorkspaceCredential(
selectedId || undefined,
Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
Boolean(selectedId) &&
!selectedCredential &&
!selectedAllCredential &&
!credentialsLoading &&
Boolean(workspaceId)
)
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
if (selectedAllCredential) return selectedAllCredential.displayName
if (selectedCredential) return selectedCredential.name
if (inaccessibleCredentialName) return inaccessibleCredentialName
return ''
}, [selectedCredentialSet, selectedCredential, inaccessibleCredentialName])
}, [selectedCredentialSet, selectedAllCredential, selectedCredential, inaccessibleCredentialName])
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const refetch = useCallback(
() => (isAllCredentials ? refetchAllCredentials() : refetchCredentials()),
[isAllCredentials, refetchAllCredentials, refetchCredentials]
)
useCredentialRefreshTriggers(refetch, effectiveProviderId, workspaceId)
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (isOpen) {
void refetchCredentials()
}
if (isOpen) void refetch()
},
[refetchCredentials]
[refetch]
)
const hasSelection = Boolean(selectedCredential)
const missingRequiredScopes = hasSelection
const hasOAuthSelection = Boolean(selectedCredential)
const missingRequiredScopes = hasOAuthSelection
? getMissingRequiredScopes(selectedCredential!, requiredScopes || [])
: []
const needsUpdate =
hasSelection &&
hasOAuthSelection &&
!isServiceAccount &&
missingRequiredScopes.length > 0 &&
!effectiveDisabled &&
@@ -218,6 +243,12 @@ export function CredentialSelector({
}, [])
const { comboboxOptions, comboboxGroups } = useMemo(() => {
if (isAllCredentials) {
const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth')
const options = oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id }))
return { comboboxOptions: options, comboboxGroups: undefined }
}
const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId)
// Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility
const matchesProvider = (csProviderId: string | null) => {
@@ -281,6 +312,8 @@ export function CredentialSelector({
return { comboboxOptions: options, comboboxGroups: undefined }
}, [
isAllCredentials,
allWorkspaceCredentials,
credentials,
provider,
effectiveProviderId,
@@ -306,6 +339,17 @@ export function CredentialSelector({
)
}
if (isAllCredentials && selectedAllCredential) {
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
<KeyRound className='h-3 w-3' />
</div>
<span className='truncate'>{displayValue}</span>
</div>
)
}
return (
<div className='flex w-full items-center truncate'>
<div className='mr-2 flex-shrink-0 opacity-90'>
@@ -320,7 +364,8 @@ export function CredentialSelector({
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
isServiceAccount,
isAllCredentials,
selectedAllCredential,
])
const handleComboboxChange = useCallback(
@@ -339,7 +384,9 @@ export function CredentialSelector({
}
}
const matchedCred = credentials.find((c) => c.id === value)
const matchedCred = (
isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials
).find((c) => c.id === value)
if (matchedCred) {
handleSelect(value)
return
@@ -348,7 +395,15 @@ export function CredentialSelector({
setIsEditing(true)
setEditingValue(value)
},
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
[
isAllCredentials,
allWorkspaceCredentials,
credentials,
credentialSets,
handleAddCredential,
handleSelect,
handleCredentialSetSelect,
]
)
return (

View File

@@ -52,14 +52,10 @@ interface DropdownProps {
/** Enable multi-select mode */
multiSelect?: boolean
/** Async function to fetch options dynamically */
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
fetchOptions?: (blockId: 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 */
@@ -160,7 +156,7 @@ export const Dropdown = memo(function Dropdown({
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
const options = await fetchOptions(blockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
@@ -169,7 +165,7 @@ export const Dropdown = memo(function Dropdown({
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
}, [fetchOptions, blockId, isPreview, disabled])
/**
* Handles combobox open state changes to trigger option fetching
@@ -430,7 +426,7 @@ export const Dropdown = memo(function Dropdown({
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
fetchOptionById(blockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
@@ -446,7 +442,6 @@ export const Dropdown = memo(function Dropdown({
singleValue,
multiSelect,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,

View File

@@ -31,8 +31,8 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
import { normalizeName } from '@/executor/constants'
import type { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'

View File

@@ -19,8 +19,8 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
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 { Variable } from '@/stores/panel'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
interface VariableAssignment {
id: string

View File

@@ -5,8 +5,8 @@ import { useParams } from 'next/navigation'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type { SubBlockConfig } from '@/blocks/types'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { usePersonalEnvironment } from '@/hooks/queries/environment'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
import { useSubBlockValue } from './use-sub-block-value'
@@ -32,7 +32,7 @@ export function useSelectorSetup(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
const envVariables = useEnvironmentStore((s) => s.variables)
const { data: envVariables = {} } = usePersonalEnvironment()
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
blockId,

View File

@@ -14,9 +14,11 @@ import {
Unlock,
} from 'lucide-react'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Button, Tooltip } from '@/components/emcn'
import { captureEvent } from '@/lib/posthog/client'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
@@ -106,6 +108,7 @@ export function Editor() {
const params = useParams()
const workspaceId = params.workspaceId as string
const posthog = usePostHog()
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -298,7 +301,11 @@ export function Editor() {
const handleOpenDocs = useCallback(() => {
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
captureEvent(posthog, 'docs_opened', {
source: 'editor_button',
block_type: currentBlock?.type,
})
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink, posthog, currentBlock?.type])
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null

View File

@@ -12,7 +12,9 @@ import {
} from 'react'
import clsx from 'clsx'
import { Search } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { Button } from '@/components/emcn'
import { captureEvent } from '@/lib/posthog/client'
import {
getBlocksForSidebar,
getTriggersForSidebar,
@@ -348,6 +350,7 @@ export const Toolbar = memo(
triggersHeaderRef,
})
const posthog = usePostHog()
const { filterBlocks } = usePermissionConfig()
const sandboxAllowedBlocks = useSandboxBlockConstraints()
@@ -541,8 +544,12 @@ export const Toolbar = memo(
const handleViewDocumentation = useCallback(() => {
if (activeItemInfo?.docsLink) {
window.open(activeItemInfo.docsLink, '_blank', 'noopener,noreferrer')
captureEvent(posthog, 'docs_opened', {
source: 'toolbar_context_menu',
block_type: activeItemInfo.type,
})
}
}, [activeItemInfo])
}, [activeItemInfo, posthog])
/**
* Handle clicks outside the context menu to close it

View File

@@ -63,7 +63,8 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { ChatContext, PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { usePanelStore } from '@/stores/panel'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { captureBaselineSnapshot } from '@/stores/workflow-diff/utils'
@@ -205,7 +206,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setIsChatOpen: state.setIsChatOpen,
}))
)
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore(
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesModalStore(
useShallow((state) => ({
isOpen: state.isOpen,
setIsOpen: state.setIsOpen,
@@ -410,6 +411,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
setHasHydrated(true)
}, [setHasHydrated])
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent<{ message: string }>).detail?.message
if (!message) return
setActiveTab('copilot')
copilotSendMessage(message)
}
window.addEventListener('mothership-send-message', handler)
return () => window.removeEventListener('mothership-send-message', handler)
}, [setActiveTab, copilotSendMessage])
/**
* Handles tab click events
*/
@@ -482,7 +494,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
throw new Error('No workflow state found')
}
const workflowVariables = usePanelVariablesStore
const workflowVariables = useVariablesStore
.getState()
.getVariablesByWorkflowId(activeWorkflowId)

View File

@@ -27,15 +27,15 @@ import {
usePreventZoom,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import {
getVariablesPosition,
MAX_VARIABLES_HEIGHT,
MAX_VARIABLES_WIDTH,
MIN_VARIABLES_HEIGHT,
MIN_VARIABLES_WIDTH,
useVariablesStore,
} from '@/stores/variables/store'
useVariablesModalStore,
} from '@/stores/variables/modal'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -96,7 +96,7 @@ export function Variables() {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
useVariablesStore(
useVariablesModalStore(
useShallow((s) => ({
isOpen: s.isOpen,
position: s.position,
@@ -108,7 +108,7 @@ export function Variables() {
}))
)
const variables = usePanelVariablesStore((s) => s.variables)
const variables = useVariablesStore((s) => s.variables)
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
useCollaborativeWorkflow()

View File

@@ -48,7 +48,7 @@ import { useSkills } from '@/hooks/queries/skills'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSelectorDisplayName } from '@/hooks/use-selector-display-name'
import { useVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react'
import type { Edge } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -16,8 +15,6 @@ export interface CurrentWorkflow {
loops: Record<string, Loop>
parallels: Record<string, Parallel>
lastSaved?: number
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
// Mode information
isDiffMode: boolean
@@ -48,8 +45,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: state.loops,
parallels: state.parallels,
lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment,
}))
)
@@ -78,8 +73,6 @@ export function useCurrentWorkflow(): CurrentWorkflow {
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
lastSaved: activeWorkflow.lastSaved,
deploymentStatuses: activeWorkflow.deploymentStatuses,
needsRedeployment: activeWorkflow.needsRedeployment,
// Mode information - update to reflect ready state
isDiffMode: hasActiveDiff && isShowingDiff,

View File

@@ -36,8 +36,6 @@ import { useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useVariablesStore } from '@/stores/panel'
import { useEnvironmentStore } from '@/stores/settings/environment'
import {
clearExecutionPointer,
consolePersistence,
@@ -45,6 +43,7 @@ import {
saveExecutionPointer,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -120,7 +119,6 @@ export function useWorkflowExecution() {
}))
)
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
const { getVariablesByWorkflowId, variables } = useVariablesStore(
useShallow((s) => ({
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
@@ -744,7 +742,6 @@ export function useWorkflowExecution() {
activeWorkflowId,
currentWorkflow,
toggleConsole,
getAllVariables,
getVariablesByWorkflowId,
setIsExecuting,
setIsDebugging,

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -124,8 +124,7 @@ export async function applyAutoLayoutAndUpdateStore(
try {
useWorkflowStore.getState().updateLastSaved()
const { deploymentStatuses, needsRedeployment, dragStartPosition, ...stateToSave } =
newWorkflowState
const { dragStartPosition, ...stateToSave } = newWorkflowState
const cleanedWorkflowState = {
...stateToSave,

View File

@@ -85,7 +85,7 @@ import { useSearchModalStore } from '@/stores/modals/search/store'
import { useNotificationStore } from '@/stores/notifications'
import { usePanelEditorStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesStore } from '@/stores/variables/store'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
@@ -265,7 +265,7 @@ const WorkflowContent = React.memo(
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance, {
embedded,
})
const { emitCursorUpdate } = useSocket()
const { emitCursorUpdate, joinWorkflow, leaveWorkflow } = useSocket()
useDynamicHandleRefresh()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
@@ -273,6 +273,14 @@ const WorkflowContent = React.memo(
const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
if (!embedded || !workflowIdParam) return
joinWorkflow(workflowIdParam)
return () => {
leaveWorkflow()
}
}, [embedded, workflowIdParam, joinWorkflow, leaveWorkflow])
useOAuthReturnForWorkflow(workflowIdParam)
const {
@@ -337,7 +345,7 @@ const WorkflowContent = React.memo(
autoConnectRef.current = isAutoConnectEnabled
// Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isVariablesOpen = useVariablesModalStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
const snapGrid: [number, number] = useMemo(
@@ -1374,7 +1382,7 @@ const WorkflowContent = React.memo(
}, [router, workspaceId, workflowIdParam])
const handleContextToggleVariables = useCallback(() => {
const { isOpen, setIsOpen } = useVariablesStore.getState()
const { isOpen, setIsOpen } = useVariablesModalStore.getState()
setIsOpen(!isOpen)
}, [])
@@ -2144,12 +2152,9 @@ const WorkflowContent = React.memo(
const handleCanvasPointerMove = useCallback(
(event: React.PointerEvent<Element>) => {
const target = event.currentTarget as HTMLElement
const bounds = target.getBoundingClientRect()
const position = screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
x: event.clientX,
y: event.clientY,
})
emitCursorUpdate(position)

View File

@@ -13,7 +13,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */

View File

@@ -343,7 +343,11 @@ export function SearchModal({
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border-[4px] border-black/[0.06] bg-[var(--bg)] shadow-[0_24px_80px_-16px_rgba(0,0,0,0.15)] dark:border-white/[0.06] dark:shadow-[0_24px_80px_-16px_rgba(0,0,0,0.4)]',
open ? 'visible opacity-100' : 'invisible opacity-0'
)}
style={{ left: 'calc(var(--sidebar-width) / 2 + 50%)' }}
style={{
left: isOnWorkflowPage
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
}}
>
<Command label='Search' shouldFilter={false}>
<div className='mx-2 mt-2 mb-1 flex items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>

View File

@@ -6,6 +6,7 @@ import { Compass, MoreHorizontal } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
Blimp,
Button,
@@ -39,6 +40,7 @@ import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
import {
START_NAV_TOUR_EVENT,
START_WORKFLOW_TOUR_EVENT,
@@ -315,6 +317,7 @@ export const Sidebar = memo(function Sidebar() {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
const { canEdit } = useUserPermissionsContext()
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
@@ -1092,10 +1095,10 @@ export const Sidebar = memo(function Sidebar() {
const handleOpenHelpFromMenu = useCallback(() => setIsHelpModalOpen(true), [])
const handleOpenDocs = useCallback(
() => window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer'),
[]
)
const handleOpenDocs = useCallback(() => {
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
captureEvent(posthog, 'docs_opened', { source: 'help_menu' })
}, [posthog])
const handleTaskRenameBlur = useCallback(
() => void taskFlyoutRename.saveRename(),

View File

@@ -1,11 +0,0 @@
import { Loader2 } from 'lucide-react'
export default function WorkflowsLoading() {
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative flex h-full w-full flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
</div>
</div>
)
}

View File

@@ -90,6 +90,7 @@ interface SocketContextType {
onSelectionUpdate: (handler: (data: any) => void) => void
onWorkflowDeleted: (handler: (data: any) => void) => void
onWorkflowReverted: (handler: (data: any) => void) => void
onWorkflowUpdated: (handler: (data: any) => void) => void
onOperationConfirmed: (handler: (data: any) => void) => void
onOperationFailed: (handler: (data: any) => void) => void
}
@@ -118,6 +119,7 @@ const SocketContext = createContext<SocketContextType>({
onSelectionUpdate: () => {},
onWorkflowDeleted: () => {},
onWorkflowReverted: () => {},
onWorkflowUpdated: () => {},
onOperationConfirmed: () => {},
onOperationFailed: () => {},
})
@@ -155,6 +157,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
selectionUpdate?: (data: any) => void
workflowDeleted?: (data: any) => void
workflowReverted?: (data: any) => void
workflowUpdated?: (data: any) => void
operationConfirmed?: (data: any) => void
operationFailed?: (data: any) => void
}>({})
@@ -334,7 +337,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
socketInstance.on('join-workflow-success', ({ workflowId, presenceUsers }) => {
isRejoiningRef.current = false
// Ignore stale success responses from previous navigation
if (workflowId !== urlWorkflowIdRef.current) {
if (urlWorkflowIdRef.current && workflowId !== urlWorkflowIdRef.current) {
logger.debug(`Ignoring stale join-workflow-success for ${workflowId}`)
return
}
@@ -382,6 +385,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted?.(data)
})
socketInstance.on('workflow-updated', (data) => {
logger.info(`Workflow ${data.workflowId} has been updated externally`)
eventHandlers.current.workflowUpdated?.(data)
})
const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => {
const [
{ useOperationQueueStore },
@@ -424,7 +432,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
lastSaved: workflowState.lastSaved || Date.now(),
deploymentStatuses: workflowState.deploymentStatuses || {},
})
useSubBlockStore.setState((state: any) => ({
@@ -804,6 +811,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
eventHandlers.current.workflowReverted = handler
}, [])
const onWorkflowUpdated = useCallback((handler: (data: any) => void) => {
eventHandlers.current.workflowUpdated = handler
}, [])
const onOperationConfirmed = useCallback((handler: (data: any) => void) => {
eventHandlers.current.operationConfirmed = handler
}, [])
@@ -837,6 +848,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
}),
@@ -864,6 +876,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
onSelectionUpdate,
onWorkflowDeleted,
onWorkflowReverted,
onWorkflowUpdated,
onOperationConfirmed,
onOperationFailed,
]

View File

@@ -50,7 +50,7 @@ async function sendLifecycleEmail({ userId, type }: LifecycleEmailParams): Promi
html,
from,
replyTo,
emailType: 'transactional',
emailType: 'notifications',
})
logger.info('[lifecycle-email] Sent lifecycle email', { userId, type })

View File

@@ -0,0 +1,151 @@
import { CredentialIcon } from '@/components/icons'
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { BlockConfig } from '@/blocks/types'
import { fetchWorkspaceCredentialList, workspaceCredentialKeys } from '@/hooks/queries/credentials'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface CredentialBlockOutput {
success: boolean
output: {
credentialId: string
displayName: string
providerId: string
credentials: Array<{
credentialId: string
displayName: string
providerId: string
}>
count: number
}
}
export const CredentialBlock: BlockConfig<CredentialBlockOutput> = {
type: 'credential',
name: 'Credential',
description: 'Select or list OAuth credentials',
longDescription:
'Select an OAuth credential once and pipe its ID into any downstream block that requires authentication, or list all OAuth credentials in the workspace for iteration. No secrets are ever exposed — only credential IDs and metadata.',
bestPractices: `
- Use "Select Credential" to define an OAuth credential once and reference <CredentialBlock.credentialId> in multiple downstream blocks instead of repeating credential IDs.
- Use "List Credentials" with a ForEach loop to iterate over all OAuth accounts (e.g. all Gmail accounts).
- Use the Provider filter to narrow results to specific services (e.g. Gmail, Slack).
- The outputs are credential ID references, not secret values — they are safe to log and inspect.
- To switch credentials across environments, replace the single Credential block rather than updating every downstream block.
`,
docsLink: 'https://docs.sim.ai/blocks/credential',
bgColor: '#6366F1',
icon: CredentialIcon,
category: 'blocks',
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Select Credential', id: 'select' },
{ label: 'List Credentials', id: 'list' },
],
value: () => 'select',
},
{
id: 'providerFilter',
title: 'Provider',
type: 'dropdown',
multiSelect: true,
options: [],
condition: { field: 'operation', value: 'list' },
fetchOptions: async () => {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []
const credentials = await getQueryClient().fetchQuery({
queryKey: workspaceCredentialKeys.list(workspaceId),
queryFn: () => fetchWorkspaceCredentialList(workspaceId),
staleTime: 60 * 1000,
})
const seen = new Set<string>()
const options: Array<{ label: string; id: string }> = []
for (const cred of credentials) {
if (cred.type === 'oauth' && cred.providerId && !seen.has(cred.providerId)) {
seen.add(cred.providerId)
const serviceConfig = getServiceConfigByProviderId(cred.providerId)
options.push({ label: serviceConfig?.name ?? cred.providerId, id: cred.providerId })
}
}
return options.sort((a, b) => a.label.localeCompare(b.label))
},
fetchOptionById: async (_blockId: string, optionId: string) => {
const serviceConfig = getServiceConfigByProviderId(optionId)
const label = serviceConfig?.name ?? optionId
return { label, id: optionId }
},
},
{
id: 'credential',
title: 'Credential',
type: 'oauth-input',
required: { field: 'operation', value: 'select' },
mode: 'basic',
placeholder: 'Select a credential',
canonicalParamId: 'credentialId',
condition: { field: 'operation', value: 'select' },
},
{
id: 'manualCredential',
title: 'Credential ID',
type: 'short-input',
required: { field: 'operation', value: 'select' },
mode: 'advanced',
placeholder: 'Enter credential ID',
canonicalParamId: 'credentialId',
condition: { field: 'operation', value: 'select' },
},
],
tools: {
access: [],
},
inputs: {
operation: { type: 'string', description: "'select' or 'list'" },
credentialId: {
type: 'string',
description: 'The OAuth credential ID to resolve (select operation)',
},
providerFilter: {
type: 'json',
description:
'Array of OAuth provider IDs to filter by (e.g. ["google-email", "slack"]). Leave empty to return all OAuth credentials.',
},
},
outputs: {
credentialId: {
type: 'string',
description: "Credential ID — pipe into other blocks' credential fields",
condition: { field: 'operation', value: 'select' },
},
displayName: {
type: 'string',
description: 'Human-readable name of the credential',
condition: { field: 'operation', value: 'select' },
},
providerId: {
type: 'string',
description: 'OAuth provider ID (e.g. google-email, slack)',
condition: { field: 'operation', value: 'select' },
},
credentials: {
type: 'json',
description:
'Array of OAuth credential objects, each with credentialId, displayName, and providerId',
condition: { field: 'operation', value: 'list' },
},
count: {
type: 'number',
description: 'Number of credentials returned',
condition: { field: 'operation', value: 'list' },
},
},
}

View File

@@ -26,6 +26,7 @@ import { ClerkBlock } from '@/blocks/blocks/clerk'
import { CloudflareBlock } from '@/blocks/blocks/cloudflare'
import { ConditionBlock } from '@/blocks/blocks/condition'
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
import { CredentialBlock } from '@/blocks/blocks/credential'
import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor'
import { DatabricksBlock } from '@/blocks/blocks/databricks'
import { DatadogBlock } from '@/blocks/blocks/datadog'
@@ -243,6 +244,7 @@ export const registry: Record<string, BlockConfig> = {
clay: ClayBlock,
clerk: ClerkBlock,
condition: ConditionBlock,
credential: CredentialBlock,
confluence: ConfluenceBlock,
confluence_v2: ConfluenceV2Block,
cursor: CursorBlock,

View File

@@ -421,15 +421,11 @@ export interface SubBlockConfig {
triggerId?: string
// Dropdown/Combobox: Function to fetch options dynamically
// Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support)
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
// Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration)
// Called when component mounts with a stored value to display the correct label before options load
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
}

View File

@@ -267,3 +267,24 @@ export const baseStyles = {
margin: '8px 0',
},
}
/** Styles for plain personal emails (no branding, no EmailLayout) */
export const plainEmailStyles = {
body: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
backgroundColor: '#ffffff',
margin: '0',
padding: '0',
},
container: {
maxWidth: '560px',
margin: '40px auto',
padding: '0 24px',
},
p: {
fontSize: '15px',
lineHeight: '1.6',
color: '#1a1a1a',
margin: '0 0 16px',
},
} as const

View File

@@ -1 +1 @@
export { baseStyles, colors, spacing, typography } from './base'
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'

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