Compare commits

..

55 Commits

Author SHA1 Message Date
Waleed
7b572f1f61 v0.6.10: tour fix, connectors reliability improvements, tooltip gif fixes 2026-03-24 21:38:19 -07:00
Vikhyath Mondreti
b497033795 Revert "improvement(mothership): show continue options on abort (#3746)" (#3756)
This reverts commit b9926df8e0.
2026-03-24 21:08:52 -07:00
Waleed
666dc67aa2 fix(db): use bigint for token counter columns in user_stats (#3755) 2026-03-24 21:08:07 -07:00
Waleed
7af7a225f2 fix(knowledge): route connector doc processing through queue instead of fire-and-forget (#3754)
* fix(knowledge): route connector doc processing through queue instead of fire-and-forget

* fix(knowledge): rename jobIds to batchIds in processDocumentsWithTrigger return type

* improvement(knowledge): add Trigger.dev tags for connector sync and document processing tasks

* fix(knowledge): move completeSyncLog after doc enqueue, handle NULL processingStartedAt in stuck doc query
2026-03-24 21:07:55 -07:00
Waleed
228578e282 fix(auth): remove captcha from login, fix signup captcha flow (#3753)
* fix(auth): remove captcha from login, fix signup captcha flow

* fix(auth): show Turnstile widget at normal size for Managed mode challenges
2026-03-24 20:36:49 -07:00
Waleed
be647469ac fix(ui): constrain tooltip width and remove question mark cursor (#3752)
- Add max-w-[260px] to Tooltip.Content so video previews don't blow out the tooltip size
- Replace cursor-help with cursor-default on info icons in settings
2026-03-24 19:01:45 -07:00
Waleed
96b171cf74 improvement(tour): fix tour auto-start logic and standardize selectors (#3751)
* improvement(tour): fix tour auto-start logic and standardize selectors

* fix(tour): address PR review comments

- Move autoStartAttempted.add() inside timer callback to prevent
  blocking auto-start when tour first mounts while disabled
- Memoize setJoyrideRef with useCallback to prevent ref churn
- Remove unused joyrideRef
2026-03-24 18:32:17 -07:00
Theodore Li
cdea2404e3 improvement(ui): Merge ui components for mothership chat (#3748)
* improvement(ui): Merge ui definitions for mothership chat

* Fix lint

* Restore copilot layout

* Fix subagent text not animating collapses

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-24 20:19:04 -04:00
Vikhyath Mondreti
ed9a71f0af v0.6.9: general ux improvements for tables, mothership 2026-03-24 17:03:24 -07:00
Waleed
f6975fc0a3 feat(settings): add video tooltip previews for canvas settings (#3749)
* feat(settings): add video tooltip previews for canvas settings

* fix(tooltip): add preload=none and handle query strings in video detection
2026-03-24 16:56:10 -07:00
Theodore Li
59182d5db2 feat(admin): Add assume user capability (#3742)
* Allow admin users to assume user sessions

* Add explicit role check

* Fix lint

* Remove admin panel when impersonating

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-03-24 19:38:06 -04:00
Siddharth Ganesan
b9926df8e0 improvement(mothership): show continue options on abort (#3746)
* Show continue options on abort

* Fix lint

* Fix
2026-03-24 16:08:28 -07:00
Waleed
77eafabb63 feat(billing): add appliesTo plan restriction for coupon codes (#3744)
* feat(billing): add appliesTo plan restriction for coupon codes

* fix(billing): fail coupon creation on partial product resolution
2026-03-24 13:04:55 -07:00
Waleed
34ea99e99d feat(home): auth-aware landing page navigation (#3743)
* feat(home): auth-aware landing page navigation

- Redirect authenticated users from / to /workspace via middleware (?home param bypasses)
- Show "Go to App" instead of "Log in / Get started" in navbar for authenticated users
- Logo links to /?home for authenticated users to stay in marketing context
- Settings "Home Page" button opens /?home
- Handle isPending session state to prevent CTA button flash

* lint

* fix(home): remove stale ?from=nav params in landing nav

* fix(home): preserve ?home param in nav links during session pending state

* lint
2026-03-24 12:59:29 -07:00
Waleed
a7f344bca1 feat(tour): added product tour (#3703)
* feat: add product tour

* chore: updated modals

* chore: fix the tour

* chore: Tour Updates

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes

* minor improvements

* chore(tour): address PR review comments

- Extract shared TourState, TourStateContext, mapPlacement, and TourTooltipAdapter
  into tour-shared.tsx, eliminating ~100 lines of duplication between product-tour.tsx
  and workflow-tour.tsx
- Fix stale closure in handleStartTour — add isOnWorkflowPage to useCallback deps
  so Take a tour dispatches the correct event after navigation

* chore(tour): address remaining PR review comments

- Remove unused logger import and instance in product-tour.tsx
- Remove unused tour-tooltip-fade animation from tailwind config
- Remove unnecessary overflow-hidden wrapper around WorkflowTour
- Add border stroke to arrow SVG in tour-tooltip for visual consistency

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

* chore(tour): address second round of PR review comments

- Remove unnecessary 'use client' from workflow layout (children are already client components)
- Fix ref guard timing issue in TourTooltipAdapter that could prevent Joyride from tracking tooltip on subsequent steps

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

* chore(tour): extract shared Joyride config, fix popover arrow overflow

- Extract duplicated Joyride floaterProps/styles into getSharedJoyrideProps()
  in tour-shared.tsx, parameterized by spotlightBorderRadius
- Fix showArrow disabling content scrolling in PopoverContent by wrapping
  children in a scrollable div when arrow is visible

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

* lint

* fix(tour): stop running tour when disabled becomes true

Prevents nav and workflow tours from overlapping. When a user navigates
to a workflow page while the nav tour is running, the disabled flag
now stops the nav tour instead of just suppressing auto-start.

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

* fix(tour): move auto-start flag into timer, fix truncate selector conflict

- Move hasAutoStarted flag inside setTimeout callback so it's only set
  when the timer fires, allowing retry if disabled changes during delay
- Add data-popover-scroll attribute to showArrow scroll wrapper and
  exclude it from the flex-1 truncate selector to prevent overflow
  conflict

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

* fix(tour): remove duplicate overlay on center-placed tour steps

Joyride's spotlight already renders a full-screen overlay via boxShadow.
The centered TourTooltip was adding its own bg-black/55 overlay on top,
causing double-darkened backgrounds. Removed the redundant overlay div.

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

* refactor: move docs link from settings to help dropdown

The Docs link (https://docs.sim.ai) was buried in settings navigation.
Moved it to the Help dropdown in the sidebar for better discoverability.

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

---------

Co-authored-by: Adithya Krishna <aadithya794@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:45:16 -07:00
Adithya Krishna
7b6149dc23 chore: optimize imports and useShallow (#3740)
* chore: fix conflicts

* chore: fix review changes

* chore: fix review changes

* chore: fix review changes
2026-03-24 10:51:09 -07:00
Waleed
b09a073c29 feat(table): column drag-and-drop reorder (#3738)
* feat(table): column drag-and-drop reorder

* fix(table): remove duplicate onDragEnd call from handleDrop

* fix(table): persist columnOrder on rename/delete and defer delete to onSuccess

* fix(table): prevent stale refs during column drag operations

Fix two bugs in column drag-and-drop:
1. Stale columnWidths ref during rename - compute updated widths inline
   before passing to updateMetadata
2. Escape-cancelled drag still reorders - update dropTargetColumnNameRef
   directly in handleColumnDragLeave to prevent handleColumnDragEnd from
   reading stale ref value

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

* fix(table): insert column at correct side when anchor is unordered

When the anchor column isn't in columnOrder, add it first then insert
the new column relative to it, so 'right' insertions appear after the
anchor as expected.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:15:48 -07:00
Adithya Krishna
8d93c850ba chore: remove lodash (#3741) 2026-03-24 10:10:20 -07:00
Waleed
83eb3ed211 fix(home): voice input text persistence bugs (#3737)
* fix(home): voice input text persistence bugs

* fix(home): gate setIsListening on startRecognition success

* fix(home): handle startRecognition failure in restartRecognition

* fix(home): reset speech prefix on submit while mic is active
2026-03-24 09:55:13 -07:00
Waleed
a783b9d4ce fix(integrations): remove outdated trigger mode text from FAQ (#3739) 2026-03-24 06:47:50 -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
Vikhyath Mondreti
0c80438ede fix(mothership): async resume and tool result ordering (#3735)
* fix(mothership):  async resume and tool result ordering

* ensure tool call terminal state

* address comments
2026-03-24 03:47:47 -07:00
Siddharth Ganesan
41a7d247ea fix(mothership): parallel tool calls 2026-03-24 02:45:31 -07:00
Siddharth Ganesan
092525e8aa fix(mothership): abort streamlining (#3734)
* Fixes

* Address bugbot

* Fixes

* Fix

* Fixes

* Fix lint

* Fixes

* Fixes

* Truncate log
2026-03-24 02:17:58 -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 4700 additions and 8633 deletions

View File

@@ -1,20 +1,16 @@
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="apps/sim/public/logo/wordmark.svg">
<source media="(prefers-color-scheme: light)" srcset="apps/sim/public/logo/wordmark-dark.svg">
<img src="apps/sim/public/logo/wordmark-dark.svg" alt="Sim Logo" width="300"/>
</picture>
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
</a>
</p>
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482" alt="Sim.ai"></a>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simdotai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-33c482.svg" alt="Documentation"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
@@ -46,7 +42,7 @@ Upload documents to a vector store and let agents answer questions grounded in y
### Cloud-hosted: [sim.ai](https://sim.ai)
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-33c482?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjMzNjNDgyIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+&logoColor=white" alt="Sim.ai"></a>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
### Self-hosted: NPM Package
@@ -74,7 +70,43 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details.
#### Using Local Models with Ollama
Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required:
```bash
# Start with GPU support (automatically downloads gemma3:4b model)
docker compose -f docker-compose.ollama.yml --profile setup up -d
# For CPU-only systems:
docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d
```
Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with:
```bash
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
```
#### Using an External Ollama Instance
If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`:
```bash
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
```
On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file.
#### Using vLLM
Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment.
### Self-hosted: Dev Containers
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open the project and click "Reopen in Container" when prompted
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
- This starts both the main application and the realtime socket server
### Self-hosted: Manual Setup
@@ -127,7 +159,18 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
## Environment Variables
See the [environment variables reference](https://docs.sim.ai/self-hosting/environment-variables) for the full list, or [`apps/sim/.env.example`](apps/sim/.env.example) for defaults.
Key environment variables for self-hosted deployments. See [`.env.example`](apps/sim/.env.example) for defaults or [`env.ts`](apps/sim/lib/core/config/env.ts) for the full list.
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string with pgvector |
| `BETTER_AUTH_SECRET` | Yes | Auth secret (`openssl rand -hex 32`) |
| `BETTER_AUTH_URL` | Yes | Your app URL (e.g., `http://localhost:3000`) |
| `NEXT_PUBLIC_APP_URL` | Yes | Public app URL (same as above) |
| `ENCRYPTION_KEY` | Yes | Encrypts environment variables (`openssl rand -hex 32`) |
| `INTERNAL_API_SECRET` | Yes | Encrypts internal API routes (`openssl rand -hex 32`) |
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
## Tech Stack

View File

@@ -1,7 +1,6 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
@@ -88,8 +87,6 @@ export default function LoginPage({
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
const callbackUrlParam = searchParams?.get('callbackUrl')
@@ -169,20 +166,6 @@ export default function LoginPage({
const safeCallbackUrl = callbackUrl
let errorHandled = false
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
}
}
setFormError(null)
const result = await client.signIn.email(
{
@@ -191,11 +174,6 @@ export default function LoginPage({
callbackURL: safeCallbackUrl,
},
{
fetchOptions: {
headers: {
...(token ? { 'x-captcha-response': token } : {}),
},
},
onError: (ctx) => {
logger.error('Login error:', ctx.error)
@@ -464,16 +442,6 @@ export default function LoginPage({
</div>
</div>
{turnstileSiteKey && (
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
)}
{resetSuccessMessage && (
<div className='text-[#4CAF50] text-xs'>
<p>{resetSuccessMessage}</p>

View File

@@ -93,6 +93,8 @@ function SignupFormContent({
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
const buttonClass = useBrandedButtonClass()
@@ -249,17 +251,30 @@ function SignupFormContent({
const sanitizedName = trimmedName
// Execute Turnstile challenge on submit and get a fresh token
let token: string | undefined
if (turnstileSiteKey && turnstileRef.current) {
const widget = turnstileRef.current
if (turnstileSiteKey && widget) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
turnstileRef.current.reset()
turnstileRef.current.execute()
token = await turnstileRef.current.getResponsePromise(15_000)
widget.reset()
token = await Promise.race([
new Promise<string>((resolve, reject) => {
captchaResolveRef.current = resolve
captchaRejectRef.current = reject
widget.execute()
}),
new Promise<string>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
}),
])
} catch {
setFormError('Captcha verification failed. Please try again.')
setIsLoading(false)
return
} finally {
clearTimeout(timeoutId)
captchaResolveRef.current = null
captchaRejectRef.current = null
}
}
@@ -478,13 +493,14 @@ function SignupFormContent({
</div>
{turnstileSiteKey && (
<div className='absolute'>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ size: 'invisible', execution: 'execute' }}
/>
</div>
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => captchaResolveRef.current?.(token)}
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
options={{ execution: 'execute' }}
/>
)}
{formError && (

View File

@@ -3,7 +3,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { GithubOutlineIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
BlogDropdown,
@@ -40,6 +42,12 @@ interface NavbarProps {
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
const brand = getBrandConfig()
const searchParams = useSearchParams()
const { data: session, isPending: isSessionPending } = useSession()
const isAuthenticated = Boolean(session?.user?.id)
const isBrowsingHome = searchParams.has('home')
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
const [activeDropdown, setActiveDropdown] = useState<DropdownId>(null)
const [hoveredLink, setHoveredLink] = useState<string | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -92,7 +100,7 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
itemScope
itemType='https://schema.org/SiteNavigationElement'
>
<Link href='/' className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<Link href={logoHref} className={LOGO_CELL} aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
{brand.name}
</span>
@@ -121,7 +129,9 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
{!logoOnly && (
<>
<ul className='mt-[0.75px] hidden lg:flex'>
{NAV_LINKS.map(({ label, href, external, icon, dropdown }) => {
{NAV_LINKS.map(({ label, href: rawHref, external, icon, dropdown }) => {
const href =
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
const hasDropdown = !!dropdown
const isActive = hasDropdown && activeDropdown === dropdown
const isThisHovered = hoveredLink === label
@@ -206,21 +216,38 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
<div className='hidden flex-1 lg:block' />
<div className='hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex'>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started
</Link>
<div
className={cn(
'hidden items-center gap-[8px] pr-[80px] pl-[20px] lg:flex',
isSessionPending && 'invisible'
)}
>
{isAuthenticated ? (
<Link
href='/workspace'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Go to app'
>
Go to App
</Link>
) : (
<>
<Link
href='/login'
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#3d3d3d] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] px-[9px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
aria-label='Get started with Sim'
>
Get started
</Link>
</>
)}
</div>
<div className='flex flex-1 items-center justify-end pr-[20px] lg:hidden'>
@@ -242,30 +269,34 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
)}
>
<ul className='flex flex-col'>
{NAV_LINKS.map(({ label, href, external }) => (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
))}
{NAV_LINKS.map(({ label, href: rawHref, external }) => {
const href =
useHomeLinks && rawHref.startsWith('/#') ? `/?home${rawHref.slice(1)}` : rawHref
return (
<li key={label} className='border-[#2A2A2A] border-b'>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='flex items-center justify-between px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
<ExternalArrowIcon />
</a>
) : (
<Link
href={href}
className='flex items-center px-[20px] py-[14px] text-[#ECECEC] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
>
{label}
</Link>
)}
</li>
)
})}
<li className='border-[#2A2A2A] border-b'>
<a
href='https://github.com/simstudioai/sim'
@@ -280,23 +311,41 @@ export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps
</li>
</ul>
<div className='mt-auto flex flex-col gap-[10px] p-[20px]'>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
<div
className={cn(
'mt-auto flex flex-col gap-[10px] p-[20px]',
isSessionPending && 'invisible'
)}
>
{isAuthenticated ? (
<Link
href='/workspace'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Go to app'
>
Go to App
</Link>
) : (
<>
<Link
href='/login'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#3d3d3d] text-[#ECECEC] text-[14px] transition-colors active:bg-[#2A2A2A]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Log in'
>
Log in
</Link>
<Link
href='/signup'
className='flex h-[32px] items-center justify-center rounded-[5px] border border-[#FFFFFF] bg-[#FFFFFF] text-[14px] text-black transition-colors active:bg-[#E0E0E0]'
onClick={() => setMobileMenuOpen(false)}
aria-label='Get started with Sim'
>
Get started
</Link>
</>
)}
</div>
</div>
</>

View File

@@ -60,7 +60,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
@@ -144,7 +143,6 @@ export default async function Page({ params }: { params: Promise<{ slug: string
className='h-[160px] w-full object-cover'
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
loading='lazy'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[#999] text-xs'>

View File

@@ -64,7 +64,6 @@ export default async function AuthorPage({ params }: { params: Promise<{ id: str
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[#999] text-xs'>

View File

@@ -32,7 +32,6 @@ export function PostGrid({ posts }: { posts: Post[] }) {
src={p.ogImage}
alt={p.title}
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
unoptimized
priority={index < 6}
loading={index < 6 ? undefined : 'lazy'}
fill

View File

@@ -5,8 +5,9 @@ import { createLogger } from '@sim/logger'
import { ArrowRight, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { GithubIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandConfig } from '@/ee/whitelabeling'
@@ -26,6 +27,12 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
const searchParams = useSearchParams()
const { data: session, isPending: isSessionPending } = useSession()
const isAuthenticated = Boolean(session?.user?.id)
const isBrowsingHome = searchParams.has('home')
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
useEffect(() => {
if (variant !== 'landing') return
@@ -72,7 +79,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
</li>
<li>
<Link
href='/?from=nav#pricing'
href={useHomeLinks ? '/?home#pricing' : '/#pricing'}
className='text-[16px] text-muted-foreground transition-colors hover:text-foreground'
scroll={true}
>
@@ -124,7 +131,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
itemType='https://schema.org/SiteNavigationElement'
>
<div className='flex items-center gap-[34px]'>
<Link href='/?from=nav' aria-label={`${brand.name} home`} itemProp='url'>
<Link href={logoHref} aria-label={`${brand.name} home`} itemProp='url'>
<span itemProp='name' className='sr-only'>
{brand.name} Home
</span>
@@ -162,45 +169,70 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
{/* Auth Buttons - show only when hosted, regardless of variant */}
{!hideAuthButtons && isHosted && (
<div className='flex items-center justify-center gap-[16px] pt-[1.5px]'>
<button
onClick={handleLoginClick}
onMouseEnter={() => setIsLoginHovered(true)}
onMouseLeave={() => setIsLoginHovered(false)}
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
type='button'
aria-label='Log in to your account'
>
<span className='flex items-center gap-1'>
Log in
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isLoginHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
<div
className={`flex items-center justify-center gap-[16px] pt-[1.5px]${isSessionPending ? ' invisible' : ''}`}
>
{isAuthenticated ? (
<Link
href='/workspace'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Go to app'
>
<span className='flex items-center gap-1'>
Go to App
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</span>
</button>
<Link
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>
<span className='flex items-center gap-1'>
Get started
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Link>
</Link>
) : (
<>
<button
onClick={handleLoginClick}
onMouseEnter={() => setIsLoginHovered(true)}
onMouseLeave={() => setIsLoginHovered(false)}
className='group hidden text-[#2E2E2E] text-[16px] transition-colors hover:text-foreground md:block'
type='button'
aria-label='Log in to your account'
>
<span className='flex items-center gap-1'>
Log in
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isLoginHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</button>
<Link
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>
<span className='flex items-center gap-1'>
Get started
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Link>
</>
)}
</div>
)}
</nav>

View File

@@ -111,7 +111,7 @@ function buildFAQs(integration: Integration): FAQItem[] {
? [
{
question: `How do I trigger a Sim workflow from ${name} automatically?`,
answer: `In your Sim workflow, switch the ${name} block to Trigger mode and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
answer: `Add a ${name} trigger block to your workflow and copy the generated webhook URL. Paste that URL into ${name}'s webhook settings and select the events you want to listen for (${triggers.map((t) => t.name).join(', ')}). From that point on, every matching event in ${name} instantly fires your workflow — no polling, no delay.`,
},
{
question: `What data does Sim receive when a ${name} event triggers a workflow?`,

View File

@@ -21,6 +21,7 @@ export type AppSession = {
id?: string
userId?: string
activeOrganizationId?: string
impersonatedBy?: string | null
}
} | null

View File

@@ -1,236 +0,0 @@
import { db } from '@sim/db'
import { account, agent, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentSlackDeployAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const DeploySlackSchema = z.object({
credentialId: z.string().min(1, 'Credential ID is required'),
channelIds: z.array(z.string().min(1)).default([]),
respondTo: z.enum(['mentions', 'all', 'threads', 'dm']),
botName: z.string().max(80).optional(),
replyInThread: z.boolean().default(true),
})
/**
* POST /api/agents/{agentId}/deployments/slack
* Configure a Slack deployment for an agent.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = DeploySlackSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
if (parsed.data.respondTo !== 'dm' && parsed.data.channelIds.length === 0) {
return NextResponse.json({ error: 'At least one channel is required' }, { status: 400 })
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { credentialId, channelIds, respondTo, botName, replyInThread } = parsed.data
const [credentialRow] = await db
.select({
id: credential.id,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
})
.from(credential)
.where(
and(
eq(credential.id, credentialId),
eq(credential.workspaceId, agentRow.workspaceId),
eq(credential.type, 'oauth'),
eq(credential.providerId, 'slack')
)
)
.limit(1)
if (!credentialRow?.accountId) {
return NextResponse.json({ error: 'Slack credential not found' }, { status: 404 })
}
const [accountRow] = await db
.select({ accessToken: account.accessToken })
.from(account)
.where(eq(account.id, credentialRow.accountId))
.limit(1)
if (!accountRow?.accessToken) {
return NextResponse.json({ error: 'Slack token not available' }, { status: 400 })
}
const authTest = await fetch('https://slack.com/api/auth.test', {
headers: { Authorization: `Bearer ${accountRow.accessToken}` },
})
const authData = await authTest.json()
if (!authData.ok) {
logger.warn(`[${requestId}] Slack auth.test failed`, { error: authData.error })
return NextResponse.json({ error: 'Failed to verify Slack token' }, { status: 400 })
}
const teamId: string = authData.team_id
const botUserId: string = authData.user_id ?? ''
if (!teamId || !botUserId) {
logger.warn(`[${requestId}] Slack auth.test returned incomplete identity`, {
teamId,
botUserId,
})
return NextResponse.json(
{ error: 'Could not determine Slack workspace or bot identity' },
{ status: 400 }
)
}
const existingDeployment = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
.limit(1)
const deploymentConfig: import('@/lib/agents/types').SlackDeploymentConfig = {
teamId,
botUserId,
channelIds,
respondTo,
replyInThread,
...(botName ? { botName } : {}),
}
let deploymentRow: typeof agentDeployment.$inferSelect
if (existingDeployment.length > 0) {
const [updated] = await db
.update(agentDeployment)
.set({
credentialId,
config: deploymentConfig,
isActive: true,
updatedAt: new Date(),
})
.where(eq(agentDeployment.id, existingDeployment[0].id))
.returning()
deploymentRow = updated
} else {
const [inserted] = await db
.insert(agentDeployment)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
credentialId,
config: deploymentConfig,
isActive: true,
})
.returning()
deploymentRow = inserted
}
await db
.update(agent)
.set({ isDeployed: true, deployedAt: new Date(), updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent ${agentId} deployed to Slack`, { teamId, channelIds })
return NextResponse.json({ success: true, data: deploymentRow })
} catch (error) {
logger.error(`[${requestId}] Failed to deploy agent ${agentId} to Slack`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}/deployments/slack
* Remove a Slack deployment from an agent.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [agentRow] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.delete(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.platform, 'slack')))
const remainingDeployments = await db
.select({ id: agentDeployment.id })
.from(agentDeployment)
.where(and(eq(agentDeployment.agentId, agentId), eq(agentDeployment.isActive, true)))
.limit(1)
if (remainingDeployments.length === 0) {
await db
.update(agent)
.set({ isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
}
logger.info(`[${requestId}] Slack deployment removed for agent ${agentId}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to remove Slack deployment for agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,99 +0,0 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentExecuteAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteAgentSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/agents/{agentId}/execute
* Test-execute an agent from the UI. Requires an active session.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = ExecuteAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId ?? `agent:${agentId}:test:${session.user.id}`
logger.info(`[${requestId}] Executing agent ${agentId}`, { userId: session.user.id })
const result = await executeAgent({
config: row.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: row.workspaceId,
userId: session.user.id,
isDeployedContext: false,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] Agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -1,171 +0,0 @@
import { db } from '@sim/db'
import { agent, agentDeployment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentByIdAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const UpdateAgentSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
config: z.record(z.unknown()).optional(),
isDeployed: z.boolean().optional(),
})
/**
* GET /api/agents/{agentId}
* Get a single agent with its deployments.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const deployments = await db
.select()
.from(agentDeployment)
.where(eq(agentDeployment.agentId, agentId))
return NextResponse.json({ success: true, data: { ...row, deployments } })
} catch (error) {
logger.error(`[${requestId}] Failed to get agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PATCH /api/agents/{agentId}
* Update an agent's name, description, or config.
*/
export async function PATCH(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = UpdateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (parsed.data.name !== undefined) updates.name = parsed.data.name
if (parsed.data.description !== undefined) updates.description = parsed.data.description
if (parsed.data.config !== undefined) updates.config = parsed.data.config
if (parsed.data.isDeployed !== undefined) {
updates.isDeployed = parsed.data.isDeployed
if (parsed.data.isDeployed) updates.deployedAt = new Date()
}
const [updated] = await db.update(agent).set(updates).where(eq(agent.id, agentId)).returning()
logger.info(`[${requestId}] Agent updated`, { agentId })
return NextResponse.json({ success: true, data: updated })
} catch (error) {
logger.error(`[${requestId}] Failed to update agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/agents/{agentId}
* Soft-delete an agent and deactivate all its deployments.
*/
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [row] = await db
.select({ id: agent.id, workspaceId: agent.workspaceId })
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt)))
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const access = await checkWorkspaceAccess(row.workspaceId, session.user.id)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db
.update(agentDeployment)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(agentDeployment.agentId, agentId))
await db
.update(agent)
.set({ archivedAt: new Date(), isDeployed: false, updatedAt: new Date() })
.where(eq(agent.id, agentId))
logger.info(`[${requestId}] Agent archived`, { agentId })
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Failed to delete agent ${agentId}`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,128 +0,0 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNotNull, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('AgentsAPI')
export const dynamic = 'force-dynamic'
type AgentQueryScope = 'active' | 'archived' | 'all'
const CreateAgentSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or fewer'),
description: z.string().optional(),
config: z.record(z.unknown()).default({}),
})
/**
* GET /api/agents?workspaceId={id}&scope=active|archived|all
* List agents for a workspace.
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agents list access`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const scopeParam = (searchParams.get('scope') ?? 'active') as AgentQueryScope
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
if (!['active', 'archived', 'all'].includes(scopeParam)) {
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
}
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const conditions = [eq(agent.workspaceId, workspaceId)]
if (scopeParam === 'active') conditions.push(isNull(agent.archivedAt))
if (scopeParam === 'archived') conditions.push(isNotNull(agent.archivedAt))
const agents = await db
.select()
.from(agent)
.where(and(...conditions))
.orderBy(agent.updatedAt)
return NextResponse.json({ success: true, data: agents })
} catch (error) {
logger.error(`[${requestId}] Failed to list agents`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/agents
* Create a new agent.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized agent create attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = CreateAgentSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const { workspaceId, name, description, config } = parsed.data
const access = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!access.exists) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const [created] = await db
.insert(agent)
.values({
id: uuidv4(),
workspaceId,
createdBy: session.user.id,
name,
description,
config,
})
.returning()
logger.info(`[${requestId}] Agent created`, { agentId: created.id, workspaceId })
return NextResponse.json({ success: true, data: created }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Failed to create agent`, { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,489 +0,0 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { account, agent, agentConversation, agentDeployment, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig, SlackDeploymentConfig } from '@/lib/agents/types'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { handleSlackChallenge } from '@/lib/webhooks/utils.server'
const logger = createLogger('AgentSlackWebhook')
export const dynamic = 'force-dynamic'
/** Verify the Slack request signature using HMAC-SHA256. */
async function verifySlackSignature(
signingSecret: string,
rawBody: string,
timestamp: string,
signature: string
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - Number.parseInt(timestamp, 10)) > 300) return false
const baseString = `v0:${timestamp}:${rawBody}`
const hmac = crypto.createHmac('sha256', signingSecret)
hmac.update(baseString)
const computed = `v0=${hmac.digest('hex')}`
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))
}
/**
* Consume a StreamingExecution ReadableStream (SSE-formatted).
* Calls onChunk with (delta, accumulated) on each new token.
* Returns the final complete text.
*/
async function consumeAgentStream(
stream: ReadableStream,
onChunk?: (delta: string, accumulated: string) => void | Promise<void>
): Promise<string> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') return accumulated
try {
const parsed = JSON.parse(data) as { content?: string }
if (parsed.content) {
const delta = parsed.content
accumulated += delta
await onChunk?.(delta, accumulated)
}
} catch {}
}
}
return accumulated
}
/** Call Slack's chat.postMessage. Returns the message ts or null on failure. */
async function postMessage(
botToken: string,
payload: {
channel: string
text: string
thread_ts?: string
username?: string
}
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
logger.warn('chat.postMessage failed', { error: data.error })
return null
}
return data.ts ?? null
}
/** Call Slack's chat.update. */
async function updateMessage(
botToken: string,
channel: string,
ts: string,
text: string,
username?: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.update', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, text, ...(username ? { username } : {}) }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.update failed', { error: data.error })
}
/**
* Start a native Slack stream (Oct 2025 streaming API).
* Returns the stream ts, or null if unsupported (e.g. Enterprise Grid).
*/
async function startStream(
botToken: string,
channel: string,
threadTs: string,
recipientUserId: string,
recipientTeamId: string,
username?: string
): Promise<string | null> {
const res = await fetch('https://slack.com/api/chat.startStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
channel,
thread_ts: threadTs,
recipient_user_id: recipientUserId,
recipient_team_id: recipientTeamId,
...(username ? { username } : {}),
}),
})
const data = (await res.json()) as { ok: boolean; ts?: string; error?: string }
if (!data.ok) {
// enterprise_is_restricted = Enterprise Grid; fall back silently
if (data.error !== 'enterprise_is_restricted') {
logger.warn('chat.startStream failed', { error: data.error })
}
return null
}
return data.ts ?? null
}
/** Append a markdown chunk to a native Slack stream. */
async function appendStream(
botToken: string,
channel: string,
ts: string,
markdownText: string
): Promise<void> {
const res = await fetch('https://slack.com/api/chat.appendStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, markdown_text: markdownText }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.appendStream failed', { error: data.error })
}
/** Finalize a native Slack stream. */
async function stopStream(botToken: string, channel: string, ts: string): Promise<void> {
const res = await fetch('https://slack.com/api/chat.stopStream', {
method: 'POST',
headers: { Authorization: `Bearer ${botToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts }),
})
const data = (await res.json()) as { ok: boolean; error?: string }
if (!data.ok) logger.warn('chat.stopStream failed', { error: data.error })
}
/**
* POST /api/agents/slack/webhook
* Receives Slack event callbacks for all agent deployments.
* Responds 200 immediately and processes events asynchronously.
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const signingSecret = env.SLACK_SIGNING_SECRET
if (!signingSecret) {
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
const rawBody = await request.text()
const timestamp = request.headers.get('x-slack-request-timestamp') ?? ''
const signature = request.headers.get('x-slack-signature') ?? ''
const isValid = await verifySlackSignature(signingSecret, rawBody, timestamp, signature)
if (!isValid) {
logger.warn(`[${requestId}] Invalid Slack signature`)
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let body: Record<string, unknown>
try {
body = JSON.parse(rawBody) as Record<string, unknown>
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const challengeResponse = handleSlackChallenge(body)
if (challengeResponse) return challengeResponse
if (body.type !== 'event_callback') {
return new NextResponse(null, { status: 200 })
}
// Deduplicate: if Slack retried (infrastructure-level duplicate delivery), skip
const retryNum = request.headers.get('x-slack-retry-num')
if (retryNum) {
return new NextResponse(null, { status: 200 })
}
const event = (body.event ?? {}) as Record<string, unknown>
const teamId = String(body.team_id ?? '')
const channel = String(event.channel ?? '')
const eventTs = String(event.ts ?? '')
const threadTs = String(event.thread_ts ?? '')
const text = String(event.text ?? '')
const userId = String(event.user ?? '')
const botId = event.bot_id as string | undefined
const subtype = event.subtype as string | undefined
const eventType = String(event.type ?? '')
// Ignore bot messages (our own replies or other bots)
if (botId || subtype === 'bot_message') {
return new NextResponse(null, { status: 200 })
}
// Respond 200 immediately — Slack never retries a 200, so no header needed
void processSlackEvent({ requestId, teamId, channel, eventTs, threadTs, text, userId, eventType })
return new NextResponse(null, { status: 200 })
}
interface SlackEventContext {
requestId: string
teamId: string
channel: string
eventTs: string
threadTs: string
text: string
userId: string
eventType: string
}
async function processSlackEvent(ctx: SlackEventContext): Promise<void> {
const { requestId, teamId, channel, eventTs, threadTs, text, userId, eventType } = ctx
try {
const deployments = await db
.select({ deployment: agentDeployment, agentRow: agent })
.from(agentDeployment)
.innerJoin(agent, and(eq(agent.id, agentDeployment.agentId), isNull(agent.archivedAt)))
.where(and(eq(agentDeployment.platform, 'slack'), eq(agentDeployment.isActive, true)))
const isDm = channel.startsWith('D')
const match = deployments.find((row) => {
const config = row.deployment.config as SlackDeploymentConfig
if (config.teamId !== teamId) return false
const respondTo = config.respondTo
if (respondTo === 'dm') return isDm
if (!config.channelIds.includes(channel)) return false
if (respondTo === 'mentions' && eventType !== 'app_mention') return false
if (respondTo === 'threads' && !threadTs) return false
return true
})
if (!match) {
logger.info(`[${requestId}] No agent matched for team=${teamId} channel=${channel}`)
return
}
const { deployment, agentRow } = match
const agentId = agentRow.id
const workspaceId = agentRow.workspaceId
const config = deployment.config as SlackDeploymentConfig
let botToken: string | undefined
if (deployment.credentialId) {
const [row] = await db
.select({ accessToken: account.accessToken })
.from(credential)
.innerJoin(account, eq(account.id, credential.accountId))
.where(eq(credential.id, deployment.credentialId))
.limit(1)
botToken = row?.accessToken ?? undefined
}
if (!botToken) {
logger.warn(`[${requestId}] No bot token for agent ${agentId}`)
return
}
// Thread → thread-scoped memory; DM or main channel → channel-scoped memory
const externalId = threadTs ? `${channel}:${threadTs}` : channel
const conversationId = `agent:${agentId}:slack:${externalId}`
await db
.insert(agentConversation)
.values({
id: uuidv4(),
agentId,
platform: 'slack',
externalId,
conversationId,
metadata: { channel, threadTs, teamId },
})
.onConflictDoUpdate({
target: [
agentConversation.agentId,
agentConversation.platform,
agentConversation.externalId,
],
set: { updatedAt: new Date(), metadata: { channel, threadTs, teamId } },
})
// Strip bot mention (use stored botUserId if available, else strip any @mention)
const mentionPattern = config.botUserId
? new RegExp(`<@${config.botUserId}>`, 'g')
: /<@[UW][A-Z0-9]+>/g
const cleanedText = text.replace(mentionPattern, '').trim()
if (!cleanedText) return
const replyInThread = config.replyInThread !== false
const replyThreadTs = replyInThread ? threadTs || eventTs : undefined
const botName = config.botName || undefined
logger.info(`[${requestId}] Executing agent ${agentId} for Slack event`, {
channel,
threadTs,
eventType,
})
const timeout = AbortSignal.timeout(30_000)
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message: cleanedText,
conversationId,
agentId,
workspaceId,
isDeployedContext: true,
abortSignal: timeout,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: ReadableStream }).stream
: null
let responseText: string
if (streamingResult instanceof ReadableStream) {
responseText = await streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream: streamingResult,
requestId,
})
} else {
responseText = String((result as Record<string, unknown>).content ?? '')
if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
}
if (!responseText) {
await postMessage(botToken, {
channel,
text: '_No response._',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
return
}
logger.info(`[${requestId}] Agent ${agentId} responded to Slack event`)
} catch (error) {
logger.error(`[${requestId}] Error processing Slack event`, { error })
}
}
interface StreamResponseParams {
botToken: string
channel: string
replyThreadTs: string | undefined
teamId: string
userId: string
botName: string | undefined
stream: ReadableStream
requestId: string
}
/**
* Deliver a streaming agent response to Slack.
*
* Prefers the native streaming API (chat.startStream / appendStream / stopStream)
* introduced in October 2025, which renders a real-time typewriter effect in
* Slack clients and uses the higher Tier 4 rate limit (100+/min).
*
* Falls back to chat.postMessage + throttled chat.update (~1/sec) when:
* - The workspace is on Enterprise Grid (startStream returns enterprise_is_restricted)
* - No thread context is available (startStream requires thread_ts)
*/
async function streamResponse({
botToken,
channel,
replyThreadTs,
teamId,
userId,
botName,
stream,
requestId,
}: StreamResponseParams): Promise<string> {
// Native streaming API requires a thread context
if (replyThreadTs) {
const streamTs = await startStream(botToken, channel, replyThreadTs, userId, teamId, botName)
if (streamTs) {
// Native streaming path — Tier 4 (100+/min), flush every ~600ms
let pendingDelta = ''
let lastFlushTime = 0
const responseText = await consumeAgentStream(stream, async (delta) => {
pendingDelta += delta
const now = Date.now()
if (now - lastFlushTime >= 600 && pendingDelta) {
lastFlushTime = now
const toSend = pendingDelta
pendingDelta = ''
await appendStream(botToken, channel, streamTs, toSend)
}
})
// Flush remaining buffer
if (pendingDelta) {
await appendStream(botToken, channel, streamTs, pendingDelta)
}
await stopStream(botToken, channel, streamTs)
logger.info(`[${requestId}] Streamed via native streaming API`)
return responseText
}
}
// Fallback: post placeholder → throttled chat.update (~1/sec, Tier 3)
const placeholderTs = await postMessage(botToken, {
channel,
text: '_Thinking…_',
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
let lastUpdateTime = 0
const responseText = await consumeAgentStream(stream, async (_, accumulated) => {
const now = Date.now()
if (placeholderTs && now - lastUpdateTime >= 1200) {
lastUpdateTime = now
void updateMessage(botToken, channel, placeholderTs, `${accumulated}`, botName)
}
})
if (placeholderTs) {
await updateMessage(botToken, channel, placeholderTs, responseText || '_No response._', botName)
} else if (responseText) {
await postMessage(botToken, {
channel,
text: responseText,
thread_ts: replyThreadTs,
...(botName ? { username: botName } : {}),
})
}
logger.info(`[${requestId}] Streamed via chat.update fallback`)
return responseText
}

View File

@@ -1,6 +1,11 @@
import { NextResponse } from 'next/server'
import { abortActiveStream } from '@/lib/copilot/chat-streaming'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
import { env } from '@/lib/core/config/env'
const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000
export async function POST(request: Request) {
const { userId: authenticatedUserId, isAuthenticated } =
@@ -12,11 +17,48 @@ export async function POST(request: Request) {
const body = await request.json().catch(() => ({}))
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
let chatId = typeof body.chatId === 'string' ? body.chatId : ''
if (!streamId) {
return NextResponse.json({ error: 'streamId is required' }, { status: 400 })
}
const aborted = abortActiveStream(streamId)
if (!chatId) {
const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null)
if (run?.chatId) {
chatId = run.chatId
}
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
headers['x-api-key'] = env.COPILOT_API_KEY
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), GO_EXPLICIT_ABORT_TIMEOUT_MS)
const response = await fetch(`${SIM_AGENT_API_URL}/api/streams/explicit-abort`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
messageId: streamId,
userId: authenticatedUserId,
...(chatId ? { chatId } : {}),
}),
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
throw new Error(`Explicit abort marker request failed: ${response.status}`)
}
} catch {
// best effort: local abort should still proceed even if Go marker fails
}
const aborted = await abortActiveStream(streamId)
if (chatId) {
await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch(
() => false
)
}
return NextResponse.json({ aborted })
}

View File

@@ -8,7 +8,9 @@ import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
releasePendingChatStream,
requestChatTitle,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/chat-streaming'
@@ -16,6 +18,7 @@ import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { resolveActiveResourceContext } from '@/lib/copilot/process-contents'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -44,6 +47,13 @@ const FileAttachmentSchema = z.object({
size: z.number(),
})
const ResourceAttachmentSchema = z.object({
type: z.enum(['workflow', 'table', 'file', 'knowledgebase']),
id: z.string().min(1),
title: z.string().optional(),
active: z.boolean().optional(),
})
const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(),
@@ -58,6 +68,7 @@ const ChatMessageSchema = z.object({
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
resourceAttachments: z.array(ResourceAttachmentSchema).optional(),
provider: z.string().optional(),
contexts: z
.array(
@@ -98,6 +109,10 @@ const ChatMessageSchema = z.object({
*/
export async function POST(req: NextRequest) {
const tracker = createRequestTracker()
let actualChatId: string | undefined
let pendingChatStreamAcquired = false
let pendingChatStreamHandedOff = false
let pendingChatStreamID: string | undefined
try {
// Get session to access user information including name
@@ -124,6 +139,7 @@ export async function POST(req: NextRequest) {
stream,
implicitFeedback,
fileAttachments,
resourceAttachments,
provider,
contexts,
commands,
@@ -189,7 +205,7 @@ export async function POST(req: NextRequest) {
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
actualChatId = chatId
const selectedModel = model || 'claude-opus-4-6'
if (chatId || createNewChat) {
@@ -241,6 +257,39 @@ export async function POST(req: NextRequest) {
}
}
if (
Array.isArray(resourceAttachments) &&
resourceAttachments.length > 0 &&
resolvedWorkspaceId
) {
const results = await Promise.allSettled(
resourceAttachments.map(async (r) => {
const ctx = await resolveActiveResourceContext(
r.type,
r.id,
resolvedWorkspaceId!,
authenticatedUserId,
actualChatId
)
if (!ctx) return null
return {
...ctx,
tag: r.active ? '@active_tab' : '@open_tab',
}
})
)
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
agentContexts.push(result.value)
} else if (result.status === 'rejected') {
logger.error(
`[${tracker.requestId}] Failed to resolve resource attachment`,
result.reason
)
}
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const userPermission = resolvedWorkspaceId
@@ -291,6 +340,21 @@ export async function POST(req: NextRequest) {
})
} catch {}
if (stream && actualChatId) {
const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
pendingChatStreamAcquired = true
pendingChatStreamID = userMessageIdToUse
}
if (actualChatId) {
const userMsg = {
id: userMessageIdToUse,
@@ -337,6 +401,7 @@ export async function POST(req: NextRequest) {
titleProvider: provider,
requestId: tracker.requestId,
workspaceId: resolvedWorkspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream),
orchestrateOptions: {
userId: authenticatedUserId,
workflowId,
@@ -348,6 +413,7 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),
@@ -423,6 +489,7 @@ export async function POST(req: NextRequest) {
},
},
})
pendingChatStreamHandedOff = true
return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS })
}
@@ -528,6 +595,14 @@ export async function POST(req: NextRequest) {
},
})
} catch (error) {
if (
actualChatId &&
pendingChatStreamAcquired &&
!pendingChatStreamHandedOff &&
pendingChatStreamID
) {
await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {})
}
const duration = tracker.getDuration()
if (error instanceof z.ZodError) {

View File

@@ -8,9 +8,9 @@ import { getSession } from '@/lib/auth'
import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload'
import {
acquirePendingChatStream,
createSSEStream,
SSE_RESPONSE_HEADERS,
waitForPendingChatStream,
} from '@/lib/copilot/chat-streaming'
import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types'
import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents'
@@ -253,7 +253,16 @@ export async function POST(req: NextRequest) {
)
if (actualChatId) {
await waitForPendingChatStream(actualChatId)
const acquired = await acquirePendingChatStream(actualChatId, userMessageId)
if (!acquired) {
return NextResponse.json(
{
error:
'A response is already in progress for this chat. Wait for it to finish or use Stop.',
},
{ status: 409 }
)
}
}
const executionId = crypto.randomUUID()
@@ -271,6 +280,7 @@ export async function POST(req: NextRequest) {
titleModel: 'claude-opus-4-6',
requestId: tracker.requestId,
workspaceId,
pendingChatStreamAlreadyRegistered: Boolean(actualChatId),
orchestrateOptions: {
userId: authenticatedUserId,
workspaceId,
@@ -282,6 +292,7 @@ export async function POST(req: NextRequest) {
interactive: true,
onComplete: async (result: OrchestratorResult) => {
if (!actualChatId) return
if (!result.success) return
const assistantMessage: Record<string, unknown> = {
id: crypto.randomUUID(),

View File

@@ -1,110 +0,0 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('SkillsImportAPI')
const FETCH_TIMEOUT_MS = 15_000
const ImportSchema = z.object({
url: z.string().url('A valid URL is required'),
})
/**
* Converts a standard GitHub file URL to its raw.githubusercontent.com equivalent.
*
* Supported formats:
* github.com/{owner}/{repo}/blob/{branch}/{path}
* raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} (passthrough)
*/
function toRawGitHubUrl(url: string): string {
const parsed = new URL(url)
if (parsed.hostname === 'raw.githubusercontent.com') {
return url
}
if (parsed.hostname !== 'github.com') {
throw new Error('Only GitHub URLs are supported')
}
// /owner/repo/blob/branch/path...
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 5 || segments[2] !== 'blob') {
throw new Error(
'Invalid GitHub URL format. Expected: https://github.com/{owner}/{repo}/blob/{branch}/{path}'
)
}
const [owner, repo, , branch, ...pathParts] = segments
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathParts.join('/')}`
}
/** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */
export async function POST(req: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized skill import attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { url } = ImportSchema.parse(body)
let rawUrl: string
try {
rawUrl = toRawGitHubUrl(url)
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid URL'
return NextResponse.json({ error: message }, { status: 400 })
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(rawUrl, {
signal: controller.signal,
headers: { Accept: 'text/plain' },
})
if (!response.ok) {
logger.warn(`[${requestId}] GitHub fetch failed`, {
status: response.status,
url: rawUrl,
})
return NextResponse.json(
{ error: `Failed to fetch file (HTTP ${response.status}). Is the repository public?` },
{ status: 502 }
)
}
const content = await response.text()
if (content.length > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
return NextResponse.json({ content })
} finally {
clearTimeout(timeout)
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 })
}
if (error instanceof Error && error.name === 'AbortError') {
logger.warn(`[${requestId}] GitHub fetch timed out`)
return NextResponse.json({ error: 'Request timed out' }, { status: 504 })
}
logger.error(`[${requestId}] Error importing skill`, error)
return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 })
}
}

View File

@@ -13,6 +13,7 @@ const MetadataSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
metadata: z.object({
columnWidths: z.record(z.number().positive()).optional(),
columnOrder: z.array(z.string()).optional(),
}),
})

View File

@@ -20,11 +20,15 @@
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
*/
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
@@ -38,6 +42,17 @@ const logger = createLogger('AdminPromoCodes')
const VALID_DURATIONS = ['once', 'repeating', 'forever'] as const
type Duration = (typeof VALID_DURATIONS)[number]
/** Broad categories match all tiers; specific plan names match exactly. */
const VALID_APPLIES_TO = [
'pro',
'team',
'pro_6000',
'pro_25000',
'team_6000',
'team_25000',
] as const
type AppliesTo = (typeof VALID_APPLIES_TO)[number]
interface PromoCodeResponse {
id: string
code: string
@@ -46,6 +61,7 @@ interface PromoCodeResponse {
percentOff: number
duration: string
durationInMonths: number | null
appliesToProductIds: string[] | null
maxRedemptions: number | null
expiresAt: string | null
active: boolean
@@ -62,6 +78,7 @@ function formatPromoCode(promo: {
percent_off: number | null
duration: string
duration_in_months: number | null
applies_to?: { products: string[] }
}
max_redemptions: number | null
expires_at: number | null
@@ -77,6 +94,7 @@ function formatPromoCode(promo: {
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
@@ -85,6 +103,54 @@ function formatPromoCode(promo: {
}
}
/**
* Resolve appliesTo values to unique Stripe product IDs.
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
* Specific plan names ('pro_6000', 'team_25000') match exactly.
*/
async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise<string[]> {
const plans = getPlans()
const priceIds: string[] = []
const broadMatchers: Record<string, (name: string) => boolean> = {
pro: isPro,
team: isTeam,
}
for (const plan of plans) {
const matches = targets.some((target) => {
const matcher = broadMatchers[target]
return matcher ? matcher(plan.name) : plan.name === target
})
if (!matches) continue
if (plan.priceId) priceIds.push(plan.priceId)
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
}
const results = await Promise.allSettled(
priceIds.map(async (priceId) => {
const price = await stripe.prices.retrieve(priceId)
return typeof price.product === 'string' ? price.product : price.product.id
})
)
const failures = results.filter((r) => r.status === 'rejected')
if (failures.length > 0) {
logger.error('Failed to resolve all Stripe products for appliesTo', {
failed: failures.length,
total: priceIds.length,
})
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
}
const productIds = new Set<string>()
for (const r of results) {
if (r.status === 'fulfilled') productIds.add(r.value)
}
return [...productIds]
}
export const GET = withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
@@ -125,7 +191,16 @@ export const POST = withAdminAuth(async (request) => {
const stripe = requireStripeClient()
const body = await request.json()
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } = body
const {
name,
percentOff,
code,
duration,
durationInMonths,
maxRedemptions,
expiresAt,
appliesTo,
} = body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return badRequestResponse('name is required and must be a non-empty string')
@@ -186,11 +261,36 @@ export const POST = withAdminAuth(async (request) => {
}
}
if (appliesTo !== undefined && appliesTo !== null) {
if (!Array.isArray(appliesTo) || appliesTo.length === 0) {
return badRequestResponse('appliesTo must be a non-empty array')
}
const invalid = appliesTo.filter(
(v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo)
)
if (invalid.length > 0) {
return badRequestResponse(
`appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}`
)
}
}
let appliesToProducts: string[] | undefined
if (appliesTo?.length) {
appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[])
if (appliesToProducts.length === 0) {
return badRequestResponse(
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
)
}
}
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
})
let promoCode
@@ -224,6 +324,7 @@ export const POST = withAdminAuth(async (request) => {
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
...(appliesTo ? { appliesTo } : {}),
})
return singleResponse(formatPromoCode(promoCode))

View File

@@ -1,102 +0,0 @@
import { db } from '@sim/db'
import { agent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { executeAgent } from '@/lib/agents/execute'
import type { AgentConfig } from '@/lib/agents/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AgentAPI')
export const dynamic = 'force-dynamic'
interface RouteParams {
agentId: string
}
const ExecuteSchema = z.object({
message: z.string().min(1, 'Message is required'),
conversationId: z.string().optional(),
})
/**
* POST /api/v1/agents/{agentId}
* Execute an agent via the public API. Requires a workspace or personal API key.
*/
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const requestId = generateRequestId()
const { agentId } = await params
try {
const rateLimit = await checkRateLimit(request, 'agent-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const body = await request.json()
const parsed = ExecuteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request' },
{ status: 400 }
)
}
const [agentRow] = await db
.select()
.from(agent)
.where(and(eq(agent.id, agentId), isNull(agent.archivedAt), eq(agent.isDeployed, true)))
.limit(1)
if (!agentRow) {
return NextResponse.json({ error: 'Agent not found or not deployed' }, { status: 404 })
}
const access = await checkWorkspaceAccess(agentRow.workspaceId, userId)
if (!access.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { message, conversationId } = parsed.data
const memoryConversationId = conversationId
? `agent:${agentId}:api:${conversationId}`
: undefined
logger.info(`[${requestId}] V1 API executing agent ${agentId}`, { userId })
const result = await executeAgent({
config: agentRow.config as AgentConfig,
message,
conversationId: memoryConversationId,
agentId,
workspaceId: agentRow.workspaceId,
userId,
isDeployedContext: true,
})
const streamingResult =
result && typeof result === 'object' && 'stream' in result
? (result as { stream: unknown }).stream
: null
if (streamingResult instanceof ReadableStream) {
return new NextResponse(streamingResult, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
return NextResponse.json({ success: true, data: result as Record<string, unknown> })
} catch (error) {
logger.error(`[${requestId}] V1 agent execution failed for ${agentId}`, { error })
return NextResponse.json({ error: 'Execution failed' }, { status: 500 })
}
}

View File

@@ -37,9 +37,7 @@ export async function checkRateLimit(
| 'file-detail'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search'
| 'agents'
| 'agent-detail' = 'logs'
| 'knowledge-search' = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)

View File

@@ -3,9 +3,17 @@
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Mic, MicOff, Phone } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
const ParticlesVisualization = dynamic(
() =>
import('@/app/chat/components/voice-interface/components/particles').then(
(mod) => mod.ParticlesVisualization
),
{ ssr: false }
)
const logger = createLogger('VoiceInterface')

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Star, User } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
@@ -281,9 +282,14 @@ function TemplateCardInner({
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
<Image
src={authorImageUrl}
alt={author}
width={20}
height={20}
className='flex-shrink-0 rounded-full object-cover'
unoptimized
/>
) : (
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
<User className='h-[12px] w-[12px] text-[var(--text-muted)]' />

View File

@@ -1,226 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { Pencil, Trash } from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
import type { AgentConfig } from '@/lib/agents/types'
import { AgentConfigPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-config-panel'
import { AgentTestPanel } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-test-panel'
import { DeployModal } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/deploy-modal'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components'
import { useAgent, useDeleteAgent, useUpdateAgent } from '@/hooks/queries/agents'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('AgentDetail')
const AUTOSAVE_DELAY_MS = 1000
interface AgentDetailProps {
agentId: string
workspaceId: string
}
export function AgentDetail({ agentId, workspaceId }: AgentDetailProps) {
const router = useRouter()
const { data: agent, isLoading } = useAgent(agentId)
const { mutateAsync: updateAgent } = useUpdateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const [localConfig, setLocalConfig] = useState<AgentConfig>({})
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
const [isDeleting, setIsDeleting] = useState(false)
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isInitializedRef = useRef(false)
// SubBlockStore.setValue reads activeWorkflowId from the workflow registry and no-ops when null.
// Set the agentId as the active "workflow" so tool sub-block params (credentials, etc.) persist.
useEffect(() => {
useWorkflowRegistry.setState({ activeWorkflowId: agentId })
return () => {
if (useWorkflowRegistry.getState().activeWorkflowId === agentId) {
useWorkflowRegistry.setState({ activeWorkflowId: null })
}
}
}, [agentId])
useEffect(() => {
if (agent && !isInitializedRef.current) {
setLocalConfig(agent.config ?? {})
isInitializedRef.current = true
}
}, [agent])
const agentRename = useInlineRename({
onSave: (id, name) => updateAgent({ agentId: id, name }),
})
const scheduleSave = useCallback(
(updatedConfig: AgentConfig) => {
setSaveStatus('unsaved')
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
saveTimerRef.current = null
setSaveStatus('saving')
try {
await updateAgent({ agentId, config: updatedConfig })
setSaveStatus('saved')
} catch (error) {
logger.error('Failed to auto-save agent', { error })
setSaveStatus('unsaved')
}
}, AUTOSAVE_DELAY_MS)
},
[agentId, updateAgent]
)
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}
}, [])
const handleConfigChange = useCallback(
(patch: Partial<AgentConfig>) => {
setLocalConfig((prev) => {
const next = { ...prev, ...patch }
scheduleSave(next)
return next
})
},
[scheduleSave]
)
const handleDelete = useCallback(async () => {
setIsDeleting(true)
try {
await deleteAgent({ agentId })
router.push(`/workspace/${workspaceId}/agents`)
} catch (error) {
logger.error('Failed to delete agent', { error })
setIsDeleting(false)
}
}, [agentId, deleteAgent, router, workspaceId])
const currentName = agent?.name ?? ''
const breadcrumbs = useMemo<BreadcrumbItem[]>(
() => [
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{
label: currentName || '…',
editing:
agentRename.editingId === agentId
? {
isEditing: true,
value: agentRename.editValue,
onChange: agentRename.setEditValue,
onSubmit: agentRename.submitRename,
onCancel: agentRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
onClick: () => agentRename.startRename(agentId, currentName),
},
{
label: 'Delete',
icon: Trash,
onClick: handleDelete,
disabled: isDeleting,
},
],
},
],
[
agentId,
currentName,
agentRename.editingId,
agentRename.editValue,
agentRename.setEditValue,
agentRename.submitRename,
agentRename.cancelRename,
agentRename.startRename,
handleDelete,
isDeleting,
router,
workspaceId,
]
)
const saveStatusLabel =
saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : undefined
if (isLoading) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader icon={AgentIcon} breadcrumbs={[{ label: 'Agents' }, { label: '…' }]} />
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Loading</span>
</div>
</div>
)
}
if (!agent) {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={[
{ label: 'Agents', onClick: () => router.push(`/workspace/${workspaceId}/agents`) },
{ label: 'Not found' },
]}
/>
<div className='flex flex-1 items-center justify-center'>
<span className='text-[14px] text-[var(--text-muted)]'>Agent not found.</span>
</div>
</div>
)
}
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--surface-1)]'>
<ResourceHeader
icon={AgentIcon}
breadcrumbs={breadcrumbs}
actions={[
...(saveStatusLabel
? [{ label: saveStatusLabel, onClick: () => {}, disabled: true }]
: []),
{ label: 'Deploy', onClick: () => setIsDeployModalOpen(true) },
]}
/>
<div className='flex min-h-0 flex-1 overflow-hidden'>
<div className='flex w-[400px] min-w-[320px] flex-shrink-0 flex-col overflow-y-auto border-[var(--border-1)] border-r'>
<AgentConfigPanel
config={localConfig}
agentId={agentId}
workspaceId={workspaceId}
onConfigChange={handleConfigChange}
/>
</div>
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<AgentTestPanel agentId={agentId} />
</div>
</div>
<DeployModal
agentId={agentId}
workspaceId={workspaceId}
isDeployed={agent.isDeployed}
open={isDeployModalOpen}
onOpenChange={setIsDeployModalOpen}
/>
</div>
)
}

View File

@@ -1,789 +0,0 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
Button,
Combobox,
type ComboboxOption,
type ComboboxOptionGroup,
Input,
Label,
Textarea,
} from '@/components/emcn'
import { ArrowUp, ChevronDown, Plus, X } from '@/components/emcn/icons'
import { AgentSkillsIcon } from '@/components/icons'
import type { AgentConfig, SkillInput } from '@/lib/agents/types'
import { getScopesForService } from '@/lib/oauth/utils'
import { AgentToolInput } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-config/agent-tool-input'
import {
type AgentWandState,
useAgentWand,
} from '@/app/workspace/[workspaceId]/agents/[agentId]/hooks/use-agent-wand'
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector'
import {
getModelOptions,
RESPONSE_FORMAT_WAND_CONFIG,
shouldRequireApiKeyForModel,
} from '@/blocks/utils'
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
import {
getMaxTemperature,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
MODELS_WITH_DEEP_RESEARCH,
MODELS_WITH_REASONING_EFFORT,
MODELS_WITH_THINKING,
MODELS_WITH_VERBOSITY,
MODELS_WITHOUT_MEMORY,
providers,
supportsTemperature,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
const MEMORY_OPTIONS: ComboboxOption[] = [
{ value: 'none', label: 'None' },
{ value: 'conversation', label: 'Conversation history' },
{ value: 'sliding_window', label: 'Sliding window (messages)' },
{ value: 'sliding_window_tokens', label: 'Sliding window (tokens)' },
]
const AZURE_MODELS = [...providers['azure-openai'].models, ...providers['azure-anthropic'].models]
const DASHED_DIVIDER_STYLE = {
backgroundImage:
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
} as const
interface AgentConfigPanelProps {
config: AgentConfig
agentId: string
workspaceId: string
onConfigChange: (patch: Partial<AgentConfig>) => void
}
const SYSTEM_PROMPT_WAND_PROMPT = `You are an expert at creating professional, comprehensive LLM agent system prompts. Generate or modify a system prompt based on the user's request.
Current system prompt: {context}
RULES:
1. Generate ONLY the system prompt text — no JSON, no explanations, no markdown fences
2. Start with a clear role definition ("You are...")
3. Include specific methodology, response format requirements, and edge case handling
4. If editing, preserve structure unless asked to change it
5. Be detailed and professional`
function Divider() {
return (
<div className='px-[2px] pt-[16px] pb-[13px]'>
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
</div>
)
}
function FieldLabelRow({ label }: { label: string }) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
</div>
)
}
export function AgentConfigPanel({
config,
agentId,
workspaceId,
onConfigChange,
}: AgentConfigPanelProps) {
const storeProviders = useProvidersStore((s) => s.providers)
const model = config.model ?? ''
const [showAdvanced, setShowAdvanced] = useState(false)
const derived = useMemo(() => {
const reasoningValues = model ? getReasoningEffortValuesForModel(model) : null
const thinkingValues = model ? getThinkingLevelsForModel(model) : null
const verbosityValues = model ? getVerbosityValuesForModel(model) : null
const toOptions = (vals: string[]): ComboboxOption[] =>
vals.map((v) => ({ value: v, label: v }))
const isDeepResearch = MODELS_WITH_DEEP_RESEARCH.includes(model)
const showTemperature = Boolean(model) && supportsTemperature(model) && !isDeepResearch
return {
isVertexModel: providers.vertex.models.includes(model),
isAzureModel: AZURE_MODELS.includes(model),
isBedrockModel: providers.bedrock.models.includes(model),
showApiKey: shouldRequireApiKeyForModel(model),
isDeepResearch,
showMemory: !MODELS_WITHOUT_MEMORY.includes(model),
showReasoningEffort: MODELS_WITH_REASONING_EFFORT.includes(model),
showThinking: MODELS_WITH_THINKING.includes(model),
showVerbosity: MODELS_WITH_VERBOSITY.includes(model),
showTemperature,
maxTemperature: (model && getMaxTemperature(model)) ?? 1,
reasoningEffortOptions: toOptions(reasoningValues ?? ['auto', 'low', 'medium', 'high']),
thinkingLevelOptions: toOptions(
thinkingValues ? ['none', ...thinkingValues] : ['none', 'low', 'high']
),
verbosityOptions: toOptions(verbosityValues ?? ['auto', 'low', 'medium', 'high']),
}
}, [model])
const hasAdvancedFields =
derived.showReasoningEffort ||
derived.showThinking ||
derived.showVerbosity ||
derived.showTemperature ||
!derived.isDeepResearch
const modelOptions: ComboboxOption[] = useMemo(
() =>
getModelOptions().map((opt) => ({
label: opt.label,
value: opt.id,
...(opt.icon && { icon: opt.icon }),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[storeProviders]
)
const systemPrompt = config.messages?.find((m) => m.role === 'system')?.content ?? ''
const handleSystemPromptChange = useCallback(
(value: string) => {
const filtered = (config.messages ?? []).filter((m) => m.role !== 'system')
const messages = value.trim()
? [{ role: 'system' as const, content: value }, ...filtered]
: filtered
onConfigChange({ messages })
},
[config.messages, onConfigChange]
)
const systemPromptWand = useAgentWand({
systemPrompt: SYSTEM_PROMPT_WAND_PROMPT,
maintainHistory: true,
currentValue: systemPrompt,
onGeneratedContent: handleSystemPromptChange,
})
const responseFormatWand = useAgentWand({
systemPrompt: RESPONSE_FORMAT_WAND_CONFIG.prompt,
generationType: RESPONSE_FORMAT_WAND_CONFIG.generationType,
maintainHistory: RESPONSE_FORMAT_WAND_CONFIG.maintainHistory,
currentValue: config.responseFormat ?? '',
onGeneratedContent: (content) => onConfigChange({ responseFormat: content }),
})
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [apiKeyEnvDropdown, setApiKeyEnvDropdown] = useState({ visible: false, searchTerm: '' })
const apiKeyInputRef = useRef<HTMLInputElement>(null)
return (
<div className='px-[8px] pt-[12px] pb-[8px]'>
{/* System prompt */}
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='System prompt' wand={systemPromptWand} />
<Textarea
placeholder='You are a helpful assistant…'
value={systemPrompt}
onChange={(e) => handleSystemPromptChange(e.target.value)}
rows={6}
className='resize-none text-[13px]'
/>
</div>
<Divider />
{/* Model */}
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Model' />
<Combobox
options={modelOptions}
value={config.model ?? ''}
onChange={(v) => onConfigChange({ model: v })}
placeholder='claude-sonnet-4-6'
editable
searchable
searchPlaceholder='Search models…'
emptyMessage='No models found'
maxHeight={240}
inputProps={{ autoComplete: 'off' }}
/>
</div>
<Divider />
{/* Vertex AI */}
{derived.isVertexModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud account' />
<ToolCredentialSelector
value={config.vertexCredential ?? ''}
onChange={(v) => onConfigChange({ vertexCredential: v || undefined })}
provider='vertex-ai'
serviceId='vertex-ai'
requiredScopes={getScopesForService('vertex-ai')}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud project' />
<Input
value={config.vertexProject ?? ''}
placeholder='your-gcp-project-id'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexProject: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Google Cloud location' />
<Input
value={config.vertexLocation ?? ''}
placeholder='us-central1'
autoComplete='off'
onChange={(e) => onConfigChange({ vertexLocation: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* Azure */}
{derived.isAzureModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure endpoint' />
<Input
value={config.azureEndpoint ?? ''}
placeholder='https://your-resource.services.ai.azure.com'
autoComplete='off'
onChange={(e) => onConfigChange({ azureEndpoint: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Azure API version' />
<Input
value={config.azureApiVersion ?? ''}
placeholder='2024-12-01-preview'
autoComplete='off'
onChange={(e) => onConfigChange({ azureApiVersion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* AWS Bedrock */}
{derived.isBedrockModel && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS access key ID' />
<Input
value={config.bedrockAccessKeyId ?? ''}
placeholder='Enter your AWS Access Key ID'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockAccessKeyId: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS secret access key' />
<Input
value={config.bedrockSecretKey ?? ''}
placeholder='Enter your AWS Secret Access Key'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockSecretKey: e.target.value || undefined })}
/>
</div>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='AWS region' />
<Input
value={config.bedrockRegion ?? ''}
placeholder='us-east-1'
autoComplete='off'
onChange={(e) => onConfigChange({ bedrockRegion: e.target.value || undefined })}
/>
</div>
<Divider />
</>
)}
{/* API key */}
{derived.showApiKey && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='API key' />
<div className='relative'>
<Input
ref={apiKeyInputRef}
type='text'
value={
apiKeyFocused ? (config.apiKey ?? '') : '•'.repeat(config.apiKey?.length ?? 0)
}
placeholder='Enter your API key'
autoComplete='off'
data-lpignore='true'
data-form-type='other'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
onChange={(e) => {
const val = e.target.value
onConfigChange({ apiKey: val || undefined })
const cursor = e.target.selectionStart ?? val.length
const { show, searchTerm } = checkEnvVarTrigger(val, cursor)
setApiKeyEnvDropdown({ visible: show, searchTerm })
}}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto px-[8px] py-[6px] pr-3 font-medium font-sans text-foreground text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
<div className='min-w-fit whitespace-pre'>
{apiKeyFocused
? formatDisplayText(config.apiKey ?? '')
: '•'.repeat(config.apiKey?.length ?? 0)}
</div>
</div>
<EnvVarDropdown
visible={apiKeyEnvDropdown.visible}
searchTerm={apiKeyEnvDropdown.searchTerm}
inputValue={config.apiKey ?? ''}
cursorPosition={
apiKeyInputRef.current?.selectionStart ?? config.apiKey?.length ?? 0
}
workspaceId={workspaceId}
inputRef={apiKeyInputRef as React.RefObject<HTMLInputElement>}
onSelect={(newValue) => {
onConfigChange({ apiKey: newValue || undefined })
setApiKeyEnvDropdown({ visible: false, searchTerm: '' })
}}
onClose={() => setApiKeyEnvDropdown({ visible: false, searchTerm: '' })}
/>
</div>
</div>
<Divider />
</>
)}
{/* Tools */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Tools' />
<AgentToolInput
workspaceId={workspaceId}
selectedTools={config.tools ?? []}
model={config.model}
onChange={(tools) => onConfigChange({ tools })}
/>
</div>
<Divider />
</>
)}
{/* Skills */}
{!derived.isDeepResearch && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Skills' />
<SkillsInput
workspaceId={workspaceId}
selectedSkills={config.skills ?? []}
onChange={(skills) => onConfigChange({ skills })}
/>
</div>
<Divider />
</>
)}
{/* Memory */}
{derived.showMemory && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Memory' />
<Combobox
options={MEMORY_OPTIONS}
value={config.memoryType ?? 'none'}
onChange={(v) => onConfigChange({ memoryType: v as AgentConfig['memoryType'] })}
/>
</div>
{config.memoryType && config.memoryType !== 'none' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Conversation ID' />
<Input
value={config.conversationId ?? ''}
placeholder='e.g., user-123, session-abc'
autoComplete='off'
onChange={(e) => onConfigChange({ conversationId: e.target.value || undefined })}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (messages)' />
<Input
value={config.slidingWindowSize ?? ''}
placeholder='20'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowSize: e.target.value || undefined })
}
/>
</div>
</>
)}
{config.memoryType === 'sliding_window_tokens' && (
<>
<Divider />
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Window size (tokens)' />
<Input
value={config.slidingWindowTokens ?? ''}
placeholder='4000'
autoComplete='off'
onChange={(e) =>
onConfigChange({ slidingWindowTokens: e.target.value || undefined })
}
/>
</div>
</>
)}
<Divider />
</>
)}
{/* Response format */}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<WandLabelRow label='Response format' wand={responseFormatWand} />
<Textarea
placeholder={
'{\n "name": "my_schema",\n "strict": true,\n "schema": { "type": "object", "properties": {} }\n}'
}
value={config.responseFormat ?? ''}
onChange={(e) => onConfigChange({ responseFormat: e.target.value || undefined })}
rows={4}
className='resize-none font-mono text-[12px]'
/>
</div>
)}
{/* Previous interaction ID (deep research) */}
{derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Previous interaction ID' />
<Input
value={config.previousInteractionId ?? ''}
placeholder='e.g., {{agent_1.interactionId}}'
autoComplete='off'
onChange={(e) => onConfigChange({ previousInteractionId: e.target.value || undefined })}
/>
</div>
)}
{/* Advanced fields toggle */}
{hasAdvancedFields && (
<>
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<button
type='button'
onClick={() => setShowAdvanced((v) => !v)}
className='flex items-center gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
{showAdvanced ? 'Hide additional fields' : 'Show additional fields'}
<ChevronDown
className={`h-[14px] w-[14px] transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
/>
</button>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
{showAdvanced && (
<>
{derived.showReasoningEffort && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Reasoning effort' />
<Combobox
options={derived.reasoningEffortOptions}
value={config.reasoningEffort ?? 'auto'}
onChange={(v) => onConfigChange({ reasoningEffort: v })}
/>
</div>
<Divider />
</>
)}
{derived.showThinking && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Thinking level' />
<Combobox
options={derived.thinkingLevelOptions}
value={config.thinkingLevel ?? 'none'}
onChange={(v) => onConfigChange({ thinkingLevel: v })}
/>
</div>
<Divider />
</>
)}
{derived.showVerbosity && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Verbosity' />
<Combobox
options={derived.verbosityOptions}
value={config.verbosity ?? 'auto'}
onChange={(v) => onConfigChange({ verbosity: v })}
/>
</div>
<Divider />
</>
)}
{derived.showTemperature && (
<>
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label={`Temperature (0${derived.maxTemperature})`} />
<Input
type='number'
value={config.temperature ?? ''}
min={0}
max={derived.maxTemperature}
step={0.1}
placeholder='1.0'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ temperature: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
<Divider />
</>
)}
{!derived.isDeepResearch && (
<div className='flex flex-col gap-[10px]'>
<FieldLabelRow label='Max tokens' />
<Input
type='number'
value={config.maxTokens ?? ''}
min={1}
max={200000}
placeholder='default'
onChange={(e) => {
const v = Number.parseFloat(e.target.value)
onConfigChange({ maxTokens: Number.isNaN(v) ? undefined : v })
}}
/>
</div>
)}
</>
)}
</>
)}
</div>
)
}
interface WandLabelRowProps {
label: string
wand: AgentWandState
}
function WandLabelRow({ label, wand }: WandLabelRowProps) {
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='font-medium text-[13px]'>{label}</Label>
<div className='flex min-w-0 flex-1 items-center justify-end'>
{!wand.isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={wand.onSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={wand.searchInputRef}
value={wand.isStreaming ? 'Generating...' : wand.searchQuery}
onChange={(e) => wand.onSearchChange(e.target.value)}
onBlur={(e) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
wand.onSearchBlur()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && wand.searchQuery.trim() && !wand.isStreaming) {
wand.onSearchSubmit()
} else if (e.key === 'Escape') {
wand.onSearchCancel()
}
}}
disabled={wand.isStreaming}
className='h-5 min-w-[80px] flex-1 text-[11px]'
placeholder='Generate with AI...'
/>
<Button
variant='primary'
disabled={!wand.searchQuery.trim() || wand.isStreaming}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.stopPropagation()
wand.onSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
)}
</div>
</div>
)
}
interface SkillsInputProps {
workspaceId: string
selectedSkills: SkillInput[]
onChange: (skills: SkillInput[]) => void
}
function SkillsInput({ workspaceId, selectedSkills, onChange }: SkillsInputProps) {
const { data: workspaceSkills = [] } = useSkills(workspaceId)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingSkill, setEditingSkill] = useState<SkillDefinition | null>(null)
const selectedIds = useMemo(() => new Set(selectedSkills.map((s) => s.skillId)), [selectedSkills])
const skillGroups = useMemo((): ComboboxOptionGroup[] => {
const available = workspaceSkills.filter((s) => !selectedIds.has(s.id))
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Create skill',
value: 'action-create-skill',
icon: Plus,
onSelect: () => setShowCreateModal(true),
keepOpen: false,
},
],
},
]
if (available.length > 0) {
groups.push({
section: 'Skills',
items: available.map((s) => ({
label: s.name,
value: `skill-${s.id}`,
icon: AgentSkillsIcon,
onSelect: () => onChange([...selectedSkills, { skillId: s.id, name: s.name }]),
})),
})
}
return groups
}, [workspaceSkills, selectedIds, selectedSkills, onChange])
const handleRemove = useCallback(
(skillId: string) => onChange(selectedSkills.filter((s) => s.skillId !== skillId)),
[selectedSkills, onChange]
)
const resolveSkillName = useCallback(
(stored: SkillInput): string => {
const found = workspaceSkills.find((s) => s.id === stored.skillId)
return found?.name ?? stored.name ?? stored.skillId
},
[workspaceSkills]
)
return (
<>
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={skillGroups}
placeholder='Add skill…'
searchable
searchPlaceholder='Search skills…'
maxHeight={240}
emptyMessage='No skills found'
/>
{selectedSkills.map((stored) => {
const fullSkill = workspaceSkills.find((s) => s.id === stored.skillId)
return (
<div
key={stored.skillId}
className='group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out'
>
<div
className='flex cursor-pointer items-center justify-between gap-[8px] rounded-[4px] bg-[var(--surface-4)] px-[8px] py-[6.5px]'
onClick={() => fullSkill && setEditingSkill(fullSkill)}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--surface-4)]'>
<AgentSkillsIcon className='h-[10px] w-[10px] text-[#333]' />
</div>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{resolveSkillName(stored)}
</span>
</div>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(stored.skillId)
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove skill'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
)
})}
</div>
<SkillModal
open={showCreateModal || !!editingSkill}
onOpenChange={(open) => {
if (!open) {
setShowCreateModal(false)
setEditingSkill(null)
}
}}
onSave={() => {
setShowCreateModal(false)
setEditingSkill(null)
}}
initialValues={editingSkill ?? undefined}
/>
</>
)
}

View File

@@ -1,739 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useParams } from 'next/navigation'
import { ReactFlowProvider } from 'reactflow'
import {
Combobox,
type ComboboxOptionGroup,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import { Plus, Wrench, X } from '@/components/emcn/icons'
import { McpIcon, WorkflowIcon } from '@/components/icons'
import type { ToolInput } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import {
type CustomTool,
CustomToolModal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer'
import { getAllBlocks } from '@/blocks'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { type McpToolForUI, useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { useCustomTools } from '@/hooks/queries/custom-tools'
import { useAllowedMcpDomains, useCreateMcpServer, useMcpServers } from '@/hooks/queries/mcp'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
import { getSubBlocksForToolInput, getToolParametersConfig } from '@/tools/params'
/** Returns true if a block type has more than one tool operation. */
function hasMultipleOperations(blockType: string): boolean {
const block = getAllBlocks().find((b) => b.type === blockType)
return (block?.tools?.access?.length || 0) > 1
}
/** Returns the available operation options for a multi-operation block. */
function getOperationOptions(blockType: string): { label: string; id: string }[] {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return []
const opSubBlock = block.subBlocks.find((sb) => sb.id === 'operation')
if (opSubBlock?.type === 'dropdown' && Array.isArray(opSubBlock.options)) {
return opSubBlock.options as { label: string; id: string }[]
}
return block.tools.access.map((toolId) => {
const params = getToolParametersConfig(toolId)
return { id: toolId, label: params?.toolConfig?.name || toolId }
})
}
/** Returns the concrete toolId for a given block type and optional operation. */
function getToolIdForOperation(blockType: string, operation?: string): string | undefined {
const block = getAllBlocks().find((b) => b.type === blockType)
if (!block?.tools?.access) return undefined
if (block.tools.access.length === 1) return block.tools.access[0]
if (operation && block.tools?.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch {}
}
if (operation && block.tools.access.includes(operation)) return operation
return block.tools.access[0]
}
interface AgentToolInputProps {
workspaceId: string
selectedTools: ToolInput[]
model?: string
onChange: (tools: ToolInput[]) => void
}
export function AgentToolInput({
workspaceId,
selectedTools,
model,
onChange,
}: AgentToolInputProps) {
const params = useParams()
const agentId = (params?.agentId as string) || 'agent'
const [activeMcpServerId, setActiveMcpServerId] = useState<string | null>(null)
const [showCustomToolModal, setShowCustomToolModal] = useState(false)
const [showMcpModal, setShowMcpModal] = useState(false)
const [usageControlIndex, setUsageControlIndex] = useState<number | null>(null)
const { data: customTools = [] } = useCustomTools(workspaceId)
const { mcpTools } = useMcpTools(workspaceId)
const { data: servers = [] } = useMcpServers(workspaceId)
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const toolBlocks = useMemo(
() =>
getAllBlocks().filter(
(block) =>
!block.hideFromToolbar &&
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'knowledge' ||
block.type === 'function' ||
block.type === 'table') &&
block.type !== 'evaluator' &&
block.type !== 'mcp' &&
block.type !== 'file'
),
[]
)
const handleSelectBlock = useCallback(
(block: ReturnType<typeof getAllBlocks>[number]) => {
const hasOps = hasMultipleOperations(block.type)
const ops = hasOps ? getOperationOptions(block.type) : []
const defaultOp = ops.length > 0 ? ops[0].id : undefined
const toolId = getToolIdForOperation(block.type, defaultOp) || ''
const newTool: ToolInput = {
type: block.type,
title: block.name,
toolId,
params: {},
operation: defaultOp,
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleSelectCustomTool = useCallback(
(tool: { id: string; title: string }) => {
const newTool: ToolInput = {
type: 'custom-tool',
title: tool.title,
customToolId: tool.id,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectMcpTool = useCallback(
(mcpTool: McpToolForUI) => {
const newTool: ToolInput = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
schema: mcpTool.inputSchema,
usageControl: 'auto',
}
onChange([...selectedTools, newTool])
},
[selectedTools, onChange]
)
const handleSelectWorkflow = useCallback(
(workflow: { id: string; name: string }) => {
const alreadySelected = selectedTools.some(
(t) => t.type === 'workflow_input' && t.params?.workflowId === workflow.id
)
if (alreadySelected) return
const newTool: ToolInput = {
type: 'workflow_input',
title: workflow.name,
toolId: 'workflow_executor',
params: { workflowId: workflow.id },
isExpanded: true,
usageControl: 'auto',
}
onChange([...selectedTools.map((t) => ({ ...t, isExpanded: false })), newTool])
},
[selectedTools, onChange]
)
const handleRemove = useCallback(
(index: number) => {
const next = [...selectedTools]
next.splice(index, 1)
onChange(next)
},
[selectedTools, onChange]
)
const handleParamChange = useCallback(
(index: number, paramId: string, value: string) => {
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, params: { ...(t.params || {}), [paramId]: value } } : t
)
)
},
[selectedTools, onChange]
)
const handleOperationChange = useCallback(
(index: number, operation: string) => {
const tool = selectedTools[index]
const newToolId = getToolIdForOperation(tool.type ?? '', operation) || ''
onChange(
selectedTools.map((t, i) =>
i === index ? { ...t, operation, toolId: newToolId, params: {} } : t
)
)
},
[selectedTools, onChange]
)
const handleUsageControlChange = useCallback(
(index: number, value: 'auto' | 'force' | 'none') => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, usageControl: value } : t)))
},
[selectedTools, onChange]
)
const toggleExpansion = useCallback(
(index: number) => {
onChange(selectedTools.map((t, i) => (i === index ? { ...t, isExpanded: !t.isExpanded } : t)))
},
[selectedTools, onChange]
)
const selectedCustomIds = useMemo(
() => new Set(selectedTools.filter((t) => t.type === 'custom-tool').map((t) => t.customToolId)),
[selectedTools]
)
const selectedMcpKeys = useMemo(
() =>
new Set(
selectedTools
.filter((t) => t.type === 'mcp')
.map((t) => `${t.params?.serverId ?? ''}:${t.params?.toolName ?? ''}`)
),
[selectedTools]
)
const showUsageControl = useMemo(() => {
if (!model) return false
const provider = getProviderFromModel(model)
return Boolean(provider && supportsToolUsageControl(provider))
}, [model])
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
if (activeMcpServerId) {
const server = servers.find((s) => s.id === activeMcpServerId)
const serverTools = mcpTools.filter(
(t) => t.serverId === activeMcpServerId && !selectedMcpKeys.has(`${t.serverId}:${t.name}`)
)
const groups: ComboboxOptionGroup[] = [
{
items: [
{
label: 'Back',
value: 'action-back',
icon: ChevronLeft,
onSelect: () => setActiveMcpServerId(null),
keepOpen: true,
},
...(serverTools.length > 0
? [
{
label: `Use all ${serverTools.length} tool${serverTools.length !== 1 ? 's' : ''}`,
value: 'action-use-all',
icon: McpIcon,
onSelect: () => {
const newTools: ToolInput[] = serverTools.map((t) => ({
type: 'mcp',
title: t.name,
toolId: t.id,
params: {
serverId: t.serverId,
toolName: t.name,
serverName: t.serverName,
},
schema: t.inputSchema,
usageControl: 'auto' as const,
}))
onChange([...selectedTools, ...newTools])
setActiveMcpServerId(null)
},
},
]
: []),
],
},
]
if (serverTools.length > 0) {
groups.push({
section: server?.name ?? activeMcpServerId,
items: serverTools.map((tool) => ({
label: tool.name,
value: `mcp:${tool.serverId}:${tool.name}`,
icon: McpIcon,
onSelect: () => {
handleSelectMcpTool(tool)
setActiveMcpServerId(null)
},
})),
})
}
return groups
}
const groups: ComboboxOptionGroup[] = []
groups.push({
items: [
{
label: 'Create custom tool',
value: 'action-create-tool',
icon: Plus,
onSelect: () => setShowCustomToolModal(true),
},
{
label: 'Add MCP server',
value: 'action-add-mcp',
icon: Plus,
onSelect: () => setShowMcpModal(true),
},
],
})
const availableCustom = customTools.filter((t) => !selectedCustomIds.has(t.id))
if (availableCustom.length > 0) {
groups.push({
section: 'Custom tools',
items: availableCustom.map((t) => ({
label: t.title,
value: `custom:${t.id}`,
icon: Wrench,
onSelect: () => handleSelectCustomTool(t),
})),
})
}
const enabledServers = servers.filter((s) => s.enabled)
if (enabledServers.length > 0) {
groups.push({
section: 'MCP servers',
items: enabledServers.map((server) => {
const count = mcpTools.filter((t) => t.serverId === server.id).length
return {
label: server.name,
value: `server:${server.id}`,
icon: McpIcon,
suffixElement: (
<div className='flex items-center gap-[3px] text-[11px] text-[var(--text-muted)]'>
<span>{count}</span>
<ChevronRight className='h-[9px] w-[9px]' />
</div>
),
onSelect: () => setActiveMcpServerId(server.id),
keepOpen: true,
}
}),
})
}
const builtInBlocks = toolBlocks.filter((b) => BUILT_IN_TOOL_TYPES.has(b.type))
if (builtInBlocks.length > 0) {
groups.push({
section: 'Tools',
items: builtInBlocks.map((block) => ({
label: block.name,
value: `block:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const integrationBlocks = toolBlocks.filter((b) => !BUILT_IN_TOOL_TYPES.has(b.type))
if (integrationBlocks.length > 0) {
groups.push({
section: 'Integrations',
items: integrationBlocks.map((block) => ({
label: block.name,
value: `integration:${block.type}`,
iconElement: (
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectBlock(block),
})),
})
}
const selectedWorkflowIds = new Set(
selectedTools
.filter((t) => t.type === 'workflow_input')
.map((t) => t.params?.workflowId as string)
)
const availableWorkflows = workflowsList.filter((w) => !selectedWorkflowIds.has(w.id))
if (availableWorkflows.length > 0) {
groups.push({
section: 'Workflows',
items: availableWorkflows.map((workflow) => ({
label: workflow.name,
value: `workflow:${workflow.id}`,
iconElement: (
<div className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] bg-[#6366F1]'>
<WorkflowIcon className='h-[9px] w-[9px] text-white' />
</div>
),
onSelect: () => handleSelectWorkflow(workflow),
})),
})
}
return groups
}, [
activeMcpServerId,
servers,
mcpTools,
selectedMcpKeys,
customTools,
selectedCustomIds,
toolBlocks,
selectedTools,
workflowsList,
onChange,
handleSelectMcpTool,
handleSelectCustomTool,
handleSelectBlock,
handleSelectWorkflow,
])
return (
<div className='w-full space-y-[8px]'>
<Combobox
options={[]}
groups={toolGroups}
placeholder='Add tool…'
searchable
searchPlaceholder='Search tools…'
maxHeight={280}
emptyMessage='No tools available'
onOpenChange={(open) => {
if (!open) setActiveMcpServerId(null)
}}
/>
{selectedTools.map((tool, idx) => (
<ToolCard
key={`${tool.type}-${tool.customToolId ?? ''}-${tool.toolId ?? ''}-${tool.params?.serverId ?? ''}-${tool.params?.toolName ?? ''}-${tool.params?.workflowId ?? ''}-${idx}`}
tool={tool}
toolIndex={idx}
agentId={agentId}
toolBlocks={toolBlocks}
showUsageControl={showUsageControl}
usageControlOpen={usageControlIndex === idx}
onUsageControlOpenChange={(open) => setUsageControlIndex(open ? idx : null)}
onRemove={() => handleRemove(idx)}
onParamChange={(paramId, value) => handleParamChange(idx, paramId, value)}
onOperationChange={(op) => handleOperationChange(idx, op)}
onUsageControlChange={(v) => handleUsageControlChange(idx, v)}
onToggleExpansion={() => toggleExpansion(idx)}
/>
))}
<CustomToolModal
open={showCustomToolModal}
onOpenChange={setShowCustomToolModal}
onSave={(tool: CustomTool) => {
if (tool.id) {
handleSelectCustomTool({ id: tool.id, title: tool.title })
}
}}
blockId=''
/>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</div>
)
}
interface ToolCardProps {
tool: ToolInput
toolIndex: number
agentId: string
toolBlocks: ReturnType<typeof getAllBlocks>
showUsageControl: boolean
usageControlOpen: boolean
onUsageControlOpenChange: (open: boolean) => void
onRemove: () => void
onParamChange: (paramId: string, value: string) => void
onOperationChange: (op: string) => void
onUsageControlChange: (value: 'auto' | 'force' | 'none') => void
onToggleExpansion: () => void
}
function ToolCard({
tool,
toolIndex,
agentId,
toolBlocks,
showUsageControl,
usageControlOpen,
onUsageControlOpenChange,
onRemove,
onParamChange,
onOperationChange,
onUsageControlChange,
onToggleExpansion,
}: ToolCardProps) {
const isCustomTool = tool.type === 'custom-tool'
const isMcpTool = tool.type === 'mcp'
const isWorkflowTool = tool.type === 'workflow_input' || tool.type === 'workflow'
const block = !isCustomTool && !isMcpTool ? toolBlocks.find((b) => b.type === tool.type) : null
const currentToolId =
!isCustomTool && !isMcpTool
? (getToolIdForOperation(tool.type ?? '', tool.operation) ?? tool.toolId ?? '')
: (tool.toolId ?? '')
const subBlocksResult =
!isCustomTool && !isMcpTool && currentToolId
? getSubBlocksForToolInput(currentToolId, tool.type ?? '', {
operation: tool.operation,
...(tool.params || {}),
})
: null
// Exclude input-mapping sub-blocks — the LLM resolves these at runtime
const displaySubBlocks = (subBlocksResult?.subBlocks ?? []).filter(
(sb) => sb.type !== 'input-mapping'
)
const hasOps = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type ?? '')
const hasBody = hasOps || displaySubBlocks.length > 0
const isExpanded = hasBody ? !!tool.isExpanded : false
const iconNode = isMcpTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#ede9fb]'>
<McpIcon className='h-[10px] w-[10px] text-[#6b5fc9]' />
</div>
) : isCustomTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#dbeafe]'>
<Wrench className='h-[10px] w-[10px] text-[#3b82f6]' />
</div>
) : isWorkflowTool ? (
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
<WorkflowIcon className='h-[10px] w-[10px] text-white' />
</div>
) : block ? (
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ backgroundColor: block.bgColor }}
>
<block.icon className='h-[10px] w-[10px] text-white' />
</div>
) : null
const displayTitle = tool.title ?? tool.params?.toolName ?? tool.type
const subtitle =
tool.type === 'mcp' && tool.params?.serverName ? String(tool.params.serverName) : undefined
return (
<div
className={cn(
'group relative flex flex-col overflow-hidden rounded-[4px] border border-[var(--border-1)] transition-all duration-200 ease-in-out',
isExpanded ? 'rounded-b-[4px]' : ''
)}
>
{/* Header row */}
<div
className={cn(
'flex items-center justify-between gap-[8px] bg-[var(--surface-4)] px-[8px] py-[6.5px]',
hasBody ? 'cursor-pointer rounded-t-[4px]' : 'rounded-[4px]'
)}
onClick={hasBody ? onToggleExpansion : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{iconNode}
<div className='min-w-0 flex-1'>
<span className='block truncate font-medium text-[13px] text-[var(--text-primary)]'>
{displayTitle}
</span>
{subtitle && (
<span className='block truncate text-[11px] text-[var(--text-muted)]'>
{subtitle}
</span>
)}
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-[6px]'>
{showUsageControl && (
<Popover open={usageControlOpen} onOpenChange={onUsageControlOpenChange}>
<PopoverTrigger asChild>
<button
type='button'
onClick={(e) => e.stopPropagation()}
className='flex items-center justify-center font-medium text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Tool usage control'
>
{tool.usageControl === 'force'
? 'Force'
: tool.usageControl === 'none'
? 'None'
: 'Auto'}
</button>
</PopoverTrigger>
<PopoverContent
side='bottom'
align='end'
sideOffset={8}
onClick={(e) => e.stopPropagation()}
className='gap-[2px]'
border
>
<PopoverItem
active={(tool.usageControl || 'auto') === 'auto'}
onClick={() => {
onUsageControlChange('auto')
onUsageControlOpenChange(false)
}}
>
Auto <span className='text-[var(--text-tertiary)]'>(model decides)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'force'}
onClick={() => {
onUsageControlChange('force')
onUsageControlOpenChange(false)
}}
>
Force <span className='text-[var(--text-tertiary)]'>(always use)</span>
</PopoverItem>
<PopoverItem
active={tool.usageControl === 'none'}
onClick={() => {
onUsageControlChange('none')
onUsageControlOpenChange(false)
}}
>
None
</PopoverItem>
</PopoverContent>
</Popover>
)}
<button
type='button'
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className='flex items-center justify-center text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
aria-label='Remove tool'
>
<X className='h-[13px] w-[13px]' />
</button>
</div>
</div>
{/* Expanded body */}
{isExpanded && (
<ReactFlowProvider>
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
{/* Operation selector */}
{hasOps &&
(() => {
const opOptions = getOperationOptions(tool.type ?? '')
return opOptions.length > 0 ? (
<div className='space-y-[6px]'>
<div className='pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={opOptions
.filter((o) => o.id !== '')
.map((o) => ({ label: o.label, value: o.id }))}
value={tool.operation || opOptions[0]?.id || ''}
onChange={(v) => onOperationChange(v)}
placeholder='Select operation'
/>
</div>
) : null
})()}
{/* Sub-block params */}
{displaySubBlocks.map((sb) => (
<ToolSubBlockRenderer
key={sb.id}
blockId={agentId}
subBlockId='agent-tools'
toolIndex={toolIndex}
subBlock={{ ...sb, title: sb.title || sb.id }}
effectiveParamId={sb.id}
toolParams={tool.params as Record<string, string> | undefined}
onParamChange={(_, paramId, value) => onParamChange(paramId, value)}
disabled={false}
/>
))}
</div>
</ReactFlowProvider>
)}
</div>
)
}

View File

@@ -1,102 +0,0 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Label } from '@/components/emcn'
import { Check, Copy } from '@/components/emcn/icons'
import { useUpdateAgent } from '@/hooks/queries/agents'
const logger = createLogger('ApiDeploy')
interface ApiDeployProps {
agentId: string
workspaceId: string
isDeployed: boolean
}
export function ApiDeploy({ agentId, workspaceId, isDeployed }: ApiDeployProps) {
const { mutateAsync: updateAgent, isPending } = useUpdateAgent()
const [copied, setCopied] = useState(false)
const endpoint =
typeof window !== 'undefined'
? `${window.location.origin}/api/v1/agents/${agentId}`
: `/api/v1/agents/${agentId}`
const curlExample = `curl -X POST "${endpoint}" \\
-H "Authorization: Bearer <your-api-key>" \\
-H "Content-Type: application/json" \\
-d '{"message": "Hello, agent!"}'`
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(curlExample)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* ignore */
}
}
const handleToggle = async () => {
try {
await updateAgent({ agentId, isDeployed: !isDeployed })
} catch (error) {
logger.error('Failed to toggle API deployment', { error })
}
}
return (
<div className='space-y-[16px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Call your agent via REST API using your workspace API key.
</p>
<div className='flex items-center justify-between'>
<Label className='text-[13px]'>API access</Label>
<Button variant='subtle' size='sm' onClick={handleToggle} disabled={isPending}>
{isDeployed ? 'Disable' : 'Enable'}
</Button>
</div>
{isDeployed && (
<>
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>Endpoint</Label>
<div className='flex items-center gap-[8px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<code className='flex-1 truncate text-[12px] text-[var(--text-primary)]'>
{endpoint}
</code>
</div>
</div>
<div>
<div className='mb-[6px] flex items-center justify-between'>
<Label className='text-[12px] text-[var(--text-muted)]'>Example request</Label>
<button
type='button'
onClick={handleCopy}
className='flex items-center gap-[4px] text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
{copied ? (
<>
<Check className='h-[11px] w-[11px]' />
Copied
</>
) : (
<>
<Copy className='h-[11px] w-[11px]' />
Copy
</>
)}
</button>
</div>
<pre className='overflow-x-auto rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[8px] text-[11px] text-[var(--text-primary)] leading-[1.6]'>
{curlExample}
</pre>
</div>
</>
)}
</div>
)
}

View File

@@ -1,362 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Combobox, type ComboboxOption, Input, Label } from '@/components/emcn'
import { Check, Loader, Plus } from '@/components/emcn/icons'
import type { AgentDeploymentRow, SlackDeploymentConfig } from '@/lib/agents/types'
import { cn } from '@/lib/core/utils/cn'
import { getCanonicalScopesForProvider } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import {
useAgent,
useDeployAgentToSlack,
useSlackChannels,
useUndeployAgentFromSlack,
} from '@/hooks/queries/agents'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
const logger = createLogger('SlackDeploy')
interface SlackDeployProps {
agentId: string
workspaceId: string
}
const RESPOND_TO_OPTIONS: ComboboxOption[] = [
{ value: 'mentions', label: '@mentions only' },
{ value: 'all', label: 'All messages in channel' },
{ value: 'threads', label: 'Thread replies only' },
{ value: 'dm', label: 'Direct messages (DMs)' },
]
export function SlackDeploy({ agentId, workspaceId }: SlackDeployProps) {
const { data: agent } = useAgent(agentId)
const { data: credentials = [], isLoading: isLoadingCredentials } = useWorkspaceCredentials({
workspaceId,
type: 'oauth',
providerId: 'slack',
})
const existingDeployment = agent?.deployments?.find((d) => d.platform === 'slack')
return (
<SlackDeployForm
key={existingDeployment?.id ?? 'new'}
agentId={agentId}
existingDeployment={existingDeployment}
credentials={credentials}
isLoadingCredentials={isLoadingCredentials}
/>
)
}
interface SlackDeployFormProps {
agentId: string
existingDeployment: AgentDeploymentRow | undefined
credentials: WorkspaceCredential[]
isLoadingCredentials: boolean
}
function SlackDeployForm({
agentId,
existingDeployment,
credentials,
isLoadingCredentials,
}: SlackDeployFormProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [deployError, setDeployError] = useState<string | null>(null)
const { mutateAsync: deploy, isPending: isDeploying } = useDeployAgentToSlack()
const { mutateAsync: undeploy, isPending: isUndeploying } = useUndeployAgentFromSlack()
const existingCfg = existingDeployment?.config as SlackDeploymentConfig | undefined
const [selectedCredentialId, setSelectedCredentialId] = useState(
existingDeployment?.credentialId ?? ''
)
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
existingCfg?.channelIds ?? []
)
const [respondTo, setRespondTo] = useState<SlackDeploymentConfig['respondTo']>(
existingCfg?.respondTo ?? 'mentions'
)
const [botName, setBotName] = useState(existingCfg?.botName ?? '')
const [replyInThread, setReplyInThread] = useState(existingCfg?.replyInThread !== false)
const isDeployed = Boolean(existingDeployment?.isActive)
const isDmMode = respondTo === 'dm'
const { data: channels = [], isLoading: isLoadingChannels } =
useSlackChannels(selectedCredentialId)
const handleCredentialChange = useCallback((id: string) => {
setSelectedCredentialId(id)
setSelectedChannelIds([])
}, [])
const handleDeploy = useCallback(async () => {
if (!selectedCredentialId) return
if (!isDmMode && selectedChannelIds.length === 0) return
setDeployError(null)
try {
await deploy({
agentId,
credentialId: selectedCredentialId,
channelIds: isDmMode ? [] : selectedChannelIds,
respondTo,
botName: botName.trim() || undefined,
replyInThread,
})
} catch (error) {
logger.error('Failed to deploy agent to Slack', { error })
setDeployError(error instanceof Error ? error.message : 'Deployment failed')
}
}, [
agentId,
botName,
deploy,
isDmMode,
respondTo,
replyInThread,
selectedChannelIds,
selectedCredentialId,
])
const handleUndeploy = useCallback(async () => {
try {
await undeploy({ agentId })
} catch (error) {
logger.error('Failed to undeploy agent from Slack', { error })
}
}, [agentId, undeploy])
const toggleChannel = useCallback((channelId: string) => {
setSelectedChannelIds((prev) =>
prev.includes(channelId) ? prev.filter((id) => id !== channelId) : [...prev, channelId]
)
}, [])
const credentialOptions: ComboboxOption[] = useMemo(
() => credentials.map((c) => ({ value: c.id, label: c.displayName })),
[credentials]
)
const hasValidConfig = selectedCredentialId && (isDmMode || selectedChannelIds.length > 0)
return (
<>
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider='slack'
toolName='Slack'
requiredScopes={getCanonicalScopesForProvider('slack')}
serviceId='slack'
/>
<div className='flex flex-col gap-[16px]'>
{/* Workspace */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Slack workspace
</Label>
{isLoadingCredentials ? (
<div className='flex h-[36px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading
</div>
) : credentials.length === 0 ? (
<div className='flex flex-col gap-[8px]'>
<p className='text-[12px] text-[var(--text-muted)]'>
No Slack workspace connected yet.
</p>
<Button
variant='outline'
size='sm'
onClick={() => setShowOAuthModal(true)}
className='w-fit'
>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Connect Slack
</Button>
</div>
) : (
<div className='flex items-center gap-[8px]'>
<div className='flex-1'>
<Combobox
options={credentialOptions}
value={selectedCredentialId}
onChange={handleCredentialChange}
placeholder='Select a Slack workspace…'
/>
</div>
<button
type='button'
onClick={() => setShowOAuthModal(true)}
className='flex-shrink-0 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
title='Connect another workspace'
>
<Plus className='h-[14px] w-[14px]' />
</button>
</div>
)}
</div>
{selectedCredentialId && (
<>
{/* Respond to */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Respond to
</Label>
<Combobox
options={RESPOND_TO_OPTIONS}
value={respondTo}
onChange={(v) => setRespondTo(v as SlackDeploymentConfig['respondTo'])}
/>
</div>
{/* Channels — hidden in DM mode */}
{!isDmMode && (
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Channels <span className='font-normal opacity-60'>(select one or more)</span>
</Label>
{isLoadingChannels ? (
<div className='flex h-[32px] items-center gap-[6px] text-[12px] text-[var(--text-muted)]'>
<Loader className='h-[12px] w-[12px] animate-spin' />
Loading channels
</div>
) : channels.length === 0 ? (
<p className='text-[12px] text-[var(--text-muted)]'>
No accessible channels found. Make sure the bot has been added to at least one
channel.
</p>
) : (
<div className='max-h-[160px] overflow-y-auto rounded-[4px] border border-[var(--border-1)]'>
{channels.map((channel) => (
<button
key={channel.id}
type='button'
onClick={() => toggleChannel(channel.id)}
className={cn(
'flex w-full items-center gap-[8px] px-[10px] py-[7px] text-left text-[12px] transition-colors',
'hover:bg-[var(--surface-7)]',
selectedChannelIds.includes(channel.id) && 'bg-[var(--surface-4)]'
)}
>
<span
className={cn(
'flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[3px] border',
selectedChannelIds.includes(channel.id)
? 'border-[var(--text-primary)] bg-[var(--text-primary)]'
: 'border-[var(--border-1)]'
)}
>
{selectedChannelIds.includes(channel.id) && (
<Check className='h-[9px] w-[9px] text-[var(--surface-1)]' />
)}
</span>
<span className='text-[var(--text-primary)]'>
{channel.isPrivate ? '🔒 ' : '#'}
{channel.name}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Bot display name */}
<div>
<Label className='mb-[6px] block text-[12px] text-[var(--text-muted)]'>
Bot display name <span className='font-normal opacity-60'>(optional)</span>
</Label>
<Input
value={botName}
onChange={(e) => setBotName(e.target.value)}
placeholder='e.g. Sales Assistant'
maxLength={80}
className='h-[34px] text-[12px]'
/>
</div>
{/* Reply in thread */}
<div className='flex items-center justify-between'>
<div>
<p className='text-[12px] text-[var(--text-primary)]'>Reply in thread</p>
<p className='text-[11px] text-[var(--text-muted)]'>
Keep responses inside the original thread
</p>
</div>
<button
type='button'
onClick={() => setReplyInThread((v) => !v)}
className={cn(
'relative h-[20px] w-[36px] flex-shrink-0 rounded-full transition-colors',
replyInThread ? 'bg-[var(--text-primary)]' : 'bg-[var(--border-1)]'
)}
aria-checked={replyInThread}
role='switch'
>
<span
className={cn(
'absolute top-[2px] h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform',
replyInThread ? 'translate-x-[18px]' : 'translate-x-[2px]'
)}
/>
</button>
</div>
</>
)}
{/* Error */}
{deployError && (
<p className='rounded-[4px] bg-[var(--error-bg,#fef2f2)] px-[10px] py-[6px] text-[12px] text-[var(--error,#dc2626)]'>
{deployError}
</p>
)}
{/* Actions */}
<div className='flex items-center gap-[8px] pt-[4px]'>
{isDeployed ? (
<>
<div className='flex items-center gap-[6px] text-[12px] text-[var(--text-success,#16a34a)]'>
<span className='h-[6px] w-[6px] rounded-full bg-[var(--text-success,#16a34a)]' />
Deployed
</div>
<Button
variant='outline'
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Update
</Button>
<Button variant='outline' size='sm' onClick={handleUndeploy} disabled={isUndeploying}>
{isUndeploying ? (
<Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' />
) : null}
Remove
</Button>
</>
) : (
<Button
size='sm'
onClick={handleDeploy}
disabled={!hasValidConfig || isDeploying}
className='ml-auto'
>
{isDeploying ? <Loader className='mr-[4px] h-[12px] w-[12px] animate-spin' /> : null}
Deploy to Slack
</Button>
)}
</div>
</div>
</>
)
}

View File

@@ -1,531 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Paperclip, Square } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Send, X } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
const logger = createLogger('AgentTestPanel')
const MAX_FILES = 10
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
const TEXT_EXTENSIONS = new Set([
'.txt',
'.md',
'.csv',
'.json',
'.xml',
'.html',
'.ts',
'.tsx',
'.js',
'.jsx',
'.py',
'.sh',
])
const TEXT_MEDIA_TYPES = new Set([
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'text/html',
'application/xml',
'text/xml',
])
interface AttachedFile {
id: string
name: string
size: number
type: string
file: File
previewUrl?: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
attachments?: Array<{ id: string; name: string; type: string; previewUrl?: string }>
}
interface AgentTestPanelProps {
agentId: string
}
function isTextFile(file: { type: string; name: string }) {
if (TEXT_MEDIA_TYPES.has(file.type)) return true
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
return TEXT_EXTENSIONS.has(ext)
}
function StreamingCursor() {
return (
<span className='ml-[1px] inline-block h-[13px] w-[5px] translate-y-[1px] animate-pulse bg-current opacity-60' />
)
}
function FilePill({
name,
type,
previewUrl,
onRemove,
}: {
name: string
type: string
previewUrl?: string
onRemove?: () => void
}) {
const isImage = type.startsWith('image/')
const Icon = getDocumentIcon(type, name)
if (isImage && previewUrl) {
return (
<div className='group relative h-[44px] w-[44px] flex-shrink-0 overflow-hidden rounded-[6px]'>
<img src={previewUrl} alt={name} className='h-full w-full object-cover' />
{onRemove && (
<button
type='button'
onClick={onRemove}
className='absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100'
aria-label={`Remove ${name}`}
>
<X className='h-[11px] w-[11px] text-white' />
</button>
)}
</div>
)
}
return (
<div className='flex max-w-[160px] items-center gap-[4px] rounded-[6px] bg-[var(--surface-4)] px-[7px] py-[3px]'>
<Icon className='h-[11px] w-[11px] flex-shrink-0 text-[var(--text-muted)]' />
<span className='min-w-0 truncate text-[11px] text-[var(--text-primary)]'>{name}</span>
{onRemove && (
<button
type='button'
onClick={onRemove}
className='ml-[2px] flex-shrink-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
aria-label={`Remove ${name}`}
>
<X className='h-[9px] w-[9px]' />
</button>
)}
</div>
)
}
function UserMessage({ message }: { message: ChatMessage }) {
const hasAttachments = (message.attachments?.length ?? 0) > 0
return (
<div className='flex w-full flex-col items-end gap-[4px]'>
{hasAttachments && (
<div className='flex flex-wrap justify-end gap-[5px]'>
{message.attachments!.map((att) => (
<FilePill key={att.id} name={att.name} type={att.type} previewUrl={att.previewUrl} />
))}
</div>
)}
{message.content && (
<div className='max-w-[85%] rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-4)] px-[10px] py-[7px]'>
<p className='whitespace-pre-wrap break-words font-medium text-[13px] text-[var(--text-primary)] leading-[1.5]'>
{message.content}
</p>
</div>
)}
</div>
)
}
const REMARK_PLUGINS = [remarkGfm]
const PROSE_CLASSES = cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-p:text-[13px] prose-p:leading-[1.6] prose-p:text-[var(--text-primary)] first:prose-p:mt-0 last:prose-p:mb-0',
'prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2',
'prose-li:text-[13px] prose-li:text-[var(--text-primary)] prose-li:my-0.5',
'prose-ul:my-2 prose-ol:my-2',
'prose-strong:text-[var(--text-primary)] prose-strong:font-semibold',
'prose-a:text-[var(--text-primary)] prose-a:underline prose-a:decoration-dashed prose-a:underline-offset-2',
'prose-code:rounded prose-code:bg-[var(--surface-4)] prose-code:px-1 prose-code:py-0.5 prose-code:text-[12px] prose-code:font-mono prose-code:text-[var(--text-primary)]',
'prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-[var(--surface-4)] prose-pre:border prose-pre:border-[var(--border-1)] prose-pre:rounded-[6px] prose-pre:text-[12px]',
'prose-blockquote:border-[var(--border-1)] prose-blockquote:text-[var(--text-muted)]'
)
function AssistantMessage({ message }: { message: ChatMessage }) {
return (
<div className='w-full pl-[2px]'>
{message.isStreaming && !message.content ? (
<span className='text-[13px] text-[var(--text-tertiary)]'>
Thinking
<StreamingCursor />
</span>
) : message.content ? (
<div className={PROSE_CLASSES}>
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{message.content}</ReactMarkdown>
{message.isStreaming && <StreamingCursor />}
</div>
) : null}
</div>
)
}
export function AgentTestPanel({ agentId }: AgentTestPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
const conversationIdRef = useRef(`test-${Date.now()}`)
const scrollRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
return () => {
abortRef.current?.abort()
// Revoke any object URLs created for image previews
for (const f of attachedFiles) {
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
const syncHeight = useCallback(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 180)}px`
}, [])
const addFiles = useCallback((files: File[]) => {
setAttachedFiles((current) => {
const slots = Math.max(0, MAX_FILES - current.length)
const next: AttachedFile[] = []
for (const file of files.slice(0, slots)) {
if (file.size > MAX_FILE_SIZE) continue
if ([...current, ...next].some((f) => f.name === file.name && f.size === file.size))
continue
const attached: AttachedFile = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
file,
}
if (file.type.startsWith('image/')) {
attached.previewUrl = URL.createObjectURL(file)
}
next.push(attached)
}
return [...current, ...next]
})
}, [])
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files))
e.target.value = ''
},
[addFiles]
)
const handleStop = useCallback(() => {
abortRef.current?.abort()
}, [])
const handleSend = useCallback(async () => {
const trimmed = input.trim()
if ((!trimmed && attachedFiles.length === 0) || isLoading) return
const files = [...attachedFiles]
setInput('')
setAttachedFiles([])
setIsLoading(true)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
// Inject text file contents into the message
let messageText = trimmed
const textFiles = files.filter(isTextFile)
if (textFiles.length > 0) {
const contents = await Promise.all(
textFiles.map(
(f) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (ev) => resolve(`\n\n--- ${f.name} ---\n${ev.target?.result ?? ''}`)
reader.onerror = () => resolve('')
reader.readAsText(f.file)
})
)
)
messageText = (trimmed + contents.join('')).trim()
} else if (!trimmed && files.length > 0) {
messageText = files.map((f) => f.name).join(', ')
}
const attachments = files.map(({ id, name, type, previewUrl }) => ({
id,
name,
type,
previewUrl,
}))
setMessages((prev) => [
...prev,
{ role: 'user', content: trimmed, attachments },
{ role: 'assistant', content: '', isStreaming: true },
])
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch(`/api/agents/${agentId}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ message: messageText, conversationId: conversationIdRef.current }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(err.error || 'Execution failed')
}
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('text/event-stream') && res.body) {
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]') break outer
try {
const parsed = JSON.parse(data)
if (parsed.content) {
accumulated += parsed.content
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: accumulated,
isStreaming: true,
}
return next
})
}
} catch {}
}
}
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: accumulated, isStreaming: false }
return next
})
} else {
const json = await res.json()
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = {
role: 'assistant',
content: json.data?.content ?? '',
isStreaming: false,
}
return next
})
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
setMessages((prev) => {
const next = [...prev]
const last = next[next.length - 1]
if (last?.role === 'assistant') next[next.length - 1] = { ...last, isStreaming: false }
return next
})
return
}
logger.error('Agent execution failed', { error })
const msg = error instanceof Error ? error.message : 'Something went wrong'
setMessages((prev) => {
const next = [...prev]
next[next.length - 1] = { role: 'assistant', content: `Error: ${msg}`, isStreaming: false }
return next
})
} finally {
setIsLoading(false)
abortRef.current = null
setTimeout(() => textareaRef.current?.focus(), 0)
}
}, [agentId, input, attachedFiles, isLoading])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
const canSend = (input.trim().length > 0 || attachedFiles.length > 0) && !isLoading
return (
<div
className='flex h-full flex-col bg-[var(--surface-1)]'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
addFiles(Array.from(e.dataTransfer.files))
}}
>
<div className='flex flex-shrink-0 items-center justify-between border-[var(--border-1)] border-b px-[16px] py-[10px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Test</span>
{messages.length > 0 && (
<button
type='button'
onClick={() => {
setMessages([])
setAttachedFiles([])
conversationIdRef.current = `test-${Date.now()}`
}}
className='text-[12px] text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
>
Clear
</button>
)}
</div>
<div ref={scrollRef} className='flex-1 overflow-y-auto px-[20px] py-[20px]'>
{messages.length === 0 ? (
<div className='flex h-full flex-col items-center justify-center gap-[6px]'>
<p className='text-[13px] text-[var(--text-muted)]'>
Send a message to test your agent.
</p>
<p className='text-[11px] text-[var(--text-tertiary)]'>
to send · Shift+ for new line
</p>
</div>
) : (
<div className='flex flex-col gap-[20px]'>
{messages.map((msg, idx) =>
msg.role === 'user' ? (
<UserMessage key={idx} message={msg} />
) : (
<AssistantMessage key={idx} message={msg} />
)
)}
</div>
)}
</div>
<div className='flex-shrink-0 p-[12px]'>
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
{attachedFiles.length > 0 && (
<div className='flex flex-wrap gap-[5px] px-[10px] pt-[8px]'>
{attachedFiles.map((file) => (
<FilePill
key={file.id}
name={file.name}
type={file.type}
previewUrl={file.previewUrl}
onRemove={() => {
if (file.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(file.previewUrl)
setAttachedFiles((prev) => prev.filter((f) => f.id !== file.id))
}}
/>
))}
</div>
)}
<div className='flex items-end px-[10px] py-[6px]'>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value)
syncHeight()
}}
onKeyDown={handleKeyDown}
placeholder='Send a message…'
rows={1}
disabled={isLoading}
className={cn(
'max-h-[180px] min-h-[24px] flex-1 resize-none bg-transparent py-[2px] text-[13px] text-[var(--text-primary)] leading-[1.5]',
'placeholder:text-[var(--text-tertiary)] focus:outline-none disabled:opacity-50',
'overflow-hidden'
)}
/>
</div>
<div className='flex items-center justify-between px-[8px] pb-[6px]'>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className='flex h-[26px] w-[26px] items-center justify-center rounded-[4px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)] disabled:opacity-40'
aria-label='Attach file'
>
<Paperclip className='h-[13px] w-[13px]' />
</button>
<button
type='button'
onClick={isLoading ? handleStop : handleSend}
disabled={!isLoading && !canSend}
className={cn(
'flex h-[26px] w-[26px] items-center justify-center rounded-[4px] transition-colors',
isLoading
? 'bg-[var(--surface-4)] text-[var(--text-primary)] hover:bg-[var(--border-1)]'
: canSend
? 'bg-[var(--text-primary)] text-[var(--surface-1)] hover:opacity-85'
: 'cursor-not-allowed text-[var(--text-tertiary)]'
)}
aria-label={isLoading ? 'Stop' : 'Send'}
>
{isLoading ? (
<Square className='h-[10px] w-[10px]' />
) : (
<Send className='h-[12px] w-[12px]' />
)}
</button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type='file'
multiple
className='hidden'
onChange={handleFileChange}
accept={CHAT_ACCEPT_ATTRIBUTE}
/>
</div>
)
}

View File

@@ -1,52 +0,0 @@
'use client'
import {
Modal,
ModalBody,
ModalContent,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
} from '@/components/emcn'
import { ApiDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/api-deploy'
import { SlackDeploy } from '@/app/workspace/[workspaceId]/agents/[agentId]/components/agent-deploy/slack-deploy'
interface DeployModalProps {
agentId: string
workspaceId: string
isDeployed: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeployModal({
agentId,
workspaceId,
isDeployed,
open,
onOpenChange,
}: DeployModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='md'>
<ModalHeader>Deploy agent</ModalHeader>
<ModalTabs defaultValue='slack'>
<ModalTabsList>
<ModalTabsTrigger value='slack'>Slack</ModalTabsTrigger>
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='slack'>
<SlackDeploy agentId={agentId} workspaceId={workspaceId} />
</ModalTabsContent>
<ModalTabsContent value='api'>
<ApiDeploy agentId={agentId} workspaceId={workspaceId} isDeployed={isDeployed} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
</ModalContent>
</Modal>
)
}

View File

@@ -1,154 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('AgentWand')
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
interface UseAgentWandOptions {
/** System prompt passed verbatim to /api/wand. Use {context} as a placeholder for the current value. */
systemPrompt: string
generationType?: string
maintainHistory?: boolean
currentValue: string
onGeneratedContent: (content: string) => void
}
export interface AgentWandState {
isSearchActive: boolean
searchQuery: string
isStreaming: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
onSearchClick: () => void
onSearchBlur: () => void
onSearchChange: (value: string) => void
onSearchSubmit: () => void
onSearchCancel: () => void
}
export function useAgentWand({
systemPrompt,
generationType,
maintainHistory = false,
currentValue,
onGeneratedContent,
}: UseAgentWandOptions): AgentWandState {
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [history, setHistory] = useState<ChatMessage[]>([])
const searchInputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
const searchQueryRef = useRef(searchQuery)
searchQueryRef.current = searchQuery
const onSearchClick = useCallback(() => {
setIsSearchActive(true)
setTimeout(() => searchInputRef.current?.focus(), 50)
}, [])
const onSearchBlur = useCallback(() => {
if (!searchQueryRef.current.trim() && !abortRef.current) {
setIsSearchActive(false)
}
}, [])
const onSearchChange = useCallback((value: string) => {
setSearchQuery(value)
}, [])
const onSearchCancel = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setIsSearchActive(false)
setSearchQuery('')
setIsStreaming(false)
}, [])
const onSearchSubmit = useCallback(async () => {
const prompt = searchQueryRef.current
if (!prompt.trim()) return
const resolvedSystemPrompt = systemPrompt.replace('{context}', currentValue)
setSearchQuery('')
setIsSearchActive(false)
setIsStreaming(true)
const controller = new AbortController()
abortRef.current = controller
try {
const res = await fetch('/api/wand', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
systemPrompt: resolvedSystemPrompt,
stream: true,
generationType,
history: maintainHistory ? history : [],
}),
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error('Wand generation failed')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
let accumulated = ''
outer: while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const parsed = JSON.parse(line.slice(6))
if (parsed.done) break outer
if (parsed.chunk) {
accumulated += parsed.chunk
onGeneratedContent(accumulated)
}
} catch {}
}
}
if (maintainHistory) {
setHistory((prev) => [
...prev,
{ role: 'user', content: prompt },
{ role: 'assistant', content: accumulated },
])
}
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
logger.error('Wand generation failed', { error })
}
} finally {
setIsStreaming(false)
abortRef.current = null
}
}, [systemPrompt, generationType, maintainHistory, currentValue, history, onGeneratedContent])
return {
isSearchActive,
searchQuery,
isStreaming,
searchInputRef,
onSearchClick,
onSearchBlur,
onSearchChange,
onSearchSubmit,
onSearchCancel,
}
}

View File

@@ -1,3 +0,0 @@
export default function AgentDetailLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -1,38 +0,0 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { AgentDetail } from '@/app/workspace/[workspaceId]/agents/[agentId]/agent-detail'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agent',
}
interface AgentDetailPageProps {
params: Promise<{
workspaceId: string
agentId: string
}>
}
export default async function AgentDetailPage({ params }: AgentDetailPageProps) {
const { workspaceId, agentId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <AgentDetail agentId={agentId} workspaceId={workspaceId} />
}

View File

@@ -1,224 +0,0 @@
'use client'
import { useCallback, useDeferredValue, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { AgentContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-context-menu'
import { AgentListContextMenu } from '@/app/workspace/[workspaceId]/agents/components/agent-list-context-menu'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
ownerCell,
Resource,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
useAgentsList,
useCreateAgent,
useDeleteAgent,
useUpdateAgent,
} from '@/hooks/queries/agents'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useInlineRename } from '@/hooks/use-inline-rename'
const logger = createLogger('Agents')
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'model', header: 'Model' },
{ id: 'status', header: 'Status' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Agents() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { data: agents = [], isLoading } = useAgentsList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const { mutateAsync: createAgent, isPending: isCreating } = useCreateAgent()
const { mutateAsync: deleteAgent } = useDeleteAgent()
const { mutateAsync: updateAgent } = useUpdateAgent()
const userPermissions = useUserPermissionsContext()
const [activeAgentId, setActiveAgentId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const deferredSearch = useDeferredValue(searchQuery)
const listRename = useInlineRename({
onSave: (agentId, name) => updateAgent({ agentId, name }),
})
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
)
return
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (isRowContextMenuOpen || listRename.editingId === rowId) return
router.push(`/workspace/${workspaceId}/agents/${rowId}`)
},
[isRowContextMenuOpen, listRename.editingId, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setActiveAgentId(rowId)
handleRowCtxMenu(e)
},
[handleRowCtxMenu]
)
const handleDelete = useCallback(async () => {
if (!activeAgentId) return
setIsDeleting(true)
try {
await deleteAgent({ agentId: activeAgentId })
closeRowContextMenu()
setActiveAgentId(null)
} catch (error) {
logger.error('Failed to delete agent', { error })
} finally {
setIsDeleting(false)
}
}, [activeAgentId, deleteAgent, closeRowContextMenu])
const handleCreateAgent = useCallback(async () => {
const existingNames = new Set(agents.map((a) => a.name))
let counter = 1
while (existingNames.has(`Agent ${counter}`)) counter++
const name = `Agent ${counter}`
try {
const agent = await createAgent({ workspaceId, name })
router.push(`/workspace/${workspaceId}/agents/${agent.id}`)
} catch (error) {
logger.error('Failed to create agent', { error })
}
}, [agents, createAgent, router, workspaceId])
const handleRename = useCallback(() => {
if (!activeAgentId) return
const agent = agents.find((a) => a.id === activeAgentId)
if (agent) listRename.startRename(activeAgentId, agent.name)
closeRowContextMenu()
}, [activeAgentId, agents, listRename, closeRowContextMenu])
const rows: ResourceRow[] = useMemo(() => {
const filtered = deferredSearch
? agents.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()))
: agents
return filtered.map((agent) => ({
id: agent.id,
cells: {
name: {
icon: <AgentIcon className='h-[14px] w-[14px]' />,
label: agent.name,
content:
listRename.editingId === agent.id ? (
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-body)]'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<AgentIcon className='h-[14px] w-[14px]' />
</span>
<InlineRenameInput
value={listRename.editValue}
onChange={listRename.setEditValue}
onSubmit={listRename.submitRename}
onCancel={listRename.cancelRename}
/>
</span>
) : undefined,
},
model: { label: agent.config.model ?? '—' },
status: { label: agent.isDeployed ? 'Deployed' : 'Draft' },
created: timeCell(agent.createdAt),
owner: ownerCell(agent.createdBy, members),
updated: timeCell(agent.updatedAt),
},
sortValues: {
created: -new Date(agent.createdAt).getTime(),
updated: -new Date(agent.updatedAt).getTime(),
},
}))
}, [agents, deferredSearch, members, listRename.editingId, listRename.editValue])
return (
<>
<Resource
icon={AgentIcon}
title='Agents'
create={{
label: 'New agent',
onClick: handleCreateAgent,
disabled: userPermissions.canEdit !== true || isCreating,
}}
search={{
value: searchQuery,
onChange: setSearchQuery,
placeholder: 'Search agents…',
}}
defaultSort='created'
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
/>
<AgentListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onAddAgent={handleCreateAgent}
disableAdd={userPermissions.canEdit !== true || isCreating}
/>
{activeAgentId && (
<AgentContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onOpen={() => {
router.push(`/workspace/${workspaceId}/agents/${activeAgentId}`)
closeRowContextMenu()
}}
onRename={handleRename}
onDelete={handleDelete}
isDeleting={isDeleting}
disableEdit={userPermissions.canEdit !== true}
/>
)}
</>
)
}

View File

@@ -1,77 +0,0 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Loader, Pencil, SquareArrowUpRight, Trash } from '@/components/emcn/icons'
interface AgentContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen?: () => void
onRename?: () => void
onDelete?: () => void
isDeleting?: boolean
disableEdit?: boolean
}
export function AgentContextMenu({
isOpen,
position,
onClose,
onOpen,
onRename,
onDelete,
isDeleting = false,
disableEdit = false,
}: AgentContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onOpen && (
<DropdownMenuItem onSelect={onOpen}>
<SquareArrowUpRight />
Open
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onDelete && (onOpen || onRename) && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableEdit || isDeleting} onSelect={onDelete}>
{isDeleting ? <Loader className='animate-spin' /> : <Trash />}
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,57 +0,0 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus } from '@/components/emcn/icons'
interface AgentListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onAddAgent?: () => void
disableAdd?: boolean
}
export function AgentListContextMenu({
isOpen,
position,
onClose,
onAddAgent,
disableAdd = false,
}: AgentListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onAddAgent && (
<DropdownMenuItem disabled={disableAdd} onSelect={onAddAgent}>
<Plus />
New agent
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,3 +0,0 @@
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
return <div className='flex h-full flex-1 flex-col overflow-hidden'>{children}</div>
}

View File

@@ -1,63 +0,0 @@
import { Skeleton } from '@/components/emcn'
const SKELETON_ROW_COUNT = 5
const COLUMN_COUNT = 6
export default function AgentsLoading() {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='h-[14px] w-[96px] rounded-[4px]' />
</div>
<div className='flex items-center gap-[6px]'>
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
</div>
</div>
</div>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
<Skeleton className='ml-[10px] h-[14px] w-[160px] rounded-[4px]' />
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full'>
<thead>
<tr className='border-[var(--border)] border-b'>
<th className='w-[40px] px-[12px] py-[8px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</th>
{Array.from({ length: COLUMN_COUNT }).map((_, i) => (
<th key={i} className='px-[12px] py-[8px] text-left'>
<Skeleton className='h-[12px] w-[56px] rounded-[4px]' />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, rowIndex) => (
<tr key={rowIndex} className='border-[var(--border)] border-b'>
<td className='w-[40px] px-[12px] py-[10px]'>
<Skeleton className='h-[14px] w-[14px] rounded-[2px]' />
</td>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<td key={colIndex} className='px-[12px] py-[10px]'>
<Skeleton
className='h-[14px] rounded-[4px]'
style={{ width: colIndex === 0 ? '128px' : '80px' }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -1,37 +0,0 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { Agents } from '@/app/workspace/[workspaceId]/agents/agents'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const metadata: Metadata = {
title: 'Agents',
}
interface AgentsPageProps {
params: Promise<{
workspaceId: string
}>
}
export default async function AgentsPage({ params }: AgentsPageProps) {
const { workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
redirect('/')
}
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideAgentsTab) {
redirect(`/workspace/${workspaceId}`)
}
return <Agents />
}

View File

@@ -0,0 +1,2 @@
export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'

View File

@@ -0,0 +1,76 @@
import type { Step } from 'react-joyride'
export const navTourSteps: Step[] = [
{
target: '[data-tour="nav-home"]',
title: 'Home',
content:
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
placement: 'right',
disableBeacon: true,
spotlightPadding: 0,
},
{
target: '[data-tour="nav-search"]',
title: 'Search',
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
placement: 'right',
disableBeacon: true,
spotlightPadding: 0,
},
{
target: '[data-tour="nav-tables"]',
title: 'Tables',
content:
'Store and query structured data. Your workflows can read and write to tables directly.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-files"]',
title: 'Files',
content: 'Upload and manage files that your workflows can process, transform, or reference.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-knowledge-base"]',
title: 'Knowledge Base',
content:
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-scheduled-tasks"]',
title: 'Scheduled Tasks',
content:
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-logs"]',
title: 'Logs',
content:
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-tasks"]',
title: 'Tasks',
content:
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="nav-workflows"]',
title: 'Workflows',
content:
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
placement: 'right',
disableBeacon: true,
},
]

View File

@@ -0,0 +1,63 @@
'use client'
import { useMemo } from 'react'
import dynamic from 'next/dynamic'
import { usePathname } from 'next/navigation'
import { navTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps'
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
import {
getSharedJoyrideProps,
TourStateContext,
TourTooltipAdapter,
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
export function NavTour() {
const pathname = usePathname()
const isWorkflowPage = /\/w\/[^/]+/.test(pathname)
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: navTourSteps,
storageKey: NAV_TOUR_STORAGE_KEY,
autoStartDelay: 1200,
resettable: true,
triggerEvent: START_NAV_TOUR_EVENT,
tourName: 'Navigation tour',
disabled: isWorkflowPage,
})
const tourState = useMemo<TourState>(
() => ({
isTooltipVisible,
isEntrance,
totalSteps: navTourSteps.length,
}),
[isTooltipVisible, isEntrance]
)
return (
<TourStateContext.Provider value={tourState}>
<Joyride
key={tourKey}
steps={navTourSteps}
run={run}
stepIndex={stepIndex}
callback={handleCallback}
continuous
disableScrolling
disableScrollParentFix
disableOverlayClose
spotlightPadding={4}
tooltipComponent={TourTooltipAdapter}
{...getSharedJoyrideProps({ spotlightBorderRadius: 8 })}
/>
</TourStateContext.Provider>
)
}

View File

@@ -0,0 +1,167 @@
'use client'
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { TooltipRenderProps } from 'react-joyride'
import { TourTooltip } from '@/components/emcn'
/** Shared state passed from the tour component to the tooltip adapter via context */
export interface TourState {
isTooltipVisible: boolean
isEntrance: boolean
totalSteps: number
}
export const TourStateContext = createContext<TourState>({
isTooltipVisible: true,
isEntrance: true,
totalSteps: 0,
})
/**
* Maps Joyride placement strings to TourTooltip placement values.
*/
function mapPlacement(placement?: string): 'top' | 'right' | 'bottom' | 'left' | 'center' {
switch (placement) {
case 'top':
case 'top-start':
case 'top-end':
return 'top'
case 'right':
case 'right-start':
case 'right-end':
return 'right'
case 'bottom':
case 'bottom-start':
case 'bottom-end':
return 'bottom'
case 'left':
case 'left-start':
case 'left-end':
return 'left'
case 'center':
return 'center'
default:
return 'bottom'
}
}
/**
* Adapter that bridges Joyride's tooltip render props to the EMCN TourTooltip component.
* Reads transition state from TourStateContext to coordinate fade animations.
*/
export function TourTooltipAdapter({
step,
index,
isLastStep,
tooltipProps,
primaryProps,
backProps,
closeProps,
}: TooltipRenderProps) {
const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
const [targetEl, setTargetEl] = useState<HTMLElement | null>(null)
useEffect(() => {
const { target } = step
if (typeof target === 'string') {
setTargetEl(document.querySelector<HTMLElement>(target))
} else if (target instanceof HTMLElement) {
setTargetEl(target)
} else {
setTargetEl(null)
}
}, [step])
/**
* Forwards the Joyride tooltip ref safely, handling both
* callback refs and RefObject refs from the library.
* Memoized to prevent ref churn (null → node cycling) on re-renders.
*/
const setJoyrideRef = useCallback(
(node: HTMLDivElement | null) => {
const { ref } = tooltipProps
if (!ref) return
if (typeof ref === 'function') {
ref(node)
} else {
;(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[tooltipProps.ref]
)
const placement = mapPlacement(step.placement)
return (
<>
<div
ref={setJoyrideRef}
role={tooltipProps.role}
aria-modal={tooltipProps['aria-modal']}
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}
/>
<TourTooltip
title={step.title as string}
description={step.content}
step={index + 1}
totalSteps={totalSteps}
placement={placement}
targetEl={targetEl}
isFirst={index === 0}
isLast={isLastStep}
isVisible={isTooltipVisible}
isEntrance={isEntrance && index === 0}
onNext={primaryProps.onClick as () => void}
onBack={backProps.onClick as () => void}
onClose={closeProps.onClick as () => void}
/>
</>
)
}
const SPOTLIGHT_TRANSITION =
'top 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), left 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
/**
* Returns the shared Joyride floaterProps and styles config used by both tours.
* Only `spotlightPadding` and spotlight `borderRadius` differ between tours.
*/
export function getSharedJoyrideProps(overrides: { spotlightBorderRadius: number }) {
return {
floaterProps: {
disableAnimation: true,
hideArrow: true,
styles: {
floater: {
filter: 'none',
opacity: 0,
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
width: 0,
height: 0,
},
},
},
styles: {
options: {
zIndex: 10000,
},
spotlight: {
backgroundColor: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: overrides.spotlightBorderRadius,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.55)',
position: 'fixed' as React.CSSProperties['position'],
transition: SPOTLIGHT_TRANSITION,
},
overlay: {
backgroundColor: 'transparent',
mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
position: 'fixed' as React.CSSProperties['position'],
height: '100%',
overflow: 'visible',
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
},
},
} as const
}

View File

@@ -0,0 +1,277 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
const logger = createLogger('useTour')
/** Transition delay before updating step index (ms) */
const FADE_OUT_MS = 80
interface UseTourOptions {
/** Tour step definitions */
steps: Step[]
/** localStorage key for completion persistence */
storageKey: string
/** Delay before auto-starting the tour (ms) */
autoStartDelay?: number
/** Whether this tour can be reset/retriggered */
resettable?: boolean
/** Custom event name to listen for manual triggers */
triggerEvent?: string
/** Identifier for logging */
tourName?: string
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
disabled?: boolean
}
interface UseTourReturn {
/** Whether the tour is currently running */
run: boolean
/** Current step index */
stepIndex: number
/** Key to force Joyride remount on retrigger */
tourKey: number
/** Whether the tooltip is visible (false during step transitions) */
isTooltipVisible: boolean
/** Whether this is the initial entrance animation */
isEntrance: boolean
/** Joyride callback handler */
handleCallback: (data: CallBackProps) => void
}
function isTourCompleted(storageKey: string): boolean {
try {
return localStorage.getItem(storageKey) === 'true'
} catch {
return false
}
}
function markTourCompleted(storageKey: string): void {
try {
localStorage.setItem(storageKey, 'true')
} catch {
logger.warn('Failed to persist tour completion', { storageKey })
}
}
function clearTourCompletion(storageKey: string): void {
try {
localStorage.removeItem(storageKey)
} catch {
logger.warn('Failed to clear tour completion', { storageKey })
}
}
/**
* Tracks which tours have already attempted auto-start in this page session.
* Module-level so it survives component remounts (e.g. navigating between
* workflows remounts WorkflowTour), while still resetting on full page reload.
*/
const autoStartAttempted = new Set<string>()
/**
* Shared hook for managing product tour state with smooth transitions.
*
* Handles auto-start on first visit, localStorage persistence,
* manual triggering via custom events, and coordinated fade
* transitions between steps to prevent layout shift.
*/
export function useTour({
steps,
storageKey,
autoStartDelay = 1200,
resettable = false,
triggerEvent,
tourName = 'tour',
disabled = false,
}: UseTourOptions): UseTourReturn {
const [run, setRun] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [tourKey, setTourKey] = useState(0)
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
const [isEntrance, setIsEntrance] = useState(true)
const disabledRef = useRef(disabled)
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
disabledRef.current = disabled
}, [disabled])
/**
* Schedules a two-frame rAF to reveal the tooltip after the browser
* finishes repositioning. Stores the outer frame ID in `rafRef` so
* it can be cancelled on unmount or when the tour is interrupted.
*/
const scheduleReveal = useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
setIsTooltipVisible(true)
})
})
}, [])
/** Cancels any pending transition timer and rAF reveal */
const cancelPendingTransitions = useCallback(() => {
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current)
transitionTimerRef.current = null
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}, [])
const stopTour = useCallback(() => {
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
markTourCompleted(storageKey)
}, [storageKey, cancelPendingTransitions])
/** Transition to a new step with a coordinated fade-out/fade-in */
const transitionToStep = useCallback(
(newIndex: number) => {
if (newIndex < 0 || newIndex >= steps.length) {
stopTour()
return
}
setIsTooltipVisible(false)
cancelPendingTransitions()
transitionTimerRef.current = setTimeout(() => {
transitionTimerRef.current = null
setStepIndex(newIndex)
setIsEntrance(false)
scheduleReveal()
}, FADE_OUT_MS)
},
[steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
)
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
useEffect(() => {
if (disabled && run) {
cancelPendingTransitions()
setRun(false)
setIsTooltipVisible(true)
setIsEntrance(true)
logger.info(`${tourName} paused — disabled became true`)
}
}, [disabled, run, tourName, cancelPendingTransitions])
/** Auto-start on first visit (once per page session per tour) */
useEffect(() => {
if (disabled || autoStartAttempted.has(storageKey) || isTourCompleted(storageKey)) return
const timer = setTimeout(() => {
if (disabledRef.current) return
autoStartAttempted.add(storageKey)
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`Auto-starting ${tourName}`)
scheduleReveal()
}, autoStartDelay)
return () => clearTimeout(timer)
}, [disabled, storageKey, autoStartDelay, tourName, scheduleReveal])
/** Listen for manual trigger events */
useEffect(() => {
if (!triggerEvent || !resettable) return
const handleTrigger = () => {
setRun(false)
clearTourCompletion(storageKey)
setTourKey((k) => k + 1)
if (retriggerTimerRef.current) {
clearTimeout(retriggerTimerRef.current)
}
retriggerTimerRef.current = setTimeout(() => {
retriggerTimerRef.current = null
setStepIndex(0)
setIsEntrance(true)
setIsTooltipVisible(false)
setRun(true)
logger.info(`${tourName} triggered via event`)
scheduleReveal()
}, 50)
}
window.addEventListener(triggerEvent, handleTrigger)
return () => {
window.removeEventListener(triggerEvent, handleTrigger)
if (retriggerTimerRef.current) {
clearTimeout(retriggerTimerRef.current)
}
}
}, [triggerEvent, resettable, storageKey, tourName, scheduleReveal])
/** Clean up all pending async work on unmount */
useEffect(() => {
return () => {
cancelPendingTransitions()
if (retriggerTimerRef.current) {
clearTimeout(retriggerTimerRef.current)
}
}
}, [cancelPendingTransitions])
const handleCallback = useCallback(
(data: CallBackProps) => {
const { action, index, status, type } = data
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
stopTour()
logger.info(`${tourName} ended`, { status })
return
}
if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
if (action === ACTIONS.CLOSE) {
stopTour()
logger.info(`${tourName} closed by user`)
return
}
const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)
if (type === EVENTS.TARGET_NOT_FOUND) {
logger.info(`${tourName} step target not found, skipping`, {
stepIndex: index,
target: steps[index]?.target,
})
}
transitionToStep(nextIndex)
}
},
[stopTour, transitionToStep, steps, tourName]
)
return {
run,
stepIndex,
tourKey,
isTooltipVisible,
isEntrance,
handleCallback,
}
}

View File

@@ -0,0 +1,56 @@
import type { Step } from 'react-joyride'
export const workflowTourSteps: Step[] = [
{
target: '[data-tour="canvas"]',
title: 'The Canvas',
content:
'This is where you build visually. Drag blocks onto the canvas and connect them to create AI workflows.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="tab-copilot"]',
title: 'AI Copilot',
content:
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
placement: 'bottom',
disableBeacon: true,
spotlightPadding: 0,
},
{
target: '[data-tour="tab-toolbar"]',
title: 'Block Library',
content:
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
placement: 'bottom',
disableBeacon: true,
spotlightPadding: 0,
},
{
target: '[data-tour="tab-editor"]',
title: 'Block Editor',
content:
'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',
placement: 'bottom',
disableBeacon: true,
spotlightPadding: 0,
},
{
target: '[data-tour="deploy-run"]',
title: 'Deploy & Run',
content:
'Deploy your workflow as an API, webhook, schedule, or chat widget. Then hit Run to test it out.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tour="workflow-controls"]',
title: 'Canvas Controls',
content:
'Switch between pointer and hand mode, undo or redo changes, and fit the canvas to your view.',
placement: 'top',
spotlightPadding: 0,
disableBeacon: true,
},
]

View File

@@ -0,0 +1,62 @@
'use client'
import { useMemo } from 'react'
import dynamic from 'next/dynamic'
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
import {
getSharedJoyrideProps,
TourStateContext,
TourTooltipAdapter,
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
import { workflowTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps'
const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
/**
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
* Runs on first workflow visit and can be retriggered via "Take a tour".
*/
export function WorkflowTour() {
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
steps: workflowTourSteps,
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
autoStartDelay: 800,
resettable: true,
triggerEvent: START_WORKFLOW_TOUR_EVENT,
tourName: 'Workflow tour',
})
const tourState = useMemo<TourState>(
() => ({
isTooltipVisible,
isEntrance,
totalSteps: workflowTourSteps.length,
}),
[isTooltipVisible, isEntrance]
)
return (
<TourStateContext.Provider value={tourState}>
<Joyride
key={tourKey}
steps={workflowTourSteps}
run={run}
stepIndex={stepIndex}
callback={handleCallback}
continuous
disableScrolling
disableScrollParentFix
disableOverlayClose
spotlightPadding={1}
tooltipComponent={TourTooltipAdapter}
{...getSharedJoyrideProps({ spotlightBorderRadius: 6 })}
/>
</TourStateContext.Provider>
)
}

View File

@@ -3,6 +3,7 @@ export {
assistantMessageHasRenderableContent,
MessageContent,
} from './message-content'
export { MothershipChat } from './mothership-chat/mothership-chat'
export { MothershipView } from './mothership-view'
export { QueuedMessages } from './queued-messages'
export { TemplatePrompts } from './template-prompts'

View File

@@ -44,6 +44,21 @@ export function AgentGroup({
const [expanded, setExpanded] = useState(defaultExpanded || !allDone)
const [mounted, setMounted] = useState(defaultExpanded || !allDone)
const didAutoCollapseRef = useRef(allDone)
const wasAutoExpandedRef = useRef(defaultExpanded)
useEffect(() => {
if (defaultExpanded) {
wasAutoExpandedRef.current = true
setMounted(true)
setExpanded(true)
return
}
if (wasAutoExpandedRef.current && allDone) {
wasAutoExpandedRef.current = false
setExpanded(false)
}
}, [defaultExpanded, allDone])
useEffect(() => {
if (!autoCollapse || didAutoCollapseRef.current) return
@@ -65,7 +80,10 @@ export function AgentGroup({
{hasItems ? (
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
onClick={() => {
wasAutoExpandedRef.current = false
setExpanded((prev) => !prev)
}}
className='flex cursor-pointer items-center gap-[8px]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>

View File

@@ -389,7 +389,7 @@ export function MessageContent({
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<AgentGroup
key={`${segment.id}-${segment.id === lastOpenSubagentGroupId ? 'expanded' : 'default'}`}
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}

View File

@@ -0,0 +1,190 @@
'use client'
import { useLayoutEffect, useRef } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import {
assistantMessageHasRenderableContent,
MessageContent,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import { UserInput } from '@/app/workspace/[workspaceId]/home/components/user-input'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import { useAutoScroll } from '@/app/workspace/[workspaceId]/home/hooks'
import type {
ChatMessage,
FileAttachmentForApi,
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
interface MothershipChatProps {
messages: ChatMessage[]
isSending: boolean
onSubmit: (
text: string,
fileAttachments?: FileAttachmentForApi[],
contexts?: ChatContext[]
) => void
onStopGeneration: () => void
messageQueue: QueuedMessage[]
onRemoveQueuedMessage: (id: string) => void
onSendQueuedMessage: (id: string) => Promise<void>
onEditQueuedMessage: (id: string) => void
userId?: string
onContextAdd?: (context: ChatContext) => void
editValue?: string
onEditValueConsumed?: () => void
layout?: 'mothership-view' | 'copilot-view'
initialScrollBlocked?: boolean
animateInput?: boolean
onInputAnimationEnd?: () => void
className?: string
}
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]',
content: 'mx-auto max-w-[42rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg relative pb-5',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
footerInner: 'mx-auto max-w-[42rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
content: 'space-y-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
attachmentWidth: 'max-w-[85%]',
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
assistantRow: 'group/msg relative pb-3',
footer: 'flex-shrink-0 px-3 pb-3',
footerInner: '',
},
} as const
export function MothershipChat({
messages,
isSending,
onSubmit,
onStopGeneration,
messageQueue,
onRemoveQueuedMessage,
onSendQueuedMessage,
onEditQueuedMessage,
userId,
onContextAdd,
editValue,
onEditValueConsumed,
layout = 'mothership-view',
initialScrollBlocked = false,
animateInput = false,
onInputAnimationEnd,
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current || initialScrollBlocked) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, initialScrollBlocked, scrollToBottom])
return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
<div className={styles.content}>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = Boolean(msg.attachments?.length)
return (
<div key={msg.id} className={styles.userRow}>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments ?? []}
align='end'
className={styles.attachmentWidth}
/>
)}
<div className={styles.userBubble}>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className={styles.assistantRow}>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? onSubmit : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={cn(styles.footer, animateInput && 'animate-slide-in-bottom')}
onAnimationEnd={animateInput ? onInputAnimationEnd : undefined}
>
<div className={styles.footerInner}>
<QueuedMessages
messageQueue={messageQueue}
onRemove={onRemoveQueuedMessage}
onSendNow={onSendQueuedMessage}
onEdit={onEditQueuedMessage}
/>
<UserInput
onSubmit={onSubmit}
isSending={isSending}
onStopGeneration={onStopGeneration}
isInitialView={false}
userId={userId}
onContextAdd={onContextAdd}
editValue={editValue}
onEditValueConsumed={onEditValueConsumed}
/>
</div>
</div>
</div>
)
}

View File

@@ -106,6 +106,7 @@ const SEND_BUTTON_ACTIVE =
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
const MAX_CHAT_TEXTAREA_HEIGHT = 200
const SPEECH_RECOGNITION_LANG = 'en-US'
const DROP_OVERLAY_ICONS = [
PdfIcon,
@@ -267,6 +268,7 @@ export function UserInput({
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null)
const prefixRef = useRef('')
const valueRef = useRef(value)
useEffect(() => {
return () => {
@@ -274,6 +276,10 @@ export function UserInput({
}
}, [])
useEffect(() => {
valueRef.current = value
}, [value])
const textareaRef = mentionMenu.textareaRef
const wasSendingRef = useRef(false)
const atInsertPosRef = useRef<number | null>(null)
@@ -390,6 +396,84 @@ export function UserInput({
[textareaRef]
)
const startRecognition = useCallback((): boolean => {
const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return false
const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = SPEECH_RECOGNITION_LANG
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
const newVal = prefix ? `${prefix} ${transcript}` : transcript
setValue(newVal)
valueRef.current = newVal
}
recognition.onend = () => {
if (recognitionRef.current === recognition) {
prefixRef.current = valueRef.current
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (recognitionRef.current !== recognition) return
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}
recognitionRef.current = recognition
try {
recognition.start()
return true
} catch {
recognitionRef.current = null
return false
}
}, [])
const restartRecognition = useCallback(
(newPrefix: string) => {
if (!recognitionRef.current) return
prefixRef.current = newPrefix
recognitionRef.current.abort()
recognitionRef.current = null
if (!startRecognition()) {
setIsListening(false)
}
},
[startRecognition]
)
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}
prefixRef.current = value
if (startRecognition()) {
setIsListening(true)
}
}, [isListening, value, startRecognition])
const handleSubmit = useCallback(() => {
const fileAttachmentsForApi: FileAttachmentForApi[] = files.attachedFiles
.filter((f) => !f.uploading && f.key)
@@ -407,13 +491,14 @@ export function UserInput({
contextManagement.selectedContexts.length > 0 ? contextManagement.selectedContexts : undefined
)
setValue('')
restartRecognition('')
files.clearAttachedFiles()
contextManagement.clearContexts()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [onSubmit, files, value, contextManagement, textareaRef])
}, [onSubmit, files, value, contextManagement, textareaRef, restartRecognition])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -488,27 +573,33 @@ export function UserInput({
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
setValue(`${before}${after}`)
atInsertPosRef.current = caret - 1
setPlusMenuOpen(true)
setPlusMenuSearch('')
setPlusMenuActiveIndex(0)
return
}
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
setPlusMenuOpen(true)
setPlusMenuSearch('')
setPlusMenuActiveIndex(0)
restartRecognition(adjusted)
return
}
setValue(newValue)
}, [])
setValue(newValue)
restartRecognition(newValue)
},
[restartRecognition]
)
const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
@@ -536,56 +627,6 @@ export function UserInput({
[isInitialView]
)
const toggleListening = useCallback(() => {
if (isListening) {
recognitionRef.current?.stop()
recognitionRef.current = null
setIsListening(false)
return
}
const w = window as WindowWithSpeech
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
if (!SpeechRecognitionAPI) return
prefixRef.current = value
const recognition = new SpeechRecognitionAPI()
recognition.continuous = true
recognition.interimResults = true
recognition.lang = 'en-US'
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = ''
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript
}
const prefix = prefixRef.current
setValue(prefix ? `${prefix} ${transcript}` : transcript)
}
recognition.onend = () => {
if (recognitionRef.current === recognition) {
try {
recognition.start()
} catch {
recognitionRef.current = null
setIsListening(false)
}
}
}
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
if (e.error === 'aborted' || e.error === 'not-allowed') {
recognitionRef.current = null
setIsListening(false)
}
}
recognitionRef.current = recognition
recognition.start()
setIsListening(true)
}, [isListening, value])
const renderOverlayContent = useCallback(() => {
const contexts = contextManagement.selectedContexts

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { PanelLeft } from '@/components/emcn/icons'
@@ -11,21 +11,10 @@ import {
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import {
assistantMessageHasRenderableContent,
ChatMessageAttachments,
MessageContent,
MothershipView,
QueuedMessages,
TemplatePrompts,
UserInput,
UserMessageContent,
} from './components'
import { PendingTagIndicator } from './components/message-content/components/special-tags'
import { useAutoScroll, useChat, useMothershipResize } from './hooks'
import { MothershipChat, MothershipView, TemplatePrompts, UserInput } from './components'
import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks'
import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types'
const logger = createLogger('Home')
@@ -173,7 +162,11 @@ export function Home({ chatId }: HomeProps = {}) {
sendNow,
editQueuedMessage,
streamingFile,
} = useChat(workspaceId, chatId, { onResourceEvent: handleResourceEvent })
} = useChat(
workspaceId,
chatId,
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
)
const [editingInputValue, setEditingInputValue] = useState('')
const [prevChatId, setPrevChatId] = useState(chatId)
@@ -285,22 +278,7 @@ export function Home({ chatId }: HomeProps = {}) {
[addResource, handleResourceEvent]
)
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isSending)
const hasMessages = messages.length > 0
const initialScrollDoneRef = useRef(false)
useLayoutEffect(() => {
if (!hasMessages) {
initialScrollDoneRef.current = false
return
}
if (initialScrollDoneRef.current) return
if (resources.length > 0 && isResourceCollapsed) return
initialScrollDoneRef.current = true
scrollToBottom()
}, [hasMessages, resources.length, isResourceCollapsed, scrollToBottom])
useEffect(() => {
if (hasMessages) return
@@ -322,11 +300,14 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
<h1
data-tour='home-greeting'
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'
>
What should we get done
{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}?
</h1>
<div ref={initialViewInputRef} className='w-full'>
<div ref={initialViewInputRef} className='w-full' data-tour='home-chat-input'>
<UserInput
defaultValue={initialPrompt}
onSubmit={handleSubmit}
@@ -339,6 +320,7 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
<div
ref={templateRef}
data-tour='home-templates'
className='-mt-[30vh] mx-auto w-full max-w-[68rem] px-[16px] pb-[32px] sm:px-[24px] lg:px-[40px]'
>
<TemplatePrompts onSelect={handleSubmit} />
@@ -350,90 +332,23 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='relative flex h-full bg-[var(--bg)]'>
<div className='flex h-full min-w-[320px] flex-1 flex-col'>
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable]'
>
<div className='mx-auto max-w-[42rem] space-y-6'>
{messages.map((msg, index) => {
if (msg.role === 'user') {
const hasAttachments = msg.attachments && msg.attachments.length > 0
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-3'>
{hasAttachments && (
<ChatMessageAttachments
attachments={msg.attachments!}
align='end'
className='max-w-[70%]'
/>
)}
<div className='max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1
const isThisStreaming = isSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? sendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div
className={`flex-shrink-0 px-[24px] pb-[16px]${isInputEntering ? ' animate-slide-in-bottom' : ''}`}
onAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
>
<div className='mx-auto max-w-[42rem]'>
<QueuedMessages
messageQueue={messageQueue}
onRemove={removeFromQueue}
onSendNow={sendNow}
onEdit={handleEditQueuedMessage}
/>
<UserInput
onSubmit={handleSubmit}
isSending={isSending}
onStopGeneration={stopGeneration}
isInitialView={false}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
/>
</div>
</div>
<MothershipChat
messages={messages}
isSending={isSending}
onSubmit={handleSubmit}
onStopGeneration={stopGeneration}
messageQueue={messageQueue}
onRemoveQueuedMessage={removeFromQueue}
onSendQueuedMessage={sendNow}
onEditQueuedMessage={handleEditQueuedMessage}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
editValue={editingInputValue}
onEditValueConsumed={clearEditingValue}
animateInput={isInputEntering}
onInputAnimationEnd={isInputEntering ? () => setIsInputEntering(false) : undefined}
initialScrollBlocked={resources.length > 0 && isResourceCollapsed}
/>
</div>
{/* Resize handle — zero-width flex child whose absolute child straddles the border */}

View File

@@ -1,6 +1,10 @@
export { useAnimatedPlaceholder } from './use-animated-placeholder'
export { useAutoScroll } from './use-auto-scroll'
export type { UseChatReturn } from './use-chat'
export { useChat } from './use-chat'
export {
getMothershipUseChatOptions,
getWorkflowCopilotUseChatOptions,
useChat,
} from './use-chat'
export { useMothershipResize } from './use-mothership-resize'
export { useStreamingReveal } from './use-streaming-reveal'

View File

@@ -8,7 +8,7 @@ import {
markRunToolManuallyStopped,
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import {
extractResourcesFromToolResult,
isResourceToolName,
@@ -263,6 +263,29 @@ export interface UseChatOptions {
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
}
export function getMothershipUseChatOptions(
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
): UseChatOptions {
return {
apiPath: MOTHERSHIP_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function getWorkflowCopilotUseChatOptions(
options: Pick<
UseChatOptions,
'workflowId' | 'onToolResult' | 'onTitleUpdate' | 'onStreamEnd'
> = {}
): UseChatOptions {
return {
apiPath: COPILOT_CHAT_API_PATH,
stopPath: '/api/mothership/chat/stop',
...options,
}
}
export function useChat(
workspaceId: string,
initialChatId?: string,
@@ -323,8 +346,8 @@ export function useChat(
reader: ReadableStreamDefaultReader<Uint8Array>,
assistantId: string,
expectedGen?: number
) => Promise<void>
>(async () => {})
) => Promise<boolean>
>(async () => false)
const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {})
const abortControllerRef = useRef<AbortController | null>(null)
@@ -415,6 +438,8 @@ export function useChat(
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
}, [initialChatId, queryClient])
@@ -433,6 +458,8 @@ export function useChat(
setIsReconnecting(false)
setResources([])
setActiveResourceId(null)
setStreamingFile(null)
streamingFileRef.current = null
setMessageQueue([])
}, [isHomePage])
@@ -441,12 +468,6 @@ export function useChat(
const activeStreamId = chatHistory.activeStreamId
const snapshot = chatHistory.streamSnapshot
if (activeStreamId && !snapshot && !sendingRef.current) {
queryClient.invalidateQueries({ queryKey: taskKeys.detail(chatHistory.id) })
return
}
appliedChatIdRef.current = chatHistory.id
const mappedMessages = chatHistory.messages.map(mapStoredMessage)
const shouldPreserveActiveStreamingMessage =
@@ -497,7 +518,6 @@ export function useChat(
}
if (activeStreamId && !sendingRef.current) {
abortControllerRef.current?.abort()
const gen = ++streamGenRef.current
const abortController = new AbortController()
abortControllerRef.current = abortController
@@ -508,6 +528,7 @@ export function useChat(
const assistantId = crypto.randomUUID()
const reconnect = async () => {
let reconnectFailed = false
try {
const encoder = new TextEncoder()
@@ -515,14 +536,8 @@ export function useChat(
const streamStatus = snapshot?.status ?? ''
if (batchEvents.length === 0 && streamStatus === 'unknown') {
const cid = chatIdRef.current
if (cid) {
fetch(stopPathRef.current, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId: cid, streamId: activeStreamId, content: '' }),
}).catch(() => {})
}
reconnectFailed = true
setError(RECONNECT_TAIL_ERROR)
return
}
@@ -550,6 +565,7 @@ export function useChat(
{ signal: abortController.signal }
)
if (!sseRes.ok || !sseRes.body) {
reconnectFailed = true
logger.warn('SSE tail reconnect returned no readable body', {
status: sseRes.status,
streamId: activeStreamId,
@@ -565,6 +581,7 @@ export function useChat(
}
} catch (err) {
if (!(err instanceof Error && err.name === 'AbortError')) {
reconnectFailed = true
logger.warn('SSE tail failed during reconnect', err)
setError(RECONNECT_TAIL_ERROR)
}
@@ -575,13 +592,21 @@ export function useChat(
},
})
await processSSEStreamRef.current(combinedStream.getReader(), assistantId, gen)
const hadStreamError = await processSSEStreamRef.current(
combinedStream.getReader(),
assistantId,
gen
)
if (hadStreamError) {
reconnectFailed = true
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
reconnectFailed = true
} finally {
setIsReconnecting(false)
if (streamGenRef.current === gen) {
finalizeRef.current()
finalizeRef.current(reconnectFailed ? { error: true } : undefined)
}
}
}
@@ -619,7 +644,34 @@ export function useChat(
return b
}
const appendInlineErrorTag = (tag: string) => {
if (runningText.includes(tag)) return
const tb = ensureTextBlock()
const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : ''
tb.content = `${tb.content ?? ''}${prefix}${tag}`
if (activeSubagent) tb.subagent = activeSubagent
runningText += `${prefix}${tag}`
streamingContentRef.current = runningText
flush()
}
const buildInlineErrorTag = (payload: SSEPayload) => {
const data = getPayloadData(payload) as Record<string, unknown> | undefined
const message =
(data?.displayMessage as string | undefined) ||
payload.error ||
'An unexpected error occurred'
const provider = (data?.provider as string | undefined) || undefined
const code = (data?.code as string | undefined) || undefined
return `<mothership-error>${JSON.stringify({
message,
...(code ? { code } : {}),
...(provider ? { provider } : {}),
})}</mothership-error>`
}
const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen
let sawStreamError = false
const flush = () => {
if (isStale()) return
@@ -644,12 +696,9 @@ export function useChat(
try {
while (true) {
if (isStale()) {
reader.cancel().catch(() => {})
break
}
const { done, value } = await reader.read()
if (done) break
if (isStale()) continue
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
@@ -1113,21 +1162,20 @@ export function useChat(
break
}
case 'error': {
sawStreamError = true
setError(parsed.error || 'An error occurred')
appendInlineErrorTag(buildInlineErrorTag(parsed))
break
}
}
}
if (isStale()) {
reader.cancel().catch(() => {})
break
}
}
} finally {
if (streamReaderRef.current === reader) {
streamReaderRef.current = null
}
}
return sawStreamError
},
[workspaceId, queryClient, addResource, removeResource]
)
@@ -1354,7 +1402,10 @@ export function useChat(
if (!response.body) throw new Error('No response body')
await processSSEStream(response.body.getReader(), assistantId, gen)
const hadStreamError = await processSSEStream(response.body.getReader(), assistantId, gen)
if (streamGenRef.current === gen) {
finalize(hadStreamError ? { error: true } : undefined)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Failed to send message')
@@ -1363,9 +1414,6 @@ export function useChat(
}
return
}
if (streamGenRef.current === gen) {
finalize()
}
},
[workspaceId, queryClient, processSSEStream, finalize]
)
@@ -1387,6 +1435,25 @@ export function useChat(
sendingRef.current = false
setIsSending(false)
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
if (sid) {
fetch('/api/copilot/chat/abort', {
method: 'POST',
@@ -1410,25 +1477,6 @@ export function useChat(
streamingFileRef.current = null
setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file'))
setMessages((prev) =>
prev.map((msg) => {
if (!msg.contentBlocks?.some((b) => b.toolCall?.status === 'executing')) return msg
const updated = msg.contentBlocks!.map((block) => {
if (block.toolCall?.status !== 'executing') return block
return {
...block,
toolCall: {
...block.toolCall,
status: 'cancelled' as const,
displayTitle: 'Stopped by user',
},
}
})
updated.push({ type: 'stopped' as const })
return { ...msg, contentBlocks: updated }
})
)
const execState = useExecutionStore.getState()
const consoleStore = useTerminalConsoleStore.getState()
for (const [workflowId, wfExec] of execState.workflowExecutions) {
@@ -1500,7 +1548,6 @@ export function useChat(
useEffect(() => {
return () => {
streamReaderRef.current?.cancel().catch(() => {})
streamReaderRef.current = null
abortControllerRef.current = null
streamGenRef.current++

View File

@@ -0,0 +1,46 @@
'use client'
import { useState } from 'react'
import { Banner } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useStopImpersonating } from '@/hooks/queries/admin-users'
function getImpersonationBannerText(userLabel: string, userEmail?: string) {
return `Impersonating ${userLabel}${userEmail ? ` (${userEmail})` : ''}. Changes will apply to this account until you switch back.`
}
export function ImpersonationBanner() {
const { data: session, isPending } = useSession()
const stopImpersonating = useStopImpersonating()
const [isRedirecting, setIsRedirecting] = useState(false)
const userLabel = session?.user?.name || 'this user'
const userEmail = session?.user?.email
if (isPending || !session?.session?.impersonatedBy) {
return null
}
return (
<Banner
variant='destructive'
text={getImpersonationBannerText(userLabel, userEmail)}
textClassName='text-red-700 dark:text-red-300'
actionLabel={
stopImpersonating.isPending || isRedirecting ? 'Returning...' : 'Stop impersonating'
}
actionVariant='destructive'
actionDisabled={stopImpersonating.isPending || isRedirecting}
onAction={() =>
stopImpersonating.mutate(undefined, {
onError: () => {
setIsRedirecting(false)
},
onSuccess: () => {
setIsRedirecting(true)
window.location.assign('/workspace')
},
})
}
/>
)
}

View File

@@ -1,4 +1,6 @@
import { ToastProvider } from '@/components/emcn'
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/impersonation-banner'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -11,16 +13,20 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full bg-[var(--surface-1)]'>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
<div className='flex min-h-0 flex-1'>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
<div className='flex min-w-0 flex-1 flex-col p-[8px] pl-0'>
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
{children}
</div>
</div>
</div>
<NavTour />
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>

View File

@@ -36,7 +36,7 @@ function WorkflowsListInner({
searchQuery: string
segmentDurationMs: number
}) {
const { workflows } = useWorkflowRegistry()
const workflows = useWorkflowRegistry((s) => s.workflows)
return (
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-2)] dark:bg-[var(--surface-1)]'>

View File

@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Skeleton } from '@/components/emcn'
import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs'
@@ -146,7 +147,14 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) {
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
const lastAnchorIndicesRef = useRef<Record<string, number>>({})
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore()
const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore(
useShallow((s) => ({
workflowIds: s.workflowIds,
searchQuery: s.searchQuery,
toggleWorkflowId: s.toggleWorkflowId,
timeRange: s.timeRange,
}))
)
const allWorkflows = useWorkflowRegistry((state) => state.workflows)

View File

@@ -3,6 +3,7 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
Button,
Combobox,
@@ -195,7 +196,25 @@ export const LogsToolbar = memo(function LogsToolbar({
setDateRange,
clearDateRange,
resetFilters,
} = useFilterStore()
} = useFilterStore(
useShallow((s) => ({
level: s.level,
setLevel: s.setLevel,
workflowIds: s.workflowIds,
setWorkflowIds: s.setWorkflowIds,
folderIds: s.folderIds,
setFolderIds: s.setFolderIds,
triggers: s.triggers,
setTriggers: s.setTriggers,
timeRange: s.timeRange,
setTimeRange: s.setTimeRange,
startDate: s.startDate,
endDate: s.endDate,
setDateRange: s.setDateRange,
clearDateRange: s.clearDateRange,
resetFilters: s.resetFilters,
}))
)
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import {
Bell,
Button,
@@ -230,7 +231,30 @@ export default function Logs() {
setTimeRange,
setDateRange,
clearDateRange,
} = useFilterStore()
} = useFilterStore(
useShallow((s) => ({
setWorkspaceId: s.setWorkspaceId,
initializeFromURL: s.initializeFromURL,
timeRange: s.timeRange,
startDate: s.startDate,
endDate: s.endDate,
level: s.level,
workflowIds: s.workflowIds,
folderIds: s.folderIds,
setWorkflowIds: s.setWorkflowIds,
setSearchQuery: s.setSearchQuery,
triggers: s.triggers,
viewMode: s.viewMode,
setViewMode: s.setViewMode,
resetFilters: s.resetFilters,
setLevel: s.setLevel,
setFolderIds: s.setFolderIds,
setTriggers: s.setTriggers,
setTimeRange: s.setTimeRange,
setDateRange: s.setDateRange,
clearDateRange: s.clearDateRange,
}))
)
useEffect(() => {
setWorkspaceId(workspaceId)
@@ -1133,7 +1157,25 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
setDateRange,
clearDateRange,
resetFilters,
} = useFilterStore()
} = useFilterStore(
useShallow((s) => ({
level: s.level,
setLevel: s.setLevel,
workflowIds: s.workflowIds,
setWorkflowIds: s.setWorkflowIds,
folderIds: s.folderIds,
setFolderIds: s.setFolderIds,
triggers: s.triggers,
setTriggers: s.setTriggers,
timeRange: s.timeRange,
setTimeRange: s.setTimeRange,
startDate: s.startDate,
endDate: s.endDate,
setDateRange: s.setDateRange,
clearDateRange: s.clearDateRange,
resetFilters: s.resetFilters,
}))
)
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)

View File

@@ -8,6 +8,7 @@ import { cn } from '@/lib/core/utils/cn'
import {
useAdminUsers,
useBanUser,
useImpersonateUser,
useSetUserRole,
useUnbanUser,
} from '@/hooks/queries/admin-users'
@@ -28,6 +29,7 @@ export function Admin() {
const setUserRole = useSetUserRole()
const banUser = useBanUser()
const unbanUser = useUnbanUser()
const impersonateUser = useImpersonateUser()
const [workflowId, setWorkflowId] = useState('')
const [usersOffset, setUsersOffset] = useState(0)
@@ -35,6 +37,8 @@ export function Admin() {
const [searchQuery, setSearchQuery] = useState('')
const [banUserId, setBanUserId] = useState<string | null>(null)
const [banReason, setBanReason] = useState('')
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
const {
data: usersData,
@@ -67,6 +71,29 @@ export function Admin() {
)
}
const handleImpersonate = (userId: string) => {
setImpersonationGuardError(null)
if (session?.user?.role !== 'admin') {
setImpersonatingUserId(null)
setImpersonationGuardError('Only admins can impersonate users.')
return
}
setImpersonatingUserId(userId)
impersonateUser.reset()
impersonateUser.mutate(
{ userId },
{
onError: () => {
setImpersonatingUserId(null)
},
onSuccess: () => {
window.location.assign('/workspace')
},
}
)
}
const pendingUserIds = useMemo(() => {
const ids = new Set<string>()
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -75,6 +102,9 @@ export function Admin() {
ids.add((banUser.variables as { userId: string }).userId)
if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId)
ids.add((unbanUser.variables as { userId: string }).userId)
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
ids.add((impersonateUser.variables as { userId: string }).userId)
if (impersonatingUserId) ids.add(impersonatingUserId)
return ids
}, [
setUserRole.isPending,
@@ -83,6 +113,9 @@ export function Admin() {
banUser.variables,
unbanUser.isPending,
unbanUser.variables,
impersonateUser.isPending,
impersonateUser.variables,
impersonatingUserId,
])
return (
<div className='flex h-full flex-col gap-[24px]'>
@@ -152,9 +185,15 @@ export function Admin() {
</p>
)}
{(setUserRole.error || banUser.error || unbanUser.error) && (
{(setUserRole.error ||
banUser.error ||
unbanUser.error ||
impersonateUser.error ||
impersonationGuardError) && (
<p className='text-[13px] text-[var(--text-error)]'>
{(setUserRole.error || banUser.error || unbanUser.error)?.message ??
{impersonationGuardError ||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
?.message ||
'Action failed. Please try again.'}
</p>
)}
@@ -175,7 +214,7 @@ export function Admin() {
<span className='flex-1'>Email</span>
<span className='w-[80px]'>Role</span>
<span className='w-[80px]'>Status</span>
<span className='w-[180px] text-right'>Actions</span>
<span className='w-[250px] text-right'>Actions</span>
</div>
{usersData.users.length === 0 && (
@@ -206,9 +245,22 @@ export function Admin() {
<Badge variant='green'>Active</Badge>
)}
</span>
<span className='flex w-[180px] justify-end gap-[4px]'>
<span className='flex w-[250px] justify-end gap-[4px]'>
{u.id !== session?.user?.id && (
<>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'
onClick={() => handleImpersonate(u.id)}
disabled={pendingUserIds.has(u.id)}
>
{impersonatingUserId === u.id ||
(impersonateUser.isPending &&
(impersonateUser.variables as { userId?: string } | undefined)
?.userId === u.id)
? 'Switching...'
: 'Impersonate'}
</Button>
<Button
variant='active'
className='h-[28px] px-[8px] text-[12px]'

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Camera, Check, Pencil } from 'lucide-react'
import { Camera, Check, Info, Pencil } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import {
@@ -16,6 +16,7 @@ import {
ModalFooter,
ModalHeader,
Switch,
Tooltip,
} from '@/components/emcn'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
@@ -375,7 +376,22 @@ export function General() {
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='auto-connect'>Auto-connect on drop</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Automatically connect blocks when dropped near each other</p>
<Tooltip.Preview
src='/tooltips/auto-connect-on-drop.mp4'
alt='Auto-connect on drop example'
loop={false}
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='auto-connect'
checked={settings?.autoConnect ?? true}
@@ -384,7 +400,21 @@ export function General() {
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='error-notifications'>Workflow error notifications</Label>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='error-notifications'>Canvas error notifications</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Info className='h-[14px] w-[14px] cursor-default text-[var(--text-muted)]' />
</Tooltip.Trigger>
<Tooltip.Content side='bottom' align='start'>
<p>Show error popups on blocks when a workflow run fails</p>
<Tooltip.Preview
src='/tooltips/canvas-error-notification.mp4'
alt='Canvas error notification example'
/>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
@@ -461,7 +491,7 @@ export function General() {
)}
{isHosted && (
<Button
onClick={() => window.open('/?from=settings', '_blank', 'noopener,noreferrer')}
onClick={() => window.open('/?home', '_blank', 'noopener,noreferrer')}
variant='active'
className='ml-auto'
>

View File

@@ -1,287 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import { useCallback, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Input, Label, Textarea } from '@/components/emcn'
import { Upload } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
interface ImportedSkill {
name: string
description: string
content: string
}
interface SkillImportProps {
onImport: (data: ImportedSkill) => void
}
type ImportState = 'idle' | 'loading' | 'error'
const ACCEPTED_EXTENSIONS = ['.md', '.zip']
function isAcceptedFile(file: File): boolean {
const name = file.name.toLowerCase()
return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext))
}
export function SkillImport({ onImport }: SkillImportProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragCounter, setDragCounter] = useState(0)
const [fileState, setFileState] = useState<ImportState>('idle')
const [fileError, setFileError] = useState('')
const [githubUrl, setGithubUrl] = useState('')
const [githubState, setGithubState] = useState<ImportState>('idle')
const [githubError, setGithubError] = useState('')
const [pasteContent, setPasteContent] = useState('')
const [pasteError, setPasteError] = useState('')
const processFile = useCallback(
async (file: File) => {
if (!isAcceptedFile(file)) {
setFileError('Unsupported file type. Use .md or .zip files.')
setFileState('error')
return
}
setFileState('loading')
setFileError('')
try {
let rawContent: string
if (file.name.toLowerCase().endsWith('.zip')) {
rawContent = await extractSkillFromZip(file)
} else {
rawContent = await file.text()
}
const parsed = parseSkillMarkdown(rawContent)
setFileState('idle')
onImport(parsed)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to process file'
setFileError(message)
setFileState('error')
}
},
[onImport]
)
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
if (fileInputRef.current) fileInputRef.current.value = ''
},
[processFile]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const next = prev + 1
if (next === 1) setIsDragging(true)
return next
})
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => {
const next = prev - 1
if (next === 0) setIsDragging(false)
return next
})
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
setDragCounter(0)
const file = e.dataTransfer.files?.[0]
if (file) processFile(file)
},
[processFile]
)
const handleGithubImport = useCallback(async () => {
const trimmed = githubUrl.trim()
if (!trimmed) {
setGithubError('Please enter a GitHub URL')
setGithubState('error')
return
}
setGithubState('loading')
setGithubError('')
try {
const res = await fetch('/api/skills/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: trimmed }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || `Import failed (HTTP ${res.status})`)
}
const parsed = parseSkillMarkdown(data.content)
setGithubState('idle')
onImport(parsed)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import from GitHub'
setGithubError(message)
setGithubState('error')
}
}, [githubUrl, onImport])
const handlePasteImport = useCallback(() => {
const trimmed = pasteContent.trim()
if (!trimmed) {
setPasteError('Please paste some content first')
return
}
setPasteError('')
const parsed = parseSkillMarkdown(trimmed)
onImport(parsed)
}, [pasteContent, onImport])
return (
<div className='flex flex-col gap-[18px]'>
{/* File drop zone */}
<div className='flex flex-col gap-[4px]'>
<Label className='font-medium text-[14px]'>Upload File</Label>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={fileState === 'loading'}
className={cn(
'flex w-full cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] border border-dashed px-[16px] py-[32px] transition-colors',
'border-[var(--border-1)] bg-[var(--surface-1)] hover:bg-[var(--surface-4)]',
isDragging && 'border-[var(--surface-7)] bg-[var(--surface-4)]',
fileState === 'loading' && 'pointer-events-none opacity-60'
)}
>
<input
ref={fileInputRef}
type='file'
accept='.md,.zip'
onChange={handleFileChange}
className='hidden'
/>
{fileState === 'loading' ? (
<Loader2 className='h-[20px] w-[20px] animate-spin text-[var(--text-tertiary)]' />
) : (
<Upload className='h-[20px] w-[20px] text-[var(--text-tertiary)]' />
)}
<div className='flex flex-col gap-[2px] text-center'>
<span className='text-[14px] text-[var(--text-primary)]'>
{isDragging ? 'Drop file here' : 'Drop file here or click to browse'}
</span>
<span className='text-[11px] text-[var(--text-tertiary)]'>
.md file with YAML frontmatter, or .zip containing a SKILL.md
</span>
</div>
</button>
{fileError && <p className='text-[13px] text-[var(--text-error)]'>{fileError}</p>}
</div>
<Divider />
{/* GitHub URL */}
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-github-url' className='font-medium text-[14px]'>
Import from GitHub
</Label>
<div className='flex gap-[8px]'>
<Input
id='skill-github-url'
placeholder='https://github.com/owner/repo/blob/main/SKILL.md'
value={githubUrl}
onChange={(e) => {
setGithubUrl(e.target.value)
if (githubError) setGithubError('')
}}
className='flex-1'
disabled={githubState === 'loading'}
/>
<Button
variant='default'
onClick={handleGithubImport}
disabled={githubState === 'loading' || !githubUrl.trim()}
>
{githubState === 'loading' ? (
<Loader2 className='h-[14px] w-[14px] animate-spin' />
) : (
'Fetch'
)}
</Button>
</div>
{githubError && <p className='text-[13px] text-[var(--text-error)]'>{githubError}</p>}
</div>
<Divider />
{/* Paste content */}
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-paste' className='font-medium text-[14px]'>
Paste SKILL.md Content
</Label>
<Textarea
id='skill-paste'
placeholder={
'---\nname: my-skill\ndescription: What this skill does\n---\n\n# Instructions...'
}
value={pasteContent}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setPasteContent(e.target.value)
if (pasteError) setPasteError('')
}}
className='min-h-[120px] resize-y font-mono text-[14px]'
/>
{pasteError && <p className='text-[13px] text-[var(--text-error)]'>{pasteError}</p>}
<div className='flex justify-end'>
<Button variant='default' onClick={handlePasteImport} disabled={!pasteContent.trim()}>
Import
</Button>
</div>
</div>
</div>
)
}
function Divider() {
return (
<div className='flex items-center gap-[12px]'>
<div className='h-px flex-1 bg-[var(--border-1)]' />
<span className='text-[12px] text-[var(--text-tertiary)]'>or</span>
<div className='h-px flex-1 bg-[var(--border-1)]' />
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { ChangeEvent } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -12,15 +12,10 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
ModalTabs,
ModalTabsContent,
ModalTabsList,
ModalTabsTrigger,
Textarea,
} from '@/components/emcn'
import type { SkillDefinition } from '@/hooks/queries/skills'
import { useCreateSkill, useUpdateSkill } from '@/hooks/queries/skills'
import { SkillImport } from './skill-import'
interface SkillModalProps {
open: boolean
@@ -39,8 +34,6 @@ interface FieldErrors {
general?: string
}
type TabValue = 'create' | 'import'
export function SkillModal({
open,
onOpenChange,
@@ -59,7 +52,6 @@ export function SkillModal({
const [content, setContent] = useState('')
const [errors, setErrors] = useState<FieldErrors>({})
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState<TabValue>('create')
const [prevOpen, setPrevOpen] = useState(false)
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
@@ -68,7 +60,6 @@ export function SkillModal({
setDescription(initialValues?.description ?? '')
setContent(initialValues?.content ?? '')
setErrors({})
setActiveTab('create')
}
if (open !== prevOpen) setPrevOpen(open)
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
@@ -133,137 +124,97 @@ export function SkillModal({
}
}
const handleImport = useCallback(
(data: { name: string; description: string; content: string }) => {
setName(data.name)
setDescription(data.description)
setContent(data.content)
setErrors({})
setActiveTab('create')
},
[]
)
const isEditing = !!initialValues
const createForm = (
<div className='flex flex-col gap-[18px]'>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
Name
</Label>
<Input
id='skill-name'
placeholder='my-skill-name'
value={name}
onChange={(e) => {
setName(e.target.value)
if (errors.name || errors.general)
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
}}
/>
{errors.name ? (
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>
Lowercase letters, numbers, and hyphens (e.g. my-skill)
</span>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
Description
</Label>
<Input
id='skill-description'
placeholder='What this skill does and when to use it...'
value={description}
onChange={(e) => {
setDescription(e.target.value)
if (errors.description || errors.general)
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
}}
maxLength={1024}
/>
{errors.description && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
Content
</Label>
<Textarea
id='skill-content'
placeholder='Skill instructions in markdown...'
value={content}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (errors.content || errors.general)
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
}}
className='min-h-[200px] resize-y font-mono text-[14px]'
/>
{errors.content && <p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>}
</div>
{errors.general && <p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>}
</div>
)
const footer = (
<ModalFooter className='items-center justify-between'>
{isEditing && onDelete ? (
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
Delete
</Button>
) : (
<div />
)}
<div className='flex gap-2'>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : isEditing ? 'Update' : 'Create'}
</Button>
</div>
</ModalFooter>
)
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='lg'>
{isEditing ? (
<>
<ModalHeader>Edit Skill</ModalHeader>
<ModalBody>{createForm}</ModalBody>
{footer}
</>
) : (
<>
<ModalHeader>Add Skill</ModalHeader>
<ModalTabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className='flex min-h-0 flex-1 flex-col'
>
<ModalTabsList activeValue={activeTab}>
<ModalTabsTrigger value='create'>Create</ModalTabsTrigger>
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
<ModalTabsContent value='import'>
<SkillImport onImport={handleImport} />
</ModalTabsContent>
</ModalBody>
</ModalTabs>
{activeTab === 'create' && footer}
</>
)}
<ModalHeader>{initialValues ? 'Edit Skill' : 'Create Skill'}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[18px]'>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-name' className='font-medium text-[14px]'>
Name
</Label>
<Input
id='skill-name'
placeholder='my-skill-name'
value={name}
onChange={(e) => {
setName(e.target.value)
if (errors.name || errors.general)
setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
}}
/>
{errors.name ? (
<p className='text-[13px] text-[var(--text-error)]'>{errors.name}</p>
) : (
<span className='text-[11px] text-[var(--text-muted)]'>
Lowercase letters, numbers, and hyphens (e.g. my-skill)
</span>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-description' className='font-medium text-[14px]'>
Description
</Label>
<Input
id='skill-description'
placeholder='What this skill does and when to use it...'
value={description}
onChange={(e) => {
setDescription(e.target.value)
if (errors.description || errors.general)
setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
}}
maxLength={1024}
/>
{errors.description && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.description}</p>
)}
</div>
<div className='flex flex-col gap-[4px]'>
<Label htmlFor='skill-content' className='font-medium text-[14px]'>
Content
</Label>
<Textarea
id='skill-content'
placeholder='Skill instructions in markdown...'
value={content}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (errors.content || errors.general)
setErrors((prev) => ({ ...prev, content: undefined, general: undefined }))
}}
className='min-h-[200px] resize-y font-mono text-[14px]'
/>
{errors.content && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.content}</p>
)}
</div>
{errors.general && (
<p className='text-[13px] text-[var(--text-error)]'>{errors.general}</p>
)}
</div>
</ModalBody>
<ModalFooter className='items-center justify-between'>
{initialValues && onDelete ? (
<Button variant='destructive' onClick={() => onDelete(initialValues.id)}>
Delete
</Button>
) : (
<div />
)}
<div className='flex gap-2'>
<Button variant='default' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant='primary' onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? 'Saving...' : initialValues ? 'Update' : 'Create'}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
)

View File

@@ -1,191 +0,0 @@
/**
* @vitest-environment node
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { extractSkillFromZip, parseSkillMarkdown } from './utils'
describe('parseSkillMarkdown', () => {
it('parses standard SKILL.md with name, description, and body', () => {
const input = [
'---',
'name: my-skill',
'description: Does something useful',
'---',
'',
'# Instructions',
'Use this skill to do things.',
].join('\n')
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-skill',
description: 'Does something useful',
content: '# Instructions\nUse this skill to do things.',
})
})
it('strips single and double quotes from frontmatter values', () => {
const input = '---\nname: \'my-skill\'\ndescription: "A quoted description"\n---\nBody'
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-skill',
description: 'A quoted description',
content: 'Body',
})
})
it('preserves colons inside description values', () => {
const input = '---\nname: api-tool\ndescription: API key: required for auth\n---\nBody'
expect(parseSkillMarkdown(input)).toEqual({
name: 'api-tool',
description: 'API key: required for auth',
content: 'Body',
})
})
it('ignores unknown frontmatter fields', () => {
const input = '---\nname: x\ndescription: y\nauthor: someone\nversion: 2\n---\nBody'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('x')
expect(result.description).toBe('y')
expect(result.content).toBe('Body')
})
it('infers name from heading when frontmatter has no name field', () => {
const input =
'---\ndescription: A tool for blocks\nargument-hint: <name>\n---\n\n# Add Block Skill\n\nContent here.'
expect(parseSkillMarkdown(input)).toEqual({
name: 'add-block-skill',
description: 'A tool for blocks',
content: '# Add Block Skill\n\nContent here.',
})
})
it('infers name from heading when there is no frontmatter at all', () => {
const input = '# My Cool Tool\n\nSome instructions.'
expect(parseSkillMarkdown(input)).toEqual({
name: 'my-cool-tool',
description: '',
content: '# My Cool Tool\n\nSome instructions.',
})
})
it('returns empty name when there is no frontmatter and no heading', () => {
const input = 'Just some plain text without any structure.'
expect(parseSkillMarkdown(input)).toEqual({
name: '',
description: '',
content: 'Just some plain text without any structure.',
})
})
it('handles empty input', () => {
expect(parseSkillMarkdown('')).toEqual({
name: '',
description: '',
content: '',
})
})
it('handles frontmatter with empty name value', () => {
const input = '---\nname:\ndescription: Has a description\n---\n\n# Fallback Heading\nBody'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('fallback-heading')
expect(result.description).toBe('Has a description')
})
it('handles frontmatter with no body', () => {
const input = '---\nname: solo\ndescription: Just frontmatter\n---'
expect(parseSkillMarkdown(input)).toEqual({
name: 'solo',
description: 'Just frontmatter',
content: '',
})
})
it('handles unclosed frontmatter as plain content', () => {
const input = '---\nname: broken\nno closing delimiter'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('')
expect(result.content).toBe(input)
})
it('trims whitespace from input', () => {
const input = '\n\n ---\nname: trimmed\ndescription: yes\n---\nBody \n\n'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('trimmed')
expect(result.content).toBe('Body')
})
it('truncates inferred heading names to 64 characters', () => {
const longHeading = `# ${'A'.repeat(100)}`
const result = parseSkillMarkdown(longHeading)
expect(result.name.length).toBeLessThanOrEqual(64)
})
it('sanitizes special characters in inferred heading names', () => {
const input = '# Hello, World! (v2) — Updated'
const result = parseSkillMarkdown(input)
expect(result.name).toBe('hello-world-v2-updated')
})
it('handles h2 and h3 headings for name inference', () => {
expect(parseSkillMarkdown('## Sub Heading').name).toBe('sub-heading')
expect(parseSkillMarkdown('### Third Level').name).toBe('third-level')
})
it('does not match h4+ headings for name inference', () => {
expect(parseSkillMarkdown('#### Too Deep').name).toBe('')
})
it('uses first heading even when multiple exist', () => {
const input = '# First\n\n## Second\n\n### Third'
expect(parseSkillMarkdown(input).name).toBe('first')
})
})
describe('extractSkillFromZip', () => {
async function makeZipBuffer(files: Record<string, string>): Promise<Uint8Array> {
const zip = new JSZip()
for (const [path, content] of Object.entries(files)) {
zip.file(path, content)
}
return zip.generateAsync({ type: 'uint8array' })
}
it('extracts SKILL.md at root level', async () => {
const data = await makeZipBuffer({ 'SKILL.md': '---\nname: root\n---\nContent' })
const content = await extractSkillFromZip(data)
expect(content).toBe('---\nname: root\n---\nContent')
})
it('extracts SKILL.md from a nested directory', async () => {
const data = await makeZipBuffer({ 'my-skill/SKILL.md': '---\nname: nested\n---\nBody' })
const content = await extractSkillFromZip(data)
expect(content).toBe('---\nname: nested\n---\nBody')
})
it('prefers the shallowest SKILL.md when multiple exist', async () => {
const data = await makeZipBuffer({
'deep/nested/SKILL.md': 'deep',
'SKILL.md': 'root',
'other/SKILL.md': 'other',
})
const content = await extractSkillFromZip(data)
expect(content).toBe('root')
})
it('throws when no SKILL.md is found', async () => {
const data = await makeZipBuffer({ 'README.md': 'No skill here' })
await expect(extractSkillFromZip(data)).rejects.toThrow('No SKILL.md file found')
})
})

View File

@@ -1,112 +0,0 @@
import JSZip from 'jszip'
interface ParsedSkill {
name: string
description: string
content: string
}
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
/**
* Parses a SKILL.md string with optional YAML frontmatter into structured fields.
*
* Expected format:
* ```
* ---
* name: my-skill
* description: What this skill does
* ---
* # Markdown content here...
* ```
*
* If no frontmatter is present, the entire text becomes the content field.
*/
export function parseSkillMarkdown(raw: string): ParsedSkill {
const trimmed = raw.trim()
const match = trimmed.match(FRONTMATTER_REGEX)
if (!match) {
return {
name: inferNameFromHeading(trimmed),
description: '',
content: trimmed,
}
}
const frontmatter = match[1]
const body = (match[2] ?? '').trim()
let name = ''
let description = ''
for (const line of frontmatter.split('\n')) {
const colonIdx = line.indexOf(':')
if (colonIdx === -1) continue
const key = line.slice(0, colonIdx).trim().toLowerCase()
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^['"]|['"]$/g, '')
if (key === 'name') {
name = value
} else if (key === 'description') {
description = value
}
}
if (!name) {
name = inferNameFromHeading(body)
}
return { name, description, content: body }
}
/**
* Derives a kebab-case name from the first markdown heading (e.g. `# Add Block Skill` -> `add-block-skill`).
*/
function inferNameFromHeading(markdown: string): string {
const headingMatch = markdown.match(/^#{1,3}\s+(.+)$/m)
if (!headingMatch) return ''
return headingMatch[1]
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 64)
}
/**
* Extracts the SKILL.md content from a ZIP archive.
* Searches for a file named SKILL.md at any depth within the archive.
* Accepts File, Blob, ArrayBuffer, or Uint8Array (anything JSZip supports).
*/
export async function extractSkillFromZip(
data: File | Blob | ArrayBuffer | Uint8Array
): Promise<string> {
const zip = await JSZip.loadAsync(data)
const candidates: string[] = []
zip.forEach((relativePath, entry) => {
if (!entry.dir && relativePath.endsWith('SKILL.md')) {
candidates.push(relativePath)
}
})
if (candidates.length === 0) {
throw new Error('No SKILL.md file found in the ZIP archive')
}
// Prefer the shallowest path (fewest slashes)
candidates.sort((a, b) => {
const depthA = a.split('/').length
const depthB = b.split('/').length
return depthA - depthB
})
const content = await zip.file(candidates[0])!.async('string')
return content
}

View File

@@ -1,5 +1,4 @@
import {
BookOpen,
Card,
Connections,
HexSimple,
@@ -38,7 +37,6 @@ export type SettingsSection =
| 'skills'
| 'workflow-mcp-servers'
| 'inbox'
| 'docs'
| 'admin'
| 'recently-deleted'
@@ -156,14 +154,6 @@ export const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'docs',
label: 'Docs',
icon: BookOpen,
section: 'system',
requiresHosted: true,
externalUrl: 'https://docs.sim.ai',
},
{
id: 'admin',
label: 'Admin',

View File

@@ -196,6 +196,18 @@ export function Table({
const columnWidthsRef = useRef(columnWidths)
columnWidthsRef.current = columnWidths
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
const columnOrderRef = useRef(columnOrder)
columnOrderRef.current = columnOrder
const [dragColumnName, setDragColumnName] = useState<string | null>(null)
const dragColumnNameRef = useRef(dragColumnName)
dragColumnNameRef.current = dragColumnName
const [dropTargetColumnName, setDropTargetColumnName] = useState<string | null>(null)
const dropTargetColumnNameRef = useRef(dropTargetColumnName)
dropTargetColumnNameRef.current = dropTargetColumnName
const [dropSide, setDropSide] = useState<'left' | 'right'>('left')
const dropSideRef = useRef(dropSide)
dropSideRef.current = dropSide
const metadataSeededRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -239,6 +251,23 @@ export function Table({
[tableData?.schema?.columns]
)
const displayColumns = useMemo(() => {
if (!columnOrder || columnOrder.length === 0) return columns
const colMap = new Map(columns.map((c) => [c.name, c]))
const ordered: ColumnDefinition[] = []
for (const name of columnOrder) {
const col = colMap.get(name)
if (col) {
ordered.push(col)
colMap.delete(name)
}
}
for (const col of colMap.values()) {
ordered.push(col)
}
return ordered
}, [columns, columnOrder])
const maxPosition = useMemo(() => (rows.length > 0 ? rows[rows.length - 1].position : -1), [rows])
const maxPositionRef = useRef(maxPosition)
maxPositionRef.current = maxPosition
@@ -258,23 +287,23 @@ export function Table({
[selectionAnchor, selectionFocus]
)
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : columns.length
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length
const tableWidth = useMemo(() => {
const colsWidth = isLoadingTable
? displayColCount * COL_WIDTH
: columns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
: displayColumns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH
}, [isLoadingTable, displayColCount, columns, columnWidths])
}, [isLoadingTable, displayColCount, displayColumns, columnWidths])
const resizeIndicatorLeft = useMemo(() => {
if (!resizingColumn) return 0
let left = CHECKBOX_COL_WIDTH
for (const col of columns) {
for (const col of displayColumns) {
left += columnWidths[col.name] ?? COL_WIDTH
if (col.name === resizingColumn) return left
}
return 0
}, [resizingColumn, columns, columnWidths])
}, [resizingColumn, displayColumns, columnWidths])
const isAllRowsSelected = useMemo(() => {
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
@@ -289,14 +318,15 @@ export function Table({
normalizedSelection.startRow === 0 &&
normalizedSelection.endRow === maxPosition &&
normalizedSelection.startCol === 0 &&
normalizedSelection.endCol === columns.length - 1
normalizedSelection.endCol === displayColumns.length - 1
)
}, [checkedRows, normalizedSelection, maxPosition, columns.length, rows])
}, [checkedRows, normalizedSelection, maxPosition, displayColumns.length, rows])
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
isAllRowsSelectedRef.current = isAllRowsSelected
const columnsRef = useRef(columns)
const columnsRef = useRef(displayColumns)
const schemaColumnsRef = useRef(columns)
const rowsRef = useRef(rows)
const selectionAnchorRef = useRef(selectionAnchor)
const selectionFocusRef = useRef(selectionFocus)
@@ -304,7 +334,8 @@ export function Table({
const checkedRowsRef = useRef(checkedRows)
checkedRowsRef.current = checkedRows
columnsRef.current = columns
columnsRef.current = displayColumns
schemaColumnsRef.current = columns
rowsRef.current = rows
selectionAnchorRef.current = selectionAnchor
selectionFocusRef.current = selectionFocus
@@ -329,10 +360,20 @@ export function Table({
const columnRename = useInlineRename({
onSave: (columnName, newName) => {
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
setColumnWidths((prev) => {
if (!(columnName in prev)) return prev
return { ...prev, [newName]: prev[columnName] }
})
let updatedWidths = columnWidthsRef.current
if (columnName in updatedWidths) {
const { [columnName]: width, ...rest } = updatedWidths
updatedWidths = { ...rest, [newName]: width }
setColumnWidths(updatedWidths)
}
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
if (updatedOrder) {
setColumnOrder(updatedOrder)
updateMetadataRef.current({
columnWidths: updatedWidths,
columnOrder: updatedOrder,
})
}
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
},
})
@@ -607,11 +648,58 @@ export function Table({
updateMetadataRef.current({ columnWidths: columnWidthsRef.current })
}, [])
const handleColumnDragStart = useCallback((columnName: string) => {
setDragColumnName(columnName)
}, [])
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
setDropTargetColumnName(columnName)
setDropSide(side)
}, [])
const handleColumnDragEnd = useCallback(() => {
const dragged = dragColumnNameRef.current
if (!dragged) return
const target = dropTargetColumnNameRef.current
const side = dropSideRef.current
if (target && dragged !== target) {
const cols = columnsRef.current
const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name)
const fromIndex = currentOrder.indexOf(dragged)
const toIndex = currentOrder.indexOf(target)
if (fromIndex !== -1 && toIndex !== -1) {
const newOrder = currentOrder.filter((n) => n !== dragged)
let insertIndex = newOrder.indexOf(target)
if (side === 'right') insertIndex += 1
newOrder.splice(insertIndex, 0, dragged)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
}
}
setDragColumnName(null)
setDropTargetColumnName(null)
}, [])
const handleColumnDragLeave = useCallback(() => {
dropTargetColumnNameRef.current = null
setDropTargetColumnName(null)
}, [])
useEffect(() => {
if (!tableData?.metadata?.columnWidths || metadataSeededRef.current) return
if (!tableData?.metadata || metadataSeededRef.current) return
if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return
metadataSeededRef.current = true
setColumnWidths(tableData.metadata.columnWidths)
}, [tableData?.metadata?.columnWidths])
if (tableData.metadata.columnWidths) {
setColumnWidths(tableData.metadata.columnWidths)
}
if (tableData.metadata.columnOrder) {
setColumnOrder(tableData.metadata.columnOrder)
}
}, [tableData?.metadata])
useEffect(() => {
const handleMouseUp = () => {
@@ -1214,7 +1302,7 @@ export function Table({
}, [])
const generateColumnName = useCallback(() => {
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase())
let name = 'untitled'
let i = 2
while (existing.includes(name.toLowerCase())) {
@@ -1226,7 +1314,7 @@ export function Table({
const handleAddColumn = useCallback(() => {
const name = generateColumnName()
const position = columnsRef.current.length
const position = schemaColumnsRef.current.length
addColumnMutation.mutate(
{ name, type: 'string' },
{
@@ -1250,9 +1338,30 @@ export function Table({
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
}, [])
const insertColumnInOrder = useCallback(
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
const order = columnOrderRef.current
if (!order) return
const newOrder = [...order]
let anchorIdx = newOrder.indexOf(anchorColumn)
if (anchorIdx === -1) {
newOrder.push(anchorColumn)
anchorIdx = newOrder.length - 1
}
const insertIdx = anchorIdx + (side === 'right' ? 1 : 0)
newOrder.splice(insertIdx, 0, newColumn)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
},
[]
)
const handleInsertColumnLeft = useCallback(
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
const name = generateColumnName()
addColumnMutation.mutate(
@@ -1260,16 +1369,17 @@ export function Table({
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
insertColumnInOrder(columnName, name, 'left')
},
}
)
},
[generateColumnName]
[generateColumnName, insertColumnInOrder]
)
const handleInsertColumnRight = useCallback(
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
const name = generateColumnName()
const position = index + 1
@@ -1278,11 +1388,12 @@ export function Table({
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position })
insertColumnInOrder(columnName, name, 'right')
},
}
)
},
[generateColumnName]
[generateColumnName, insertColumnInOrder]
)
const handleToggleUnique = useCallback((columnName: string) => {
@@ -1310,8 +1421,20 @@ export function Table({
const handleDeleteColumnConfirm = useCallback(() => {
if (!deletingColumn) return
deleteColumnMutation.mutate(deletingColumn)
const columnToDelete = deletingColumn
setDeletingColumn(null)
deleteColumnMutation.mutate(columnToDelete, {
onSuccess: () => {
const order = columnOrderRef.current
if (!order) return
const newOrder = order.filter((n) => n !== columnToDelete)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
},
})
}, [deletingColumn])
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
@@ -1327,13 +1450,13 @@ export function Table({
}, [])
const columnOptions = useMemo<ColumnOption[]>(
() =>
columns.map((col) => ({
displayColumns.map((col) => ({
id: col.name,
label: col.name,
type: col.type,
icon: COLUMN_TYPE_ICONS[col.type],
})),
[columns]
[displayColumns]
)
const tableDataRef = useRef(tableData)
@@ -1404,8 +1527,8 @@ export function Table({
)
const filterElement = useMemo(
() => <TableFilter columns={columns} onApply={handleFilterApply} />,
[columns, handleFilterApply]
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
[displayColumns, handleFilterApply]
)
const activeSortState = useMemo(() => {
@@ -1501,7 +1624,7 @@ export function Table({
<col style={{ width: ADD_COL_WIDTH }} />
</colgroup>
) : (
<TableColGroup columns={columns} columnWidths={columnWidths} />
<TableColGroup columns={displayColumns} columnWidths={columnWidths} />
)}
<thead className='sticky top-0 z-10'>
{isLoadingTable ? (
@@ -1532,7 +1655,7 @@ export function Table({
checked={isAllRowsSelected}
onCheckedChange={handleSelectAllToggle}
/>
{columns.map((column) => (
{displayColumns.map((column) => (
<ColumnHeaderMenu
key={column.name}
column={column}
@@ -1553,6 +1676,13 @@ export function Table({
onResizeStart={handleColumnResizeStart}
onResize={handleColumnResize}
onResizeEnd={handleColumnResizeEnd}
isDragging={dragColumnName === column.name}
isDropTarget={dropTargetColumnName === column.name}
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
onDragStart={handleColumnDragStart}
onDragOver={handleColumnDragOver}
onDragEnd={handleColumnDragEnd}
onDragLeave={handleColumnDragLeave}
/>
))}
{userPermissions.canEdit && (
@@ -1578,7 +1708,7 @@ export function Table({
<PositionGapRows
count={gapCount}
startPosition={prevPosition + 1}
columns={columns}
columns={displayColumns}
normalizedSelection={normalizedSelection}
checkedRows={checkedRows}
firstRowUnderHeader={prevPosition === -1}
@@ -1589,7 +1719,7 @@ export function Table({
)}
<DataRow
row={row}
columns={columns}
columns={displayColumns}
rowIndex={row.position}
isFirstRow={row.position === 0}
editingColumnName={
@@ -2445,6 +2575,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResizeStart,
onResize,
onResizeEnd,
isDragging,
isDropTarget,
dropSide,
onDragStart,
onDragOver,
onDragEnd,
onDragLeave,
}: {
column: ColumnDefinition
readOnly?: boolean
@@ -2462,6 +2599,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResizeStart: (columnName: string) => void
onResize: (columnName: string, width: number) => void
onResizeEnd: () => void
isDragging?: boolean
isDropTarget?: boolean
dropSide?: 'left' | 'right'
onDragStart?: (columnName: string) => void
onDragOver?: (columnName: string, side: 'left' | 'right') => void
onDragEnd?: () => void
onDragLeave?: () => void
}) {
const renameInputRef = useRef<HTMLInputElement>(null)
@@ -2503,8 +2647,68 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
[column.name, onResizeStart, onResize, onResizeEnd]
)
const handleDragStart = useCallback(
(e: React.DragEvent) => {
if (readOnly || isRenaming) {
e.preventDefault()
return
}
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', column.name)
onDragStart?.(column.name)
},
[column.name, readOnly, isRenaming, onDragStart]
)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midX = rect.left + rect.width / 2
const side = e.clientX < midX ? 'left' : 'right'
onDragOver?.(column.name, side)
},
[column.name, onDragOver]
)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
const handleDragEnd = useCallback(() => {
onDragEnd?.()
}, [onDragEnd])
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
const th = e.currentTarget as HTMLElement
const related = e.relatedTarget as Node | null
if (related && th.contains(related)) return
onDragLeave?.()
},
[onDragLeave]
)
return (
<th className='relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'>
<th
className={cn(
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
isDragging && 'opacity-40'
)}
draggable={!readOnly && !isRenaming}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
>
{isDropTarget && dropSide === 'left' && (
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
)}
{isDropTarget && dropSide === 'right' && (
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
)}
{isRenaming ? (
<div className='flex h-full w-full min-w-0 items-center px-[8px] py-[7px]'>
<ColumnTypeIcon type={column.type} />
@@ -2533,7 +2737,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-full w-full min-w-0 cursor-pointer items-center px-[8px] py-[7px] outline-none'
className='flex h-full w-full min-w-0 cursor-grab items-center px-[8px] py-[7px] outline-none active:cursor-grabbing'
>
<ColumnTypeIcon type={column.type} />
<span className='ml-[6px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
@@ -2589,6 +2793,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
)}
<div
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
draggable={false}
onDragStart={(e) => e.stopPropagation()}
onPointerDown={handleResizePointerDown}
/>
</th>

View File

@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Star, User } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { VerifiedBadge } from '@/components/ui/verified-badge'
import { cn } from '@/lib/core/utils/cn'
@@ -288,9 +289,14 @@ function TemplateCardInner({
<div className='mt-[10px] flex items-center justify-between'>
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
{authorImageUrl ? (
<div className='h-[20px] w-[20px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
<Image
src={authorImageUrl}
alt={author}
width={20}
height={20}
className='flex-shrink-0 rounded-full object-cover'
unoptimized
/>
) : (
<div className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-7)]'>
<User className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />

View File

@@ -11,6 +11,7 @@ import {
Square,
X,
} from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import {
Badge,
Button,
@@ -220,7 +221,7 @@ interface StartInputFormatField {
* position across sessions using the floating chat store.
*/
export function Chat() {
const { activeWorkflowId } = useWorkflowRegistry()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blocks = useWorkflowStore((state) => state.blocks)
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
@@ -242,7 +243,26 @@ export function Chat() {
getConversationId,
clearChat,
exportChatCSV,
} = useChatStore()
} = useChatStore(
useShallow((s) => ({
isChatOpen: s.isChatOpen,
chatPosition: s.chatPosition,
chatWidth: s.chatWidth,
chatHeight: s.chatHeight,
setIsChatOpen: s.setIsChatOpen,
setChatPosition: s.setChatPosition,
setChatDimensions: s.setChatDimensions,
messages: s.messages,
addMessage: s.addMessage,
selectedWorkflowOutputs: s.selectedWorkflowOutputs,
setSelectedWorkflowOutput: s.setSelectedWorkflowOutput,
appendMessageContent: s.appendMessageContent,
finalizeMessageStream: s.finalizeMessageStream,
getConversationId: s.getConversationId,
clearChat: s.clearChat,
exportChatCSV: s.exportChatCSV,
}))
)
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)

View File

@@ -3,6 +3,7 @@
import type React from 'react'
import { useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -80,7 +81,14 @@ export function OutputSelect({
maxHeight = 200,
}: OutputSelectProps) {
const blocks = useWorkflowStore((state) => state.blocks)
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore()
const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore(
useShallow((s) => ({
isShowingDiff: s.isShowingDiff,
isDiffReady: s.isDiffReady,
hasActiveDiff: s.hasActiveDiff,
baselineWorkflow: s.baselineWorkflow,
}))
)
const subBlockValues = useSubBlockStore((state) =>
workflowId ? state.workflowValues[workflowId] : null
)

View File

@@ -58,7 +58,7 @@ const commands: CommandItem[] = [
export function CommandList() {
const params = useParams()
const router = useRouter()
const { open: openSearchModal } = useSearchModalStore()
const openSearchModal = useSearchModalStore((s) => s.open)
const preventZoomRef = usePreventZoom()
const workspaceId = params.workspaceId as string | undefined
@@ -179,6 +179,7 @@ export function CommandList() {
)}
>
<div
data-tour='command-list'
className='pointer-events-auto flex flex-col gap-[8px]'
onDragOver={handleDragOver}
onDrop={handleDrop}
@@ -195,7 +196,6 @@ export function CommandList() {
filter:
'brightness(0) saturate(100%) invert(69%) sepia(0%) saturate(0%) hue-rotate(202deg) brightness(94%) contrast(89%)',
}}
priority
/>
</div>

View File

@@ -17,8 +17,6 @@ import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-to
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import { useAllowedMcpDomains, useCreateMcpServer } from '@/hooks/queries/mcp'
import {
useAddWorkflowMcpTool,
useDeleteWorkflowMcpTool,
@@ -28,7 +26,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -102,11 +100,7 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [showMcpModal, setShowMcpModal] = useState(false)
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -470,27 +464,17 @@ export function McpDeploy({
if (servers.length === 0) {
return (
<>
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first.
</p>
<Button variant='tertiary' onClick={() => setShowMcpModal(true)}>
Create MCP Server
</Button>
</div>
<McpServerFormModal
open={showMcpModal}
onOpenChange={setShowMcpModal}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</>
<div className='flex h-full flex-col items-center justify-center gap-3'>
<p className='text-[13px] text-[var(--text-muted)]'>
Create an MCP Server in Settings MCP Servers first.
</p>
<Button
variant='tertiary'
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
>
Create MCP Server
</Button>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import { useReactFlow } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -7,6 +7,7 @@ import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -25,6 +26,7 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -51,10 +53,7 @@ export function CredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId, workflows } = useWorkflowRegistry()
// Only pass workflowId when it's a real registered workflow (not an agent ID set as the active context)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const { activeWorkflowId } = useWorkflowRegistry()
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
const requiredScopes = subBlock.requiredScopes || []
@@ -104,7 +103,7 @@ export function CredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: effectiveWorkflowId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo(
@@ -158,6 +157,7 @@ export function CredentialSelector({
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -199,8 +199,21 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
returnOrigin: activeWorkflowId
? { type: 'workflow', workflowId: activeWorkflowId }
: undefined,
})
navigateToSettings({ section: 'integrations' })
}, [workspaceId, effectiveProviderId, serviceId, activeWorkflowId])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'

View File

@@ -7,7 +7,7 @@ import {
useRef,
useState,
} from 'react'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'

View File

@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
@@ -15,6 +16,7 @@ import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getProviderIcon = (providerName: OAuthProvider) => {
@@ -72,10 +74,8 @@ export function ToolCredentialSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId, workflows } = useWorkflowRegistry()
// Only pass workflowId when it's a real registered workflow (not an agent ID set as the active context)
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const { activeWorkflowId } = useWorkflowRegistry()
const { navigateToSettings } = useSettingsNavigation()
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
@@ -89,7 +89,7 @@ export function ToolCredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: effectiveWorkflowId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo(
@@ -164,8 +164,18 @@ export function ToolCredentialSelector({
)
const handleAddCredential = useCallback(() => {
setShowOAuthModal(true)
}, [])
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
navigateToSettings({ section: 'integrations' })
}, [workspaceId, effectiveProviderId, serviceId])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({

View File

@@ -27,7 +27,6 @@ import type { McpToolSchema } from '@/lib/mcp/types'
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
import {
LongInput,
ShortInput,
@@ -49,7 +48,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { getAllBlocks } from '@/blocks'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import {
type CustomTool as CustomToolDefinition,
@@ -57,15 +55,12 @@ import {
} from '@/hooks/queries/custom-tools'
import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments'
import {
useAllowedMcpDomains,
useCreateMcpServer,
useForceRefreshMcpTools,
useMcpServers,
useMcpToolsEvents,
useStoredMcpTools,
} from '@/hooks/queries/mcp'
import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -335,6 +330,24 @@ function resolveCustomToolFromReference(
* These are distinguished from third-party integrations for categorization
* in the tool selection dropdown.
*/
const BUILT_IN_TOOL_TYPES = new Set([
'api',
'file',
'function',
'knowledge',
'search',
'thinking',
'image_generator',
'video_generator',
'vision',
'translate',
'tts',
'stt',
'memory',
'table',
'webhook_request',
'workflow',
])
/**
* Checks if a block supports multiple operations.
@@ -456,7 +469,6 @@ export const ToolInput = memo(function ToolInput({
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
@@ -495,9 +507,6 @@ export const ToolInput = memo(function ToolInput({
const forceRefreshMcpTools = useForceRefreshMcpTools()
useMcpToolsEvents(workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const mcpDataLoading = mcpLoading || mcpServersLoading
const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false })
@@ -1370,7 +1379,7 @@ export const ToolInput = memo(function ToolInput({
icon: McpIcon,
onSelect: () => {
setOpen(false)
setMcpModalOpen(true)
navigateToSettings({ section: 'mcp' })
},
disabled: isPreview,
})
@@ -2086,18 +2095,6 @@ export const ToolInput = memo(function ToolInput({
: undefined
}
/>
<McpServerFormModal
open={mcpModalOpen}
onOpenChange={setMcpModalOpen}
mode='add'
onSubmit={async (config) => {
await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
}}
workspaceId={workspaceId}
availableEnvVars={availableEnvVars}
allowedMcpDomains={allowedMcpDomains}
/>
</div>
)
})

View File

@@ -85,7 +85,7 @@ export function VariablesInput({
const params = useParams()
const workflowId = params.workflowId as string
const [storeValue, setStoreValue] = useSubBlockValue<VariableAssignment[]>(blockId, subBlockId)
const { variables: workflowVariables } = useVariablesStore()
const workflowVariables = useVariablesStore((s) => s.variables)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [showTags, setShowTags] = useState(false)

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo } from 'react'
import { isEqual } from 'lodash'
import { isEqual } from 'es-toolkit'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import {
buildCanonicalIndex,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { isEqual } from 'lodash'
import { isEqual } from 'es-toolkit'
import { useShallow } from 'zustand/react/shallow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'

View File

@@ -1,5 +1,5 @@
import { type JSX, type MouseEvent, memo, useCallback, useMemo, useRef, useState } from 'react'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import {
AlertTriangle,
ArrowLeftRight,

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import {
BookOpen,
Check,

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useToolbarStore } from '@/stores/panel'
/**
@@ -76,7 +77,12 @@ export function useToolbarResize({
triggersContentRef,
triggersHeaderRef,
}: UseToolbarResizeProps) {
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore()
const { toolbarTriggersHeight, setToolbarTriggersHeight } = useToolbarStore(
useShallow((s) => ({
toolbarTriggersHeight: s.toolbarTriggersHeight,
setToolbarTriggersHeight: s.setToolbarTriggersHeight,
}))
)
const [isResizing, setIsResizing] = useState(false)
const startYRef = useRef<number>(0)

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { PANEL_WIDTH } from '@/stores/constants'
import { usePanelStore } from '@/stores/panel'
@@ -13,7 +14,13 @@ const CONTENT_WINDOW_GAP = 8
* @returns Resize state and handlers
*/
export function usePanelResize() {
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore(
useShallow((s) => ({
setPanelWidth: s.setPanelWidth,
isResizing: s.isResizing,
setIsResizing: s.setIsResizing,
}))
)
/**
* Handles mouse down on resize handle

View File

@@ -34,16 +34,9 @@ import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem, MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
assistantMessageHasRenderableContent,
MessageContent,
QueuedMessages,
UserInput,
UserMessageContent,
} from '@/app/workspace/[workspaceId]/home/components'
import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { useAutoScroll, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
import { getWorkflowCopilotUseChatOptions, useChat } from '@/app/workspace/[workspaceId]/home/hooks'
import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -332,13 +325,15 @@ export const Panel = memo(function Panel() {
removeFromQueue: copilotRemoveFromQueue,
sendNow: copilotSendNow,
editQueuedMessage: copilotEditQueuedMessage,
} = useChat(workspaceId, copilotChatId, {
apiPath: '/api/copilot/chat',
stopPath: '/api/mothership/chat/stop',
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
} = useChat(
workspaceId,
copilotChatId,
getWorkflowCopilotUseChatOptions({
workflowId: activeWorkflowId || undefined,
onTitleUpdate: loadCopilotChats,
onToolResult: handleCopilotToolResult,
})
)
const handleCopilotNewChat = useCallback(() => {
if (!activeWorkflowId || !workspaceId) return
@@ -403,9 +398,6 @@ export const Panel = memo(function Panel() {
[copilotSendMessage]
)
const { ref: copilotScrollRef, scrollToBottom: copilotScrollToBottom } =
useAutoScroll(copilotIsSending)
/**
* Mark hydration as complete on mount
* This allows React to take over visibility control from CSS
@@ -608,7 +600,7 @@ export const Panel = memo(function Panel() {
<div className='flex gap-[6px]'>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button className='h-[30px] w-[30px] rounded-[5px]'>
<Button className='h-[30px] w-[30px] rounded-[5px]' data-tour='panel-menu'>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
@@ -668,10 +660,11 @@ export const Panel = memo(function Panel() {
</div>
{/* Deploy and Run */}
<div className='flex gap-[6px]'>
<div className='flex gap-[6px]' data-tour='deploy-run'>
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
<Button
className='h-[30px] gap-[8px] px-[10px]'
data-tour='run-button'
variant={isExecuting ? 'active' : 'tertiary'}
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
disabled={!isExecuting && isButtonDisabled}
@@ -699,6 +692,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'copilot' ? 'active' : 'ghost'}
onClick={() => handleTabClick('copilot')}
data-tab-button='copilot'
data-tour='tab-copilot'
>
Copilot
</Button>
@@ -712,6 +706,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'toolbar' ? 'active' : 'ghost'}
onClick={() => handleTabClick('toolbar')}
data-tab-button='toolbar'
data-tour='tab-toolbar'
>
Toolbar
</Button>
@@ -724,6 +719,7 @@ export const Panel = memo(function Panel() {
variant={_hasHydrated && activeTab === 'editor' ? 'active' : 'ghost'}
onClick={() => handleTabClick('editor')}
data-tab-button='editor'
data-tour='tab-editor'
>
Editor
</Button>
@@ -812,77 +808,21 @@ export const Panel = memo(function Panel() {
</div>
</div>
<div
ref={copilotScrollRef}
className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4'
>
<div className='space-y-4'>
{copilotMessages.map((msg, index) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex flex-col items-end gap-[6px] pt-2'>
<div className='max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2'>
<UserMessageContent content={msg.content} contexts={msg.contexts} />
</div>
</div>
)
}
const hasAnyBlocks = Boolean(msg.contentBlocks?.length)
const hasRenderableAssistant = assistantMessageHasRenderableContent(
msg.contentBlocks ?? [],
msg.content ?? ''
)
const isLastAssistant =
msg.role === 'assistant' && index === copilotMessages.length - 1
const isThisStreaming = copilotIsSending && isLastAssistant
if (!hasAnyBlocks && !msg.content?.trim() && isThisStreaming) {
return <PendingTagIndicator key={msg.id} />
}
if (!hasRenderableAssistant && !msg.content?.trim() && !isThisStreaming) {
return null
}
const isLastMessage = index === copilotMessages.length - 1
return (
<div key={msg.id} className='group/msg relative pb-3'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage ? copilotSendMessage : undefined}
/>
</div>
)
})}
</div>
</div>
<div className='flex-shrink-0 px-3 pb-3'>
<QueuedMessages
messageQueue={copilotMessageQueue}
onRemove={copilotRemoveFromQueue}
onSendNow={copilotSendNow}
onEdit={handleCopilotEditQueuedMessage}
/>
<UserInput
onSubmit={handleCopilotSubmit}
isSending={copilotIsSending}
onStopGeneration={copilotStopGeneration}
isInitialView={false}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
/>
</div>
<MothershipChat
className='min-h-0 flex-1'
messages={copilotMessages}
isSending={copilotIsSending}
onSubmit={handleCopilotSubmit}
onStopGeneration={copilotStopGeneration}
messageQueue={copilotMessageQueue}
onRemoveQueuedMessage={copilotRemoveFromQueue}
onSendQueuedMessage={copilotSendNow}
onEditQueuedMessage={handleCopilotEditQueuedMessage}
userId={session?.user?.id}
editValue={copilotEditingInputValue}
onEditValueConsumed={clearCopilotEditingValue}
layout='copilot-view'
/>
</div>
)}
<div

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import { useShallow } from 'zustand/react/shallow'
import {
Badge,
Button,
@@ -92,17 +93,33 @@ const STRINGS = {
* - Uses emcn Input/Code/Combobox components for a consistent UI
*/
export function Variables() {
const { activeWorkflowId } = useWorkflowRegistry()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { isOpen, position, width, height, setIsOpen, setPosition, setDimensions } =
useVariablesStore()
useVariablesStore(
useShallow((s) => ({
isOpen: s.isOpen,
position: s.position,
width: s.width,
height: s.height,
setIsOpen: s.setIsOpen,
setPosition: s.setPosition,
setDimensions: s.setDimensions,
}))
)
const { getVariablesByWorkflowId } = usePanelVariablesStore()
const variables = usePanelVariablesStore((s) => s.variables)
const { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable } =
useCollaborativeWorkflow()
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
const workflowVariables = useMemo(
() =>
activeWorkflowId
? Object.values(variables).filter((v) => v.workflowId === activeWorkflowId)
: [],
[variables, activeWorkflowId]
)
const actualPosition = useMemo(
() => getVariablesPosition(position, width, height),

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import isEqual from 'lodash/isEqual'
import { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'

View File

@@ -4,6 +4,7 @@ import { memo, useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Scan } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import {
Button,
ChevronDown,
@@ -36,7 +37,9 @@ const logger = createLogger('WorkflowControls')
export const WorkflowControls = memo(function WorkflowControls() {
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore()
const { mode, setMode } = useCanvasModeStore(
useShallow((s) => ({ mode: s.mode, setMode: s.setMode }))
)
const { undo, redo } = useCollaborativeWorkflow()
const showWorkflowControls = useShowActionBar()
const updateSetting = useUpdateGeneralSetting()
@@ -80,13 +83,14 @@ export const WorkflowControls = memo(function WorkflowControls() {
}
if (!showWorkflowControls) {
return null
return <div data-tour='workflow-controls' className='hidden' />
}
return (
<>
<div
className='absolute bottom-[16px] left-[16px] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]'
data-tour='workflow-controls'
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { v4 as uuidv4 } from 'uuid'
import { useShallow } from 'zustand/react/shallow'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import {
@@ -97,12 +98,27 @@ function normalizeErrorMessage(error: unknown): string {
export function useWorkflowExecution() {
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const { activeWorkflowId, workflows } = useWorkflowRegistry(
useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows }))
)
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
useTerminalConsoleStore()
useTerminalConsoleStore(
useShallow((s) => ({
toggleConsole: s.toggleConsole,
addConsole: s.addConsole,
updateConsole: s.updateConsole,
cancelRunningEntries: s.cancelRunningEntries,
clearExecutionEntries: s.clearExecutionEntries,
}))
)
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
const { getAllVariables } = useEnvironmentStore()
const { getVariablesByWorkflowId, variables } = useVariablesStore()
const getAllVariables = useEnvironmentStore((s) => s.getAllVariables)
const { getVariablesByWorkflowId, variables } = useVariablesStore(
useShallow((s) => ({
getVariablesByWorkflowId: s.getVariablesByWorkflowId,
variables: s.variables,
}))
)
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
useCurrentWorkflowExecution()
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)

View File

@@ -1,9 +1,11 @@
import { WorkflowTour } from '@/app/workspace/[workspaceId]/components/product-tour'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<main className='flex h-full flex-1 flex-col overflow-hidden'>
<ErrorBoundary>{children}</ErrorBoundary>
<WorkflowTour />
</main>
)
}

View File

@@ -3909,7 +3909,11 @@ const WorkflowContent = React.memo(
return (
<div className='flex h-full w-full overflow-hidden'>
<div className='flex min-w-0 flex-1 flex-col'>
<div ref={canvasContainerRef} className='relative flex-1 overflow-hidden'>
<div
ref={canvasContainerRef}
className='relative flex-1 overflow-hidden'
data-tour='canvas'
>
{!isWorkflowReady && (
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
<div

View File

@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useMemo } from 'react'
import clsx from 'clsx'
import { useShallow } from 'zustand/react/shallow'
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
@@ -78,7 +79,14 @@ export const WorkflowList = memo(function WorkflowList({
}: WorkflowListProps) {
const { isLoading: foldersLoading } = useFolders(workspaceId)
const folders = useFolderStore((state) => state.folders)
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore()
const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore(
useShallow((s) => ({
getFolderTree: s.getFolderTree,
expandedFolders: s.expandedFolders,
getFolderPath: s.getFolderPath,
setExpanded: s.setExpanded,
}))
)
const {
isOpen: isEmptyAreaMenuOpen,

View File

@@ -47,7 +47,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
const workspaceId = params.workspaceId as string | undefined
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const { setExpanded, expandedFolders } = useFolderStore()
const setExpanded = useFolderStore((s) => s.setExpanded)
const expandedFolders = useFolderStore((s) => s.expandedFolders)
const handleAutoScroll = useCallback(() => {
if (!scrollContainerRef.current) {

View File

@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useFolderStore } from '@/stores/folders/store'
interface UseFolderSelectionProps {
@@ -44,7 +45,15 @@ export function useFolderSelection({
selectFolderOnly,
selectFolderRange,
toggleFolderSelection,
} = useFolderStore()
} = useFolderStore(
useShallow((s) => ({
selectedFolders: s.selectedFolders,
lastSelectedFolderId: s.lastSelectedFolderId,
selectFolderOnly: s.selectFolderOnly,
selectFolderRange: s.selectFolderRange,
toggleFolderSelection: s.toggleFolderSelection,
}))
)
/**
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -10,7 +11,13 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* @returns Resize state and handlers
*/
export function useSidebarResize() {
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore(
useShallow((s) => ({
setSidebarWidth: s.setSidebarWidth,
isResizing: s.isResizing,
setIsResizing: s.setIsResizing,
}))
)
/**
* Handles mouse down on resize handle

View File

@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useFolderStore } from '@/stores/folders/store'
interface UseWorkflowSelectionProps {
@@ -30,7 +31,14 @@ export function useWorkflowSelection({
activeWorkflowId,
workflowAncestorFolderIds,
}: UseWorkflowSelectionProps) {
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore()
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore(
useShallow((s) => ({
selectedWorkflows: s.selectedWorkflows,
selectOnly: s.selectOnly,
selectRange: s.selectRange,
toggleWorkflowSelection: s.toggleWorkflowSelection,
}))
)
/**
* After a workflow selection change, deselect any folder that is an ancestor of a selected

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