Merge branch 'staging' of github.com:simstudioai/sim into staging

This commit is contained in:
Vikhyath Mondreti
2026-03-25 20:32:49 -07:00
39 changed files with 2824 additions and 807 deletions

View File

@@ -1,16 +1,20 @@
<p align="center">
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
<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>
</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-6F3DFA" alt="Sim.ai"></a>
<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://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-6F3DFA.svg" alt="Documentation"></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>
</p>
<p align="center">
@@ -42,7 +46,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-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&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-33c482?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjMzNjNDgyIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+&logoColor=white" alt="Sim.ai"></a>
### Self-hosted: NPM Package
@@ -70,43 +74,7 @@ docker compose -f docker-compose.prod.yml up -d
Open [http://localhost:3000](http://localhost:3000)
#### 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
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.
### Self-hosted: Manual Setup
@@ -159,18 +127,7 @@ Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
## Environment Variables
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 |
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.
## Tech Stack

View File

@@ -54,11 +54,23 @@ html[data-sidebar-collapsed] .sidebar-container .text-small {
transition: opacity 60ms ease;
}
.sidebar-container .sidebar-collapse-show {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}
.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}
.sidebar-container[data-collapsed] .sidebar-collapse-show,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
opacity: 1;
pointer-events: auto;
}
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}

View File

@@ -1,65 +0,0 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('RenameChatAPI')
const RenameChatSchema = z.object({
chatId: z.string().min(1),
title: z.string().min(1).max(200),
})
export async function PATCH(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { chatId, title } = RenameChatSchema.parse(body)
const chat = await getAccessibleCopilotChat(chatId, session.user.id)
if (!chat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
const now = new Date()
const [updated] = await db
.update(copilotChats)
.set({ title, updatedAt: now, lastSeenAt: now })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
if (!updated) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
logger.info('Chat renamed', { chatId, title })
if (updated.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: updated.workspaceId,
chatId,
type: 'renamed',
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
logger.error('Error renaming chat:', error)
return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 })
}
}

View File

@@ -0,0 +1,217 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle'
import { appendCopilotLogContext } from '@/lib/copilot/logging'
import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { taskPubSub } from '@/lib/copilot/task-events'
const logger = createLogger('MothershipChatAPI')
const UpdateChatSchema = z
.object({
title: z.string().trim().min(1).max(200).optional(),
isUnread: z.boolean().optional(),
})
.refine((data) => data.title !== undefined || data.isUnread !== undefined, {
message: 'At least one field must be provided',
})
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const chat = await getAccessibleCopilotChat(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: Array<{ eventId: number; streamId: string; event: Record<string, unknown> }>
status: string
} | null = null
if (chat.conversationId) {
try {
const [meta, events] = await Promise.all([
getStreamMeta(chat.conversationId),
readStreamEvents(chat.conversationId, 0),
])
streamSnapshot = {
events: events || [],
status: meta?.status || 'unknown',
}
} catch (error) {
logger.warn(
appendCopilotLogContext('Failed to read stream snapshot for mothership chat', {
messageId: chat.conversationId || undefined,
}),
{
chatId,
conversationId: chat.conversationId,
error: error instanceof Error ? error.message : String(error),
}
)
}
}
return NextResponse.json({
success: true,
chat: {
id: chat.id,
title: chat.title,
messages: Array.isArray(chat.messages) ? chat.messages : [],
conversationId: chat.conversationId || null,
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
} catch (error) {
logger.error('Error fetching mothership chat:', error)
return createInternalServerErrorResponse('Failed to fetch chat')
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const body = await request.json()
const { title, isUnread } = UpdateChatSchema.parse(body)
const updates: Record<string, unknown> = {}
if (title !== undefined) {
const now = new Date()
updates.title = title
updates.updatedAt = now
if (isUnread === undefined) {
updates.lastSeenAt = now
}
}
if (isUnread !== undefined) {
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
}
const [updatedChat] = await db
.update(copilotChats)
.set(updates)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
id: copilotChats.id,
workspaceId: copilotChats.workspaceId,
})
if (!updatedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (title !== undefined && updatedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
}
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('Invalid request data')
}
logger.error('Error updating mothership chat:', error)
return createInternalServerErrorResponse('Failed to update chat')
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ chatId: string }> }
) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const { chatId } = await params
if (!chatId) {
return createBadRequestResponse('chatId is required')
}
const chat = await getAccessibleCopilotChat(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: true })
}
const [deletedChat] = await db
.delete(copilotChats)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
workspaceId: copilotChats.workspaceId,
})
if (!deletedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (deletedChat.workspaceId) {
taskPubSub?.publishStatusChanged({
workspaceId: deletedChat.workspaceId,
chatId,
type: 'deleted',
})
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting mothership chat:', error)
return createInternalServerErrorResponse('Failed to delete chat')
}
}

View File

@@ -1,43 +0,0 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
const logger = createLogger('MarkTaskReadAPI')
const MarkReadSchema = z.object({
chatId: z.string().min(1),
})
export async function POST(request: NextRequest) {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const body = await request.json()
const { chatId } = MarkReadSchema.parse(body)
await db
.update(copilotChats)
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return createBadRequestResponse('chatId is required')
}
logger.error('Error marking task as read:', error)
return createInternalServerErrorResponse('Failed to mark task as read')
}
}

View File

@@ -0,0 +1,107 @@
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')
}
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 response = await fetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
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 contentLength = response.headers.get('content-length')
if (contentLength && Number.parseInt(contentLength, 10) > 100_000) {
return NextResponse.json({ error: 'File is too large (max 100KB)' }, { status: 400 })
}
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 })
} 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' || error.name === 'TimeoutError')) {
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

@@ -8,6 +8,7 @@ interface ConversationListItemProps {
isUnread?: boolean
className?: string
titleClassName?: string
statusIndicatorClassName?: string
actions?: ReactNode
}
@@ -17,6 +18,7 @@ export function ConversationListItem({
isUnread = false,
className,
titleClassName,
statusIndicatorClassName,
actions,
}: ConversationListItemProps) {
return (
@@ -24,10 +26,20 @@ export function ConversationListItem({
<span className='relative flex-shrink-0'>
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
{isActive && (
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400' />
<span
className={cn(
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400',
statusIndicatorClassName
)}
/>
)}
{!isActive && isUnread && (
<span className='-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]' />
<span
className={cn(
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]',
statusIndicatorClassName
)}
/>
)}
</span>
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>

View File

@@ -48,7 +48,7 @@ interface MothershipChatProps {
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]',
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
content: 'mx-auto max-w-[42rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',

View File

@@ -299,7 +299,7 @@ export function Home({ chatId }: HomeProps = {}) {
if (!hasMessages && !chatId) {
return (
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable_both-edges]'>
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
<h1
data-tour='home-greeting'

View File

@@ -30,15 +30,12 @@ import {
readPendingCredentialCreateRequest,
writeOAuthReturnContext,
} from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getServiceConfigByProviderId,
type OAuthProvider,
} from '@/lib/oauth'
import { getCanonicalScopesForProvider, getServiceConfigByProviderId } from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { getUserColor } from '@/lib/workspaces/colors'
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/credentials/credential-skeleton'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import {
useCreateCredentialDraft,
useCreateWorkspaceCredential,
useDeleteWorkspaceCredential,
useRemoveWorkspaceCredentialMember,
@@ -82,7 +79,8 @@ export function IntegrationsManager() {
const [detailsError, setDetailsError] = useState<string | null>(null)
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('')
const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
const [createStep, setCreateStep] = useState<1 | 2>(1)
const [serviceSearch, setServiceSearch] = useState('')
const [copyIdSuccess, setCopyIdSuccess] = useState(false)
const [credentialToDelete, setCredentialToDelete] = useState<WorkspaceCredential | null>(null)
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
@@ -125,6 +123,7 @@ export function IntegrationsManager() {
selectedCredential?.id
)
const createDraft = useCreateCredentialDraft()
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
const deleteCredential = useDeleteWorkspaceCredential()
@@ -155,12 +154,18 @@ export function IntegrationsManager() {
const sortedCredentials = useMemo(() => {
return [...filteredCredentials].sort((a, b) => {
const aDate = new Date(a.updatedAt).getTime()
const bDate = new Date(b.updatedAt).getTime()
return bDate - aDate
const aProvider = a.providerId || ''
const bProvider = b.providerId || ''
return aProvider.localeCompare(bProvider)
})
}, [filteredCredentials])
const filteredAvailableIntegrations = useMemo(() => {
if (!searchTerm.trim()) return oauthConnections
const normalized = searchTerm.toLowerCase()
return oauthConnections.filter((service) => service.name.toLowerCase().includes(normalized))
}, [oauthConnections, searchTerm])
const oauthServiceOptions = useMemo(
() =>
oauthConnections.map((service) => ({
@@ -202,6 +207,14 @@ export function IntegrationsManager() {
return getCanonicalScopesForProvider(createOAuthProviderId)
}, [selectedOAuthService, createOAuthProviderId])
const createDisplayScopes = useMemo(
() =>
createOAuthRequiredScopes.filter(
(s) => !s.includes('userinfo.email') && !s.includes('userinfo.profile')
),
[createOAuthRequiredScopes]
)
const existingOAuthDisplayName = useMemo(() => {
const name = createDisplayName.trim()
if (!name) return null
@@ -237,6 +250,8 @@ export function IntegrationsManager() {
...(isDisplayNameDirty ? { displayName: selectedDisplayNameDraft.trim() } : {}),
...(isDescriptionDirty ? { description: selectedDescriptionDraft.trim() || null } : {}),
})
if (isDisplayNameDirty) setSelectedDisplayNameDraft((v) => v.trim())
if (isDescriptionDirty) setSelectedDescriptionDraft((v) => v.trim())
}
await refetchCredentials()
@@ -254,15 +269,17 @@ export function IntegrationsManager() {
setShowUnsavedChangesAlert(true)
} else {
setSelectedCredentialId(null)
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
}
}, [isDetailsDirty, isSavingDetails])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setSelectedDescriptionDraft(selectedCredential?.description || '')
setSelectedDisplayNameDraft(selectedCredential?.displayName || '')
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
setSelectedCredentialId(null)
}, [selectedCredential])
}, [])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -290,7 +307,6 @@ export function IntegrationsManager() {
pendingReturnOriginRef.current = request.returnOrigin
setShowCreateModal(true)
setShowCreateOAuthRequiredModal(false)
setCreateError(null)
setCreateDescription('')
setCreateOAuthProviderId(request.providerId)
@@ -330,18 +346,6 @@ export function IntegrationsManager() {
}
}, [workspaceId, applyPendingCredentialCreateRequest])
useEffect(() => {
if (!selectedCredential) {
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
return
}
setDetailsError(null)
setSelectedDescriptionDraft(selectedCredential.description || '')
setSelectedDisplayNameDraft(selectedCredential.displayName)
}, [selectedCredential])
const isSelectedAdmin = selectedCredential?.role === 'admin'
const selectedOAuthServiceConfig = useMemo(() => {
if (
@@ -360,28 +364,16 @@ export function IntegrationsManager() {
setCreateDescription('')
setCreateOAuthProviderId('')
setCreateError(null)
setShowCreateOAuthRequiredModal(false)
setCreateStep(1)
setServiceSearch('')
pendingReturnOriginRef.current = undefined
}
const handleSelectCredential = (credential: WorkspaceCredential) => {
setSelectedCredentialId(credential.id)
setDetailsError(null)
}
const handleCreateCredential = async () => {
if (!workspaceId) return
setCreateError(null)
if (!selectedOAuthService) {
setCreateError('Select an OAuth service before connecting.')
return
}
if (!createDisplayName.trim()) {
setCreateError('Display name is required.')
return
}
setShowCreateOAuthRequiredModal(true)
setSelectedDescriptionDraft(credential.description || '')
setSelectedDisplayNameDraft(credential.displayName)
}
const handleConnectOAuthService = async () => {
@@ -398,15 +390,11 @@ export function IntegrationsManager() {
setCreateError(null)
try {
await fetch('/api/credentials/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
providerId: selectedOAuthService.providerId,
displayName,
description: createDescription.trim() || undefined,
}),
await createDraft.mutateAsync({
workspaceId,
providerId: selectedOAuthService.providerId,
displayName,
description: createDescription.trim() || undefined,
})
const oauthPreCount = credentials.filter(
@@ -490,6 +478,8 @@ export function IntegrationsManager() {
if (selectedCredentialId === credentialToDelete.id) {
setSelectedCredentialId(null)
setSelectedDescriptionDraft('')
setSelectedDisplayNameDraft('')
}
setShowDeleteConfirmDialog(false)
setCredentialToDelete(null)
@@ -539,16 +529,12 @@ export function IntegrationsManager() {
setDetailsError(null)
try {
await fetch('/api/credentials/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
providerId: selectedCredential.providerId,
displayName: selectedCredential.displayName,
description: selectedCredential.description || undefined,
credentialId: selectedCredential.id,
}),
await createDraft.mutateAsync({
workspaceId,
providerId: selectedCredential.providerId,
displayName: selectedCredential.displayName,
description: selectedCredential.description || undefined,
credentialId: selectedCredential.id,
})
const oauthPreCount = credentials.filter(
@@ -618,8 +604,31 @@ export function IntegrationsManager() {
}
const hasCredentials = oauthCredentials && oauthCredentials.length > 0
const connectedProviderIds = useMemo(
() => new Set(oauthCredentials.map((c) => c.providerId).filter(Boolean) as string[]),
[oauthCredentials]
)
const showNoResults =
searchTerm.trim() && sortedCredentials.length === 0 && oauthCredentials.length > 0
searchTerm.trim() &&
sortedCredentials.length === 0 &&
filteredAvailableIntegrations.length === 0
const handleAddForProvider = useCallback((providerId: string) => {
setCreateOAuthProviderId(providerId)
setCreateStep(2)
setCreateDisplayName('')
setCreateDescription('')
setCreateError(null)
setShowCreateModal(true)
}, [])
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
return oauthServiceOptions.filter((s) => s.label.toLowerCase().includes(q))
}, [oauthServiceOptions, serviceSearch])
const createModalJsx = (
<Modal
@@ -630,128 +639,199 @@ export function IntegrationsManager() {
}}
>
<ModalContent size='md'>
<ModalHeader>Connect Integration</ModalHeader>
<ModalBody>
{(createError || existingOAuthDisplayName) && (
<div className='mb-3 flex flex-col gap-2'>
{createError && (
<Badge variant='red' size='lg' dot className='max-w-full'>
{createError}
</Badge>
{createStep === 1 ? (
<>
<ModalHeader>Connect Integration</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[12px]'>
<div className='flex items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<UiInput
placeholder='Search services...'
value={serviceSearch}
onChange={(e) => setServiceSearch(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
autoFocus
/>
</div>
<div className='flex max-h-[320px] flex-col overflow-y-auto'>
{filteredServices.map((service) => {
const config = getServiceConfigByProviderId(service.value)
return (
<button
key={service.value}
type='button'
onClick={() => {
setCreateOAuthProviderId(service.value)
setCreateStep(2)
setServiceSearch('')
}}
className='flex items-center gap-[10px] rounded-[6px] px-[8px] py-[8px] text-left hover:bg-[var(--surface-5)]'
>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[6px] bg-[var(--surface-5)]'>
{config ? (
createElement(config.icon, { className: 'h-4 w-4' })
) : (
<span className='font-medium text-[11px] text-[var(--text-tertiary)]'>
{service.label.slice(0, 2)}
</span>
)}
</div>
<span className='font-medium text-[15px] text-[var(--text-primary)]'>
{service.label}
</span>
</button>
)
})}
{filteredServices.length === 0 && (
<div className='py-[24px] text-center text-[13px] text-[var(--text-muted)]'>
No services found
</div>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
</ModalFooter>
</>
) : (
<>
<ModalHeader>
<div className='flex items-center gap-[10px]'>
<button
type='button'
onClick={() => {
setCreateStep(1)
setCreateError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
aria-label='Back'
>
</button>
<span>
Connect{' '}
{selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
</div>
</ModalHeader>
<ModalBody>
{(createError || existingOAuthDisplayName) && (
<div className='mb-3 flex flex-col gap-2'>
{createError && (
<Badge variant='red' size='lg' dot className='max-w-full'>
{createError}
</Badge>
)}
{existingOAuthDisplayName && (
<Badge variant='red' size='lg' dot className='max-w-full'>
An integration named "{existingOAuthDisplayName.displayName}" already exists.
</Badge>
)}
</div>
)}
{existingOAuthDisplayName && (
<Badge variant='red' size='lg' dot className='max-w-full'>
An integration named "{existingOAuthDisplayName.displayName}" already exists.
</Badge>
)}
</div>
)}
<div className='flex flex-col gap-[12px]'>
<div className='flex flex-col gap-[10px]'>
<div>
<Label>Account</Label>
<div className='mt-[6px]'>
<Combobox
options={oauthServiceOptions}
value={
oauthServiceOptions.find((option) => option.value === createOAuthProviderId)
?.label || ''
}
selectedValue={createOAuthProviderId}
onChange={(value) => {
setCreateOAuthProviderId(value)
setCreateError(null)
}}
placeholder='Select OAuth service'
searchable
searchPlaceholder='Search services...'
overlayContent={
createOAuthProviderId
? (() => {
const config = getServiceConfigByProviderId(createOAuthProviderId)
const label =
oauthServiceOptions.find((o) => o.value === createOAuthProviderId)
?.label || ''
return (
<div className='flex items-center gap-[8px]'>
{config &&
createElement(config.icon, {
className: 'h-[14px] w-[14px] flex-shrink-0',
})}
<span className='truncate'>{label}</span>
</div>
)
})()
: undefined
}
<div className='flex flex-col gap-[16px]'>
<div className='flex items-center gap-[12px]'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{selectedOAuthService &&
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Connect your {selectedOAuthService?.name} account
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Grant access to use {selectedOAuthService?.name} in your workflows
</p>
</div>
</div>
{createDisplayScopes.length > 0 && (
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-[14px] py-[10px]'>
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
{createDisplayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-[10px]'>
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
</div>
<span className='text-[12px] text-[var(--text-primary)]'>
{getScopeDescription(scope)}
</span>
</li>
))}
</ul>
</div>
)}
<div>
<Label>
Display name<span className='ml-1'>*</span>
</Label>
<Input
value={createDisplayName}
onChange={(event) => setCreateDisplayName(event.target.value)}
placeholder='Integration name'
autoComplete='off'
data-lpignore='true'
className='mt-[6px]'
autoFocus
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-[6px] min-h-[80px] resize-none'
/>
</div>
</div>
<div>
<Label>
Display name<span className='ml-1'>*</span>
</Label>
<Input
value={createDisplayName}
onChange={(event) => setCreateDisplayName(event.target.value)}
placeholder='Integration name'
autoComplete='off'
data-lpignore='true'
className='mt-[6px]'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-[6px] min-h-[80px] resize-none'
/>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleCreateCredential}
disabled={
!createOAuthProviderId ||
!createDisplayName.trim() ||
connectOAuthService.isPending ||
Boolean(existingOAuthDisplayName) ||
disconnectOAuthService.isPending
}
>
{connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setCreateStep(1)
setCreateError(null)
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleConnectOAuthService}
disabled={
!createOAuthProviderId ||
!createDisplayName.trim() ||
connectOAuthService.isPending ||
Boolean(existingOAuthDisplayName) ||
disconnectOAuthService.isPending
}
>
{connectOAuthService.isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
const oauthRequiredModalJsx = showCreateOAuthRequiredModal && createOAuthProviderId && (
<OAuthRequiredModal
isOpen={showCreateOAuthRequiredModal}
onClose={() => setShowCreateOAuthRequiredModal(false)}
provider={createOAuthProviderId as OAuthProvider}
toolName={resolveProviderLabel(createOAuthProviderId)}
requiredScopes={createOAuthRequiredScopes}
newScopes={[]}
serviceId={selectedOAuthService?.id || createOAuthProviderId}
onConnect={async () => {
await handleConnectOAuthService()
}}
/>
)
const handleCloseDeleteDialog = () => {
setShowDeleteConfirmDialog(false)
setCredentialToDelete(null)
@@ -1083,7 +1163,6 @@ export function IntegrationsManager() {
</div>
{createModalJsx}
{oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
{unsavedChangesAlertJsx}
</>
@@ -1124,17 +1203,12 @@ export function IntegrationsManager() {
<CredentialSkeleton />
<CredentialSkeleton />
</div>
) : !hasCredentials ? (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
Click "Connect" above to get started
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{sortedCredentials.map((credential) => {
const serviceConfig = credential.providerId
? getServiceConfigByProviderId(credential.providerId)
: null
return (
<div key={credential.id} className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 items-center gap-[10px]'>
@@ -1169,18 +1243,53 @@ export function IntegrationsManager() {
</div>
)
})}
{showNoResults && (
<div className='py-[16px] text-center text-[14px] text-[var(--text-muted)]'>
No integrations found matching &ldquo;{searchTerm}&rdquo;
</div>
)}
{filteredAvailableIntegrations.length > 0 && (
<div
className={`flex flex-col gap-[8px]${hasCredentials || showNoResults ? ' mt-[8px] border-[var(--border)] border-t pt-[16px]' : ''}`}
>
<p className='mb-[4px] font-medium text-[12px] text-[var(--text-muted)]'>
Available integrations
</p>
{filteredAvailableIntegrations.map((service) => {
const serviceConfig = getServiceConfigByProviderId(service.providerId)
const isConnected = connectedProviderIds.has(service.providerId)
return (
<div
key={service.providerId}
className='flex items-center justify-between gap-[12px]'
>
<div className='flex min-w-0 items-center gap-[10px]'>
{serviceConfig && (
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[6px] bg-[var(--surface-5)]'>
{createElement(serviceConfig.icon, { className: 'h-4 w-4' })}
</div>
)}
<span className='truncate font-medium text-[15px]'>{service.name}</span>
</div>
<Button
variant='default'
onClick={() => handleAddForProvider(service.providerId)}
>
{isConnected ? 'Add account' : 'Connect'}
</Button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
</div>
{createModalJsx}
{oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
</>
)

View File

@@ -0,0 +1,283 @@
'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 [dragCounter, setDragCounter] = useState(0)
const isDragging = dragCounter > 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')) {
if (file.size > 5 * 1024 * 1024) {
setFileError('ZIP file is too large (max 5 MB)')
setFileState('error')
return
}
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) => prev + 1)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragCounter((prev) => prev - 1)
}, [])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
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 { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -12,10 +12,15 @@ 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
@@ -34,6 +39,8 @@ interface FieldErrors {
general?: string
}
type TabValue = 'create' | 'import'
export function SkillModal({
open,
onOpenChange,
@@ -52,6 +59,7 @@ 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)
@@ -60,6 +68,7 @@ export function SkillModal({
setDescription(initialValues?.description ?? '')
setContent(initialValues?.content ?? '')
setErrors({})
setActiveTab('create')
}
if (open !== prevOpen) setPrevOpen(open)
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
@@ -124,97 +133,137 @@ 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'>
<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>
{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}
</>
)}
</ModalContent>
</Modal>
)

View File

@@ -0,0 +1,191 @@
/**
* @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

@@ -0,0 +1,111 @@
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.replace(/\r\n/g, '\n').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')
}
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

@@ -103,7 +103,7 @@ export function CreditBalance({
{canPurchase && (
<Modal open={isOpen} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant='default' className='h-[32px] text-[13px]'>
<Button variant='active' className='h-[32px] text-[13px]'>
Add Credits
</Button>
</ModalTrigger>

View File

@@ -711,7 +711,7 @@ export function Subscription() {
const showProCard = !isOnMaxTier
return (
<div className='grid grid-cols-2 gap-[10px]'>
<div className='grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-[10px]'>
{showProCard && (
<CreditPlanCard
name='Pro'
@@ -801,6 +801,15 @@ export function Subscription() {
features={MAX_PLAN_FEATURES}
isLegacyPlan={isLegacyPlan && isOnMaxTier}
/>
{hasEnterprise && (
<PlanCard
name='Enterprise'
price=''
features={ENTERPRISE_PLAN_FEATURES}
buttonText='Contact'
onButtonClick={() => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
/>
)}
</div>
)
})()}
@@ -924,24 +933,26 @@ export function Subscription() {
{/* Billing details section */}
{(subscription.isPaid || (!isLoading && isTeamAdmin)) && (
<div className='flex flex-col gap-[14px] rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[14px] py-[12px]'>
<div className='flex flex-col'>
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={hasUsablePaidAccess && permissions.canEditUsageLimit}
entityType={
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
<div className='py-[2px]'>
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={hasUsablePaidAccess && permissions.canEditUsageLimit}
entityType={
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
}
isLoading={isLoading}
onPurchaseComplete={() => refetchSubscription()}
/>
</div>
)}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
<Label>{isCancelledAtPeriodEnd ? 'Access Until' : 'Next Billing Date'}</Label>
<span className='text-[13px] text-[var(--text-secondary)]'>
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
@@ -950,16 +961,18 @@ export function Subscription() {
)}
{subscription.isPaid && permissions.canViewUsageInfo && (
<BillingUsageNotificationsToggle />
<div className='border-[var(--border-1)] border-t pt-[16px]'>
<BillingUsageNotificationsToggle />
</div>
)}
{subscription.isPaid &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
<Label>Invoices</Label>
<Button
variant='outline'
variant='active'
size='sm'
disabled={openBillingPortal.isPending}
onClick={() => {
@@ -995,7 +1008,7 @@ export function Subscription() {
)}
{!isLoading && isTeamAdmin && (
<div className='flex items-center justify-between'>
<div className='flex items-center justify-between border-[var(--border-1)] border-t pt-[16px]'>
<div className='flex items-center gap-[6px]'>
<Label htmlFor='billed-account'>Billed Account</Label>
<Tooltip.Root>
@@ -1040,18 +1053,6 @@ export function Subscription() {
)}
</div>
)}
{/* Enterprise */}
{hasEnterprise && (
<PlanCard
name='Enterprise'
price=''
features={ENTERPRISE_PLAN_FEATURES}
buttonText='Contact'
onButtonClick={() => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
inlineButton
/>
)}
</div>
)
}

View File

@@ -61,6 +61,8 @@ export interface NavigationItem {
selfHostedOverride?: boolean
requiresSuperUser?: boolean
requiresAdminRole?: boolean
/** Show in the sidebar even when the user lacks the required plan, with an upgrade badge. */
showWhenLocked?: boolean
externalUrl?: string
}
@@ -137,13 +139,13 @@ export const allNavigationItems: NavigationItem[] = [
requiresMax: true,
requiresHosted: true,
selfHostedOverride: isInboxEnabled,
showWhenLocked: true,
},
{
id: 'credential-sets',
label: 'Email Polling',
icon: Mail,
section: 'system',
requiresTeam: true,
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},

View File

@@ -17,6 +17,8 @@ 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,
@@ -26,7 +28,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -100,7 +102,11 @@ export function McpDeploy({
}: McpDeployProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const { navigateToSettings } = useSettingsNavigation()
const [showMcpModal, setShowMcpModal] = useState(false)
const createMcpServer = useCreateMcpServer()
const { data: allowedMcpDomains = null } = useAllowedMcpDomains()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
const addToolMutation = useAddWorkflowMcpTool()
@@ -464,17 +470,27 @@ 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={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
>
Create MCP Server
</Button>
</div>
<>
<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}
/>
</>
)
}

View File

@@ -0,0 +1,211 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { client } from '@/lib/auth/auth-client'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
const logger = createLogger('ConnectCredentialModal')
export interface ConnectCredentialModalProps {
isOpen: boolean
onClose: () => void
provider: OAuthProvider
serviceId: string
workspaceId: string
workflowId: string
/** Number of existing credentials for this provider — used to detect a successful new connection. */
credentialCount: number
}
export function ConnectCredentialModal({
isOpen,
onClose,
provider,
serviceId,
workspaceId,
workflowId,
credentialCount,
}: ConnectCredentialModalProps) {
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null)
const createDraft = useCreateCredentialDraft()
const { providerName, ProviderIcon } = useMemo(() => {
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
let name = baseProviderConfig?.name || provider
let Icon = baseProviderConfig?.icon || (() => null)
if (baseProviderConfig) {
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
if (key === serviceId || service.providerId === provider) {
name = service.name
Icon = service.icon
break
}
}
}
return { providerName: name, ProviderIcon: Icon }
}, [provider, serviceId])
const providerId = getProviderIdFromServiceId(serviceId)
const displayScopes = useMemo(
() =>
getCanonicalScopesForProvider(providerId).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
),
[providerId]
)
const handleClose = () => {
setDisplayName('')
setError(null)
onClose()
}
const handleConnect = async () => {
const trimmedName = displayName.trim()
if (!trimmedName) {
setError('Display name is required.')
return
}
setError(null)
try {
await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmedName })
writeOAuthReturnContext({
origin: 'workflow',
workflowId,
displayName: trimmedName,
providerId,
preCount: credentialCount,
workspaceId,
requestedAt: Date.now(),
})
if (providerId === 'trello') {
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })
setError('Failed to connect. Please try again.')
}
}
const isPending = createDraft.isPending
return (
<Modal open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<div className='flex items-center gap-[12px]'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
<ProviderIcon className='h-[18px] w-[18px]' />
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Connect your {providerName} account
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Grant access to use {providerName} in your workflow
</p>
</div>
</div>
{displayScopes.length > 0 && (
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-[14px] py-[10px]'>
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-[10px]'>
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
</div>
<div className='flex flex-1 items-center gap-[8px] text-[12px] text-[var(--text-primary)]'>
<span>{getScopeDescription(scope)}</span>
</div>
</li>
))}
</ul>
</div>
)}
<div>
<Label>
Display name <span className='text-[var(--text-muted)]'>*</span>
</Label>
<Input
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isPending) void handleConnect()
}}
placeholder={`My ${providerName} account`}
autoComplete='off'
data-lpignore='true'
className='mt-[6px]'
/>
</div>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleConnect}
disabled={!displayName.trim() || isPending}
>
{isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -153,7 +153,7 @@ export function OAuthRequiredModal({
Permissions requested
</h4>
</div>
<ul className='max-h-[330px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
<ul className='max-h-[200px] space-y-[10px] overflow-y-auto px-[14px] py-[12px]'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-[10px]'>
<div className='mt-[2px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>

View File

@@ -1,13 +1,12 @@
'use client'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createElement, useCallback, useMemo, useState } from 'react'
import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionAccessState } 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,
@@ -16,17 +15,18 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { 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'))
@@ -50,6 +50,7 @@ export function CredentialSelector({
}: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
@@ -116,36 +117,11 @@ export function CredentialSelector({
[credentialSets, selectedCredentialSetId]
)
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
setInaccessibleCredentialName(null)
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const { data: inaccessibleCredential } = useWorkspaceCredential(
selectedId || undefined,
Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
)
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name
@@ -157,7 +133,6 @@ export function CredentialSelector({
const displayValue = isEditing ? editingValue : resolvedLabel
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
const { navigateToSettings } = useSettingsNavigation()
const handleOpenChange = useCallback(
(isOpen: boolean) => {
@@ -199,21 +174,8 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
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])
setShowConnectModal(true)
}, [])
const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
@@ -403,6 +365,18 @@ export function CredentialSelector({
</div>
)}
{showConnectModal && (
<ConnectCredentialModal
isOpen={showConnectModal}
onClose={() => setShowConnectModal(false)}
provider={provider}
serviceId={serviceId}
workspaceId={workspaceId}
workflowId={activeWorkflowId || ''}
credentialCount={credentials.length}
/>
)}
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}

View File

@@ -1,8 +1,9 @@
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
'use client'
import { createElement, useCallback, useMemo, useRef, useState } from 'react'
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,
@@ -13,10 +14,11 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
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) => {
@@ -71,11 +73,13 @@ export function ToolCredentialSelector({
const workspaceId = (params?.workspaceId as string) || ''
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { navigateToSettings } = useSettingsNavigation()
const { activeWorkflowId, workflows } = useWorkflowRegistry()
const effectiveWorkflowId =
activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined
const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
@@ -89,7 +93,7 @@ export function ToolCredentialSelector({
} = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
workflowId: effectiveWorkflowId,
})
const selectedCredential = useMemo(
@@ -97,36 +101,11 @@ export function ToolCredentialSelector({
[credentials, selectedId]
)
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
setInaccessibleCredentialName(null)
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const { data: inaccessibleCredential } = useWorkspaceCredential(
selectedId || undefined,
Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId)
)
const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null
const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name
@@ -164,18 +143,8 @@ export function ToolCredentialSelector({
)
const handleAddCredential = useCallback(() => {
writePendingCredentialCreateRequest({
workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
navigateToSettings({ section: 'integrations' })
}, [workspaceId, effectiveProviderId, serviceId])
setShowConnectModal(true)
}, [])
const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({
@@ -261,6 +230,18 @@ export function ToolCredentialSelector({
</div>
)}
{showConnectModal && (
<ConnectCredentialModal
isOpen={showConnectModal}
onClose={() => setShowConnectModal(false)}
provider={provider}
serviceId={serviceId}
workspaceId={workspaceId}
workflowId={effectiveWorkflowId || ''}
credentialCount={credentials.length}
/>
)}
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}

View File

@@ -27,6 +27,7 @@ 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,
@@ -48,6 +49,7 @@ 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,
@@ -55,12 +57,15 @@ 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'
@@ -330,24 +335,6 @@ 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.
@@ -469,6 +456,7 @@ 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)
@@ -507,6 +495,9 @@ 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 })
@@ -1379,7 +1370,7 @@ export const ToolInput = memo(function ToolInput({
icon: McpIcon,
onSelect: () => {
setOpen(false)
navigateToSettings({ section: 'mcp' })
setMcpModalOpen(true)
},
disabled: isPreview,
})
@@ -2095,6 +2086,18 @@ 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

@@ -1,15 +1,19 @@
import { Folder } from 'lucide-react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
import Link from 'next/link'
import {
Blimp,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -17,19 +21,123 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface CollapsedSidebarMenuProps {
icon: React.ReactNode
hover: ReturnType<typeof useHoverMenu>
onClick?: () => void
ariaLabel?: string
children: React.ReactNode
className?: string
primaryAction?: {
label: string
onSelect: () => void
}
}
interface CollapsedTaskFlyoutItemProps {
task: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
isCurrentRoute: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
isRenaming?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, taskId: string) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, taskId: string) => void
}
interface CollapsedWorkflowFlyoutItemProps {
workflow: WorkflowMetadata
href: string
isCurrentRoute?: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
isRenaming?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
}
const EDIT_ROW_CLASS =
'mx-[2px] flex min-h-[30px] min-w-0 cursor-default select-none items-center gap-[8px] rounded-[5px] bg-[var(--surface-active)] px-[8px] py-[5px] font-medium text-[12px] text-[var(--text-body)]'
function FlyoutMoreButton({
ariaLabel,
onPointerDown,
onClick,
}: {
ariaLabel: string
onPointerDown?: () => void
onClick: (e: ReactMouseEvent<HTMLButtonElement>) => void
}) {
return (
<button
type='button'
aria-label={ariaLabel}
onPointerDown={onPointerDown}
onClick={onClick}
className='-translate-y-1/2 absolute top-1/2 right-[8px] z-10 flex h-[18px] w-[18px] items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100'
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
)
}
function TaskStatusIcon({
isActive,
isUnread,
hideStatusOnHover = false,
}: {
isActive?: boolean
isUnread?: boolean
hideStatusOnHover?: boolean
}) {
return (
<span className='relative flex-shrink-0'>
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
{isActive && (
<span
className={cn(
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-amber-400',
hideStatusOnHover && 'group-hover:hidden'
)}
/>
)}
{!isActive && isUnread && (
<span
className={cn(
'-right-[1px] -bottom-[1px] absolute h-[6px] w-[6px] rounded-full border border-[var(--surface-1)] bg-[#33C482]',
hideStatusOnHover && 'group-hover:hidden'
)}
/>
)}
</span>
)
}
function WorkflowColorSwatch({ color }: { color: string }) {
return (
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: color,
borderColor: `${color}60`,
backgroundClip: 'padding-box',
}}
/>
)
}
export function CollapsedSidebarMenu({
icon,
hover,
onClick,
ariaLabel,
children,
className,
primaryAction,
}: CollapsedSidebarMenuProps) {
return (
<div className={cn('flex flex-col px-[8px]', className)}>
@@ -47,13 +155,21 @@ export function CollapsedSidebarMenu({
type='button'
aria-label={ariaLabel}
className='mx-[2px] flex h-[30px] items-center rounded-[8px] px-[8px] hover:bg-[var(--surface-active)]'
onClick={onClick}
>
{icon}
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{primaryAction && (
<>
<DropdownMenuItem onSelect={primaryAction.onSelect}>
<Plus className='h-[14px] w-[14px]' />
{primaryAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
@@ -61,14 +177,185 @@ export function CollapsedSidebarMenu({
)
}
export function CollapsedTaskFlyoutItem({
task,
isCurrentRoute,
isEditing = false,
editValue,
inputRef,
isRenaming = false,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onContextMenu,
onMorePointerDown,
onMoreClick,
}: CollapsedTaskFlyoutItemProps) {
const showActions = task.id !== 'new' && onMoreClick
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<TaskStatusIcon isActive={task.isActive} isUnread={task.isUnread} />
<input
aria-label={`Rename task ${task.name}`}
ref={inputRef}
value={editValue ?? task.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
)
}
return (
<div className='group relative mx-[2px]'>
<Link
href={task.href}
className={cn(
'flex min-h-[30px] min-w-0 items-center rounded-[5px] px-[8px] py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
)}
onContextMenu={
task.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, task.id) : undefined
}
>
<ConversationListItem
title={task.name}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
statusIndicatorClassName={!isCurrentRoute ? 'group-hover:hidden' : undefined}
/>
</Link>
{showActions && (
<FlyoutMoreButton
ariaLabel='Task options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick?.(e, task.id)
}}
/>
)}
</div>
)
}
export function CollapsedWorkflowFlyoutItem({
workflow,
href,
isCurrentRoute = false,
isEditing = false,
editValue,
inputRef,
isRenaming = false,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onContextMenu,
onMorePointerDown,
onMoreClick,
}: CollapsedWorkflowFlyoutItemProps) {
const showActions = !!onMoreClick
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<WorkflowColorSwatch color={workflow.color} />
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[12px] text-[var(--text-body)] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
)
}
return (
<div className='group relative mx-[2px]'>
<Link
href={href}
className={cn(
'flex min-h-[30px] min-w-0 items-center gap-[8px] rounded-[5px] px-[8px] py-[5px] pr-[30px] font-medium text-[12px] text-[var(--text-body)] hover:bg-[var(--surface-active)] group-focus-within:bg-[var(--surface-active)] group-hover:bg-[var(--surface-active)]',
isCurrentRoute && 'bg-[var(--surface-active)]'
)}
onContextMenu={onContextMenu ? (e) => onContextMenu(e, workflow) : undefined}
>
<WorkflowColorSwatch color={workflow.color} />
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
</Link>
{showActions && (
<FlyoutMoreButton
ariaLabel='Workflow options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick?.(e, workflow)
}}
/>
)}
</div>
)
}
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
currentWorkflowId,
editingWorkflowId,
editingValue,
editInputRef,
isRenamingWorkflow,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onWorkflowContextMenu,
onWorkflowMorePointerDown,
onWorkflowMoreClick,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
workspaceId: string
currentWorkflowId?: string
editingWorkflowId?: string | null
editingValue?: string
editInputRef?: React.RefObject<HTMLInputElement | null>
isRenamingWorkflow?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
onWorkflowMorePointerDown?: () => void
onWorkflowMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, workflow: WorkflowMetadata) => void
}) {
return (
<>
@@ -96,21 +383,35 @@ export function CollapsedFolderItems({
nodes={folder.children}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={currentWorkflowId}
editingWorkflowId={editingWorkflowId}
editingValue={editingValue}
editInputRef={editInputRef}
isRenamingWorkflow={isRenamingWorkflow}
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onWorkflowContextMenu={onWorkflowContextMenu}
onWorkflowMorePointerDown={onWorkflowMorePointerDown}
onWorkflowMoreClick={onWorkflowMoreClick}
/>
{folderWorkflows.map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === currentWorkflowId}
isEditing={workflow.id === editingWorkflowId}
editValue={editingValue}
inputRef={editInputRef}
isRenaming={isRenamingWorkflow}
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onContextMenu={onWorkflowContextMenu}
onMorePointerDown={onWorkflowMorePointerDown}
onMoreClick={onWorkflowMoreClick}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>

View File

@@ -1,6 +1,8 @@
export {
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedTaskFlyoutItem,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu/collapsed-sidebar-menu'
export { HelpModal } from './help-modal/help-modal'
export { NavItemContextMenu } from './nav-item-context-menu'

View File

@@ -27,12 +27,12 @@ const SKELETON_SECTIONS = [3, 2, 2] as const
interface SettingsSidebarProps {
isCollapsed?: boolean
showCollapsedContent?: boolean
showCollapsedTooltips?: boolean
}
export function SettingsSidebar({
isCollapsed = false,
showCollapsedContent = false,
showCollapsedTooltips = false,
}: SettingsSidebarProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -74,55 +74,62 @@ export function SettingsSidebar({
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
const navigationItems = useMemo(() => {
return allNavigationItems.flatMap((item) => {
return allNavigationItems.filter((item) => {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return []
return false
}
if (item.id === 'template-profile') {
return []
return false
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return []
return false
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
return []
return false
}
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
return []
return false
}
if (item.id === 'skills' && permissionConfig.disableSkills) {
return []
return false
}
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true ? [{ ...item, disabled: false }] : []
return !hasProviders || isSSOProviderOwner === true
}
return [{ ...item, disabled: false }]
return true
}
if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) {
return false
}
if (item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) {
return false
}
if (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess && !item.showWhenLocked) {
return false
}
if (item.requiresHosted && !isHosted) {
return []
return false
}
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
return []
return false
}
if (item.requiresAdminRole && !isSuperUser) {
return []
return false
}
const disabled =
(item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) ||
(item.requiresEnterprise && (!hasEnterprisePlan || !isOrgAdminOrOwner)) ||
(item.requiresMax && !subscriptionAccess.hasUsableMaxAccess)
return [{ ...item, disabled }]
return true
})
}, [
hasTeamPlan,
@@ -192,7 +199,7 @@ export function SettingsSidebar({
<span className='truncate font-base text-[var(--text-body)]'>Back</span>
</button>
</Tooltip.Trigger>
{showCollapsedContent && (
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>Back</p>
</Tooltip.Content>
@@ -250,20 +257,22 @@ export function SettingsSidebar({
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const disabled = Boolean(item.disabled)
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
const itemClassName = cn(
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px]',
disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-[var(--surface-active)]',
active && !disabled && 'bg-[var(--surface-active)]'
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
active && 'bg-[var(--surface-active)]'
)
const content = (
<>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-base text-[var(--text-body)]'>
<span className='min-w-0 truncate font-base text-[var(--text-body)]'>
{item.label}
</span>
{isLocked && (
<span className='ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-[4px] py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
</span>
)}
</>
)
@@ -280,11 +289,9 @@ export function SettingsSidebar({
<button
type='button'
className={itemClassName}
disabled={disabled}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() =>
!disabled &&
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
scroll: false,
})
@@ -297,7 +304,7 @@ export function SettingsSidebar({
return (
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedContent && (
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>

View File

@@ -15,9 +15,11 @@ import {
import {
Check,
Duplicate,
Eye,
FolderPlus,
Lock,
LogOut,
Mail,
Palette,
Pencil,
Plus,
@@ -230,6 +232,8 @@ interface ContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onOpenInNewTab?: () => void
onMarkAsRead?: () => void
onMarkAsUnread?: () => void
onRename?: () => void
onCreate?: () => void
onCreateFolder?: () => void
@@ -239,6 +243,8 @@ interface ContextMenuProps {
onColorChange?: (color: string) => void
currentColor?: string
showOpenInNewTab?: boolean
showMarkAsRead?: boolean
showMarkAsUnread?: boolean
showRename?: boolean
showCreate?: boolean
showCreateFolder?: boolean
@@ -246,6 +252,8 @@ interface ContextMenuProps {
showExport?: boolean
showColorChange?: boolean
disableExport?: boolean
disableMarkAsRead?: boolean
disableMarkAsUnread?: boolean
disableColorChange?: boolean
disableRename?: boolean
disableDuplicate?: boolean
@@ -259,6 +267,7 @@ interface ContextMenuProps {
showLock?: boolean
disableLock?: boolean
isLocked?: boolean
showDelete?: boolean
}
/**
@@ -271,6 +280,8 @@ export function ContextMenu({
menuRef,
onClose,
onOpenInNewTab,
onMarkAsRead,
onMarkAsUnread,
onRename,
onCreate,
onCreateFolder,
@@ -280,6 +291,8 @@ export function ContextMenu({
onColorChange,
currentColor,
showOpenInNewTab = false,
showMarkAsRead = false,
showMarkAsUnread = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
@@ -287,6 +300,8 @@ export function ContextMenu({
showExport = false,
showColorChange = false,
disableExport = false,
disableMarkAsRead = false,
disableMarkAsUnread = false,
disableColorChange = false,
disableRename = false,
disableDuplicate = false,
@@ -300,6 +315,7 @@ export function ContextMenu({
showLock = false,
disableLock = false,
isLocked = false,
showDelete = true,
}: ContextMenuProps) {
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
@@ -346,6 +362,7 @@ export function ContextMenu({
}, [])
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread)
const hasEditSection =
(showRename && onRename) ||
(showCreate && onCreate) ||
@@ -387,7 +404,35 @@ export function ContextMenu({
Open in new tab
</DropdownMenuItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
{hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
<DropdownMenuSeparator />
)}
{showMarkAsRead && onMarkAsRead && (
<DropdownMenuItem
disabled={disableMarkAsRead}
onSelect={() => {
onMarkAsRead()
onClose()
}}
>
<Eye />
Mark as read
</DropdownMenuItem>
)}
{showMarkAsUnread && onMarkAsUnread && (
<DropdownMenuItem
disabled={disableMarkAsUnread}
onSelect={() => {
onMarkAsUnread()
onClose()
}}
>
<Mail />
Mark as unread
</DropdownMenuItem>
)}
{hasStatusSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
{showRename && onRename && (
<DropdownMenuItem
@@ -478,7 +523,8 @@ export function ContextMenu({
</DropdownMenuItem>
)}
{(hasNavigationSection || hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
(showLeave || showDelete) && <DropdownMenuSeparator />}
{showLeave && onLeave && (
<DropdownMenuItem
disabled={disableLeave}
@@ -491,16 +537,18 @@ export function ContextMenu({
Leave
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={disableDelete}
onSelect={() => {
onDelete()
onClose()
}}
>
<Trash />
Delete
</DropdownMenuItem>
{showDelete && (
<DropdownMenuItem
disabled={disableDelete}
onSelect={() => {
onDelete()
onClose()
}}
>
<Trash />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -1,6 +1,7 @@
export { useAutoScroll } from './use-auto-scroll'
export { useContextMenu } from './use-context-menu'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFlyoutInlineRename } from './use-flyout-inline-rename'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useFolderSelection } from './use-folder-selection'

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useFlyoutInlineRename')
interface RenameTarget {
id: string
name: string
}
interface UseFlyoutInlineRenameProps {
itemType: string
onSave: (id: string, name: string) => Promise<void>
}
export function useFlyoutInlineRename({ itemType, onSave }: UseFlyoutInlineRenameProps) {
const [editingTarget, setEditingTarget] = useState<RenameTarget | null>(null)
const [value, setValue] = useState('')
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const cancelRequestedRef = useRef(false)
const isSavingRef = useRef(false)
useEffect(() => {
if (editingTarget && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingTarget])
const startRename = useCallback((target: RenameTarget) => {
cancelRequestedRef.current = false
setEditingTarget(target)
setValue(target.name)
}, [])
const cancelRename = useCallback(() => {
cancelRequestedRef.current = true
setEditingTarget(null)
}, [])
const saveRename = useCallback(async () => {
if (cancelRequestedRef.current) {
cancelRequestedRef.current = false
return
}
if (!editingTarget || isSavingRef.current) {
return
}
const trimmedValue = value.trim()
if (!trimmedValue || trimmedValue === editingTarget.name) {
setEditingTarget(null)
return
}
isSavingRef.current = true
setIsSaving(true)
try {
await onSave(editingTarget.id, trimmedValue)
setEditingTarget(null)
} catch (error) {
logger.error(`Failed to rename ${itemType}:`, {
error,
itemId: editingTarget.id,
oldName: editingTarget.name,
newName: trimmedValue,
})
setValue(editingTarget.name)
} finally {
isSavingRef.current = false
setIsSaving(false)
}
}, [editingTarget, itemType, onSave, value])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
void saveRename()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
},
[cancelRename, saveRename]
)
return {
editingId: editingTarget?.id ?? null,
value,
setValue,
isSaving,
inputRef,
startRename,
cancelRename,
saveRename,
handleKeyDown,
}
}

View File

@@ -12,6 +12,8 @@ const preventAutoFocus = (e: Event) => e.preventDefault()
export function useHoverMenu() {
const [isOpen, setIsOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLockedRef = useRef(false)
const hoverRegionCountRef = useRef(0)
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
@@ -29,8 +31,15 @@ export function useHoverMenu() {
}, [])
const scheduleClose = useCallback(() => {
if (isLockedRef.current) {
return
}
cancelClose()
closeTimerRef.current = setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS)
closeTimerRef.current = setTimeout(() => {
if (!isLockedRef.current && hoverRegionCountRef.current === 0) {
setIsOpen(false)
}
}, CLOSE_DELAY_MS)
}, [cancelClose])
const open = useCallback(() => {
@@ -39,24 +48,64 @@ export function useHoverMenu() {
}, [cancelClose])
const close = useCallback(() => {
if (isLockedRef.current) {
return
}
cancelClose()
setIsOpen(false)
}, [cancelClose])
const setLocked = useCallback(
(locked: boolean) => {
isLockedRef.current = locked
cancelClose()
if (locked) {
setIsOpen(true)
} else if (hoverRegionCountRef.current === 0) {
setIsOpen(false)
}
},
[cancelClose]
)
const handleTriggerMouseEnter = useCallback(() => {
hoverRegionCountRef.current += 1
open()
}, [open])
const handleTriggerMouseLeave = useCallback(() => {
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
scheduleClose()
}, [scheduleClose])
const handleContentMouseEnter = useCallback(() => {
hoverRegionCountRef.current += 1
cancelClose()
}, [cancelClose])
const handleContentMouseLeave = useCallback(() => {
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
scheduleClose()
}, [scheduleClose])
const triggerProps = useMemo(
() => ({ onMouseEnter: open, onMouseLeave: scheduleClose }) as const,
[open, scheduleClose]
() =>
({
onMouseEnter: handleTriggerMouseEnter,
onMouseLeave: handleTriggerMouseLeave,
}) as const,
[handleTriggerMouseEnter, handleTriggerMouseLeave]
)
const contentProps = useMemo(
() =>
({
onMouseEnter: cancelClose,
onMouseLeave: scheduleClose,
onMouseEnter: handleContentMouseEnter,
onMouseLeave: handleContentMouseLeave,
onCloseAutoFocus: preventAutoFocus,
}) as const,
[cancelClose, scheduleClose]
[handleContentMouseEnter, handleContentMouseLeave]
)
return { isOpen, open, close, triggerProps, contentProps }
return { isOpen, open, close, setLocked, triggerProps, contentProps }
}

View File

@@ -33,10 +33,10 @@ import {
Settings,
Sim,
Table,
Wordmark,
} from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import {
START_NAV_TOUR_EVENT,
START_WORKFLOW_TOUR_EVENT,
@@ -47,6 +47,8 @@ import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-uti
import {
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedTaskFlyoutItem,
CollapsedWorkflowFlyoutItem,
HelpModal,
NavItemContextMenu,
SearchModal,
@@ -58,6 +60,7 @@ import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
useContextMenu,
useFlyoutInlineRename,
useFolderOperations,
useHoverMenu,
useSidebarResize,
@@ -74,7 +77,14 @@ import {
} from '@/app/workspace/[workspaceId]/w/hooks'
import { getBrandConfig } from '@/ee/whitelabeling'
import { useFolders } from '@/hooks/queries/folders'
import { useDeleteTask, useDeleteTasks, useRenameTask, useTasks } from '@/hooks/queries/tasks'
import {
useDeleteTask,
useDeleteTasks,
useMarkTaskRead,
useMarkTaskUnread,
useRenameTask,
useTasks,
} from '@/hooks/queries/tasks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useTaskEvents } from '@/hooks/use-task-events'
@@ -82,6 +92,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Sidebar')
@@ -99,7 +110,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected,
isActive,
isUnread,
showCollapsedContent,
showCollapsedTooltips,
onMultiSelectClick,
onContextMenu,
onMorePointerDown,
@@ -110,7 +121,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
isSelected: boolean
isActive: boolean
isUnread: boolean
showCollapsedContent: boolean
showCollapsedTooltips: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
onMorePointerDown: () => void
@@ -171,7 +182,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
)}
</Link>
</Tooltip.Trigger>
{showCollapsedContent && (
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{task.name}</p>
</Tooltip.Content>
@@ -191,12 +202,12 @@ interface SidebarNavItemData {
const SidebarNavItem = memo(function SidebarNavItem({
item,
active,
showCollapsedContent,
showCollapsedTooltips,
onContextMenu,
}: {
item: SidebarNavItemData
active: boolean
showCollapsedContent: boolean
showCollapsedTooltips: boolean
onContextMenu?: (e: React.MouseEvent, href: string) => void
}) {
const Icon = item.icon
@@ -245,7 +256,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedContent && (
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
@@ -296,7 +307,8 @@ export const Sidebar = memo(function Sidebar() {
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
// Delay collapsed tooltips until the width transition finishes.
const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed)
useLayoutEffect(() => {
if (!isCollapsed) {
@@ -306,10 +318,10 @@ export const Sidebar = memo(function Sidebar() {
useEffect(() => {
if (isCollapsed) {
const timer = setTimeout(() => setShowCollapsedContent(true), 200)
const timer = setTimeout(() => setShowCollapsedTooltips(true), 200)
return () => clearTimeout(timer)
}
setShowCollapsedContent(false)
setShowCollapsedTooltips(false)
}, [isCollapsed])
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
@@ -398,6 +410,7 @@ export const Sidebar = memo(function Sidebar() {
useFolders(workspaceId)
const folders = useFolderStore((s) => s.folders)
const getFolderTree = useFolderStore((s) => s.getFolderTree)
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
const folderTree = useMemo(
() => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []),
@@ -450,7 +463,11 @@ export const Sidebar = memo(function Sidebar() {
const deleteTaskMutation = useDeleteTask(workspaceId)
const deleteTasksMutation = useDeleteTasks(workspaceId)
const markTaskReadMutation = useMarkTaskRead(workspaceId)
const markTaskUnreadMutation = useMarkTaskUnread(workspaceId)
const renameTaskMutation = useRenameTask(workspaceId)
const tasksHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const {
isOpen: isTaskContextMenuOpen,
@@ -482,9 +499,11 @@ export const Sidebar = memo(function Sidebar() {
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
captureTaskSelection(taskId)
tasksHover.setLocked(true)
preventTaskDismiss()
handleTaskContextMenuBase(e)
},
[captureTaskSelection, handleTaskContextMenuBase]
[captureTaskSelection, handleTaskContextMenuBase, preventTaskDismiss, tasksHover]
)
const handleTaskMorePointerDown = useCallback(() => {
@@ -499,6 +518,7 @@ export const Sidebar = memo(function Sidebar() {
closeTaskContextMenu()
return
}
tasksHover.setLocked(true)
captureTaskSelection(taskId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
@@ -508,7 +528,84 @@ export const Sidebar = memo(function Sidebar() {
clientY: rect.top,
} as React.MouseEvent)
},
[isTaskContextMenuOpen, closeTaskContextMenu, captureTaskSelection, handleTaskContextMenuBase]
[
isTaskContextMenuOpen,
closeTaskContextMenu,
captureTaskSelection,
handleTaskContextMenuBase,
tasksHover,
]
)
const {
isOpen: isCollapsedWorkflowContextMenuOpen,
position: collapsedWorkflowContextMenuPosition,
menuRef: collapsedWorkflowMenuRef,
handleContextMenu: handleCollapsedWorkflowContextMenuBase,
closeMenu: closeCollapsedWorkflowContextMenu,
preventDismiss: preventCollapsedWorkflowDismiss,
} = useContextMenu()
const collapsedWorkflowContextMenuRef = useRef<{
workflowId: string
workflowName: string
} | null>(null)
const captureCollapsedWorkflowSelection = useCallback(
(workflow: { id: string; name: string }) => {
collapsedWorkflowContextMenuRef.current = {
workflowId: workflow.id,
workflowName: workflow.name,
}
},
[]
)
const handleCollapsedWorkflowContextMenu = useCallback(
(e: React.MouseEvent, workflow: { id: string; name: string }) => {
captureCollapsedWorkflowSelection(workflow)
workflowsHover.setLocked(true)
preventCollapsedWorkflowDismiss()
handleCollapsedWorkflowContextMenuBase(e)
},
[
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
preventCollapsedWorkflowDismiss,
workflowsHover,
]
)
const handleCollapsedWorkflowMorePointerDown = useCallback(() => {
if (isCollapsedWorkflowContextMenuOpen) {
preventCollapsedWorkflowDismiss()
}
}, [isCollapsedWorkflowContextMenuOpen, preventCollapsedWorkflowDismiss])
const handleCollapsedWorkflowMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, workflow: { id: string; name: string }) => {
if (isCollapsedWorkflowContextMenuOpen) {
closeCollapsedWorkflowContextMenu()
return
}
workflowsHover.setLocked(true)
captureCollapsedWorkflowSelection(workflow)
const rect = e.currentTarget.getBoundingClientRect()
handleCollapsedWorkflowContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[
isCollapsedWorkflowContextMenuOpen,
closeCollapsedWorkflowContextMenu,
captureCollapsedWorkflowSelection,
handleCollapsedWorkflowContextMenuBase,
workflowsHover,
]
)
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
@@ -653,6 +750,10 @@ export const Sidebar = memo(function Sidebar() {
const { selectedTasks, handleTaskClick } = useTaskSelection({ taskIds })
const isMultiTaskContextMenu = contextMenuSelectionRef.current.taskIds.length > 1
const activeTaskContextMenuItem =
!isMultiTaskContextMenu && contextMenuSelectionRef.current.taskIds.length === 1
? tasks.find((task) => task.id === contextMenuSelectionRef.current.taskIds[0])
: null
const [isTaskDeleteModalOpen, setIsTaskDeleteModalOpen] = useState(false)
@@ -699,19 +800,31 @@ export const Sidebar = memo(function Sidebar() {
}, [pathname, workspaceId, deleteTaskMutation, deleteTasksMutation, navigateToPage])
const [visibleTaskCount, setVisibleTaskCount] = useState(5)
const [renamingTaskId, setRenamingTaskId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const tasksHover = useHoverMenu()
const workflowsHover = useHoverMenu()
const renameInputRef = useRef<HTMLInputElement>(null)
const renameCanceledRef = useRef(false)
const taskFlyoutRename = useFlyoutInlineRename({
itemType: 'task',
onSave: async (taskId, name) => {
await renameTaskMutation.mutateAsync({ chatId: taskId, title: name })
},
})
const workflowFlyoutRename = useFlyoutInlineRename({
itemType: 'workflow',
onSave: async (workflowIdToRename, name) => {
await updateWorkflow(workflowIdToRename, { name })
collapsedWorkflowContextMenuRef.current = {
workflowId: workflowIdToRename,
workflowName: name,
}
},
})
useEffect(() => {
if (renamingTaskId && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
}
}, [renamingTaskId])
tasksHover.setLocked(isTaskContextMenuOpen || !!taskFlyoutRename.editingId)
}, [isTaskContextMenuOpen, taskFlyoutRename.editingId, tasksHover.setLocked])
useEffect(() => {
workflowsHover.setLocked(isCollapsedWorkflowContextMenuOpen || !!workflowFlyoutRename.editingId)
}, [isCollapsedWorkflowContextMenuOpen, workflowFlyoutRename.editingId, workflowsHover.setLocked])
const handleTaskOpenInNewTab = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
@@ -719,51 +832,44 @@ export const Sidebar = memo(function Sidebar() {
window.open(`/workspace/${workspaceId}/task/${ids[0]}`, '_blank', 'noopener,noreferrer')
}, [workspaceId])
const handleMarkTaskAsRead = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
markTaskReadMutation.mutate(ids[0])
}, [markTaskReadMutation])
const handleMarkTaskAsUnread = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
markTaskUnreadMutation.mutate(ids[0])
}, [markTaskUnreadMutation])
const handleStartTaskRename = useCallback(() => {
const { taskIds: ids } = contextMenuSelectionRef.current
if (ids.length !== 1) return
const taskId = ids[0]
const task = tasks.find((t) => t.id === taskId)
if (!task) return
renameCanceledRef.current = false
setRenamingTaskId(taskId)
setRenameValue(task.name)
}, [tasks])
tasksHover.setLocked(true)
taskFlyoutRename.startRename({ id: taskId, name: task.name })
}, [taskFlyoutRename, tasks, tasksHover])
const handleSaveTaskRename = useCallback(() => {
if (renameCanceledRef.current) {
renameCanceledRef.current = false
return
}
const trimmed = renameValue.trim()
if (!renamingTaskId || !trimmed) {
setRenamingTaskId(null)
return
}
const task = tasks.find((t) => t.id === renamingTaskId)
if (task && trimmed !== task.name) {
renameTaskMutation.mutate({ chatId: renamingTaskId, title: trimmed })
}
setRenamingTaskId(null)
}, [renamingTaskId, renameValue, tasks, renameTaskMutation])
const handleCollapsedWorkflowOpenInNewTab = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
window.open(
`/workspace/${workspaceId}/w/${workflow.workflowId}`,
'_blank',
'noopener,noreferrer'
)
}, [workspaceId])
const handleCancelTaskRename = useCallback(() => {
renameCanceledRef.current = true
setRenamingTaskId(null)
}, [])
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveTaskRename()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelTaskRename()
}
},
[handleSaveTaskRename, handleCancelTaskRename]
)
const handleStartCollapsedWorkflowRename = useCallback(() => {
const workflow = collapsedWorkflowContextMenuRef.current
if (!workflow) return
workflowsHover.setLocked(true)
workflowFlyoutRename.startRename({ id: workflow.workflowId, name: workflow.workflowName })
}, [workflowFlyoutRename, workflowsHover])
const [hasOverflowTop, setHasOverflowTop] = useState(false)
const [hasOverflowBottom, setHasOverflowBottom] = useState(false)
@@ -998,14 +1104,34 @@ export const Sidebar = memo(function Sidebar() {
<div className='flex h-full flex-col pt-[12px]'>
{/* Top bar: Logo + Collapse toggle */}
<div className='flex flex-shrink-0 items-center pr-[8px] pb-[8px] pl-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{showCollapsedContent ? (
<div className='relative flex h-[30px] items-center'>
<Link
href={`/workspace/${workspaceId}/home`}
className='sidebar-collapse-hide sidebar-collapse-remove flex h-[30px] items-center rounded-[8px] px-[6px] hover:bg-[var(--surface-active)]'
tabIndex={isCollapsed ? -1 : 0}
>
{brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}
width={16}
height={16}
className='h-[16px] w-[16px] object-contain'
unoptimized
/>
) : (
<Wordmark className='h-[16px] w-auto text-[var(--text-body)]' />
)}
</Link>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={toggleCollapsed}
className='group flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
className='sidebar-collapse-show group absolute left-0 flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
aria-label='Expand sidebar'
tabIndex={isCollapsed ? 0 : -1}
>
{brand.logoUrl ? (
<Image
@@ -1021,32 +1147,14 @@ export const Sidebar = memo(function Sidebar() {
)}
<PanelLeft className='hidden h-[16px] w-[16px] rotate-180 text-[var(--text-icon)] group-hover:block' />
</button>
) : (
<Link
href={`/workspace/${workspaceId}/home`}
className='flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
>
{brand.logoUrl ? (
<Image
src={brand.logoUrl}
alt={brand.name}
width={16}
height={16}
className='h-[16px] w-[16px] object-contain'
unoptimized
/>
) : (
<Sim className='h-[16px] w-[16px] text-[var(--text-icon)]' />
)}
</Link>
</Tooltip.Trigger>
{isCollapsed && (
<Tooltip.Content side='right'>
<p>Expand sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>Expand sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</Tooltip.Root>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
@@ -1097,7 +1205,7 @@ export const Sidebar = memo(function Sidebar() {
{isOnSettingsPage ? (
<SettingsSidebar
isCollapsed={isCollapsed}
showCollapsedContent={showCollapsedContent}
showCollapsedTooltips={showCollapsedTooltips}
/>
) : (
<>
@@ -1108,7 +1216,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedContent={showCollapsedContent}
showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
/>
))}
@@ -1125,7 +1233,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={item.href ? !!pathname?.startsWith(item.href) : false}
showCollapsedContent={showCollapsedContent}
showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={handleNavItemContextMenu}
/>
))}
@@ -1169,9 +1277,12 @@ export const Sidebar = memo(function Sidebar() {
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
}
hover={tasksHover}
onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
className='mt-[6px]'
primaryAction={{
label: 'New task',
onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
}}
>
{tasksLoading ? (
<DropdownMenuItem disabled>
@@ -1180,15 +1291,21 @@ export const Sidebar = memo(function Sidebar() {
</DropdownMenuItem>
) : (
tasks.map((task) => (
<DropdownMenuItem key={task.id} asChild>
<Link href={task.href}>
<ConversationListItem
title={task.name}
isActive={task.isActive}
isUnread={task.isUnread}
/>
</Link>
</DropdownMenuItem>
<CollapsedTaskFlyoutItem
key={task.id}
task={task}
isCurrentRoute={task.id !== 'new' && pathname === task.href}
isEditing={task.id === taskFlyoutRename.editingId}
editValue={taskFlyoutRename.value}
inputRef={taskFlyoutRename.inputRef}
isRenaming={taskFlyoutRename.isSaving}
onEditValueChange={taskFlyoutRename.setValue}
onEditKeyDown={taskFlyoutRename.handleKeyDown}
onEditBlur={() => void taskFlyoutRename.saveRename()}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
))
)}
</CollapsedSidebarMenu>
@@ -1200,7 +1317,7 @@ export const Sidebar = memo(function Sidebar() {
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isRenaming = taskFlyoutRename.editingId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
@@ -1211,11 +1328,11 @@ export const Sidebar = memo(function Sidebar() {
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
ref={taskFlyoutRename.inputRef}
value={taskFlyoutRename.value}
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
onKeyDown={taskFlyoutRename.handleKeyDown}
onBlur={() => void taskFlyoutRename.saveRename()}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
@@ -1230,7 +1347,7 @@ export const Sidebar = memo(function Sidebar() {
isSelected={isSelected}
isActive={!!task.isActive}
isUnread={!!task.isUnread}
showCollapsedContent={showCollapsedContent}
showCollapsedTooltips={showCollapsedTooltips}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
@@ -1336,9 +1453,12 @@ export const Sidebar = memo(function Sidebar() {
/>
}
hover={workflowsHover}
onClick={handleCreateWorkflow}
ariaLabel='Workflows'
className='mt-[6px]'
primaryAction={{
label: 'New workflow',
onSelect: handleCreateWorkflow,
}}
>
{workflowsLoading && regularWorkflows.length === 0 ? (
<DropdownMenuItem disabled>
@@ -1353,21 +1473,35 @@ export const Sidebar = memo(function Sidebar() {
nodes={folderTree}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={workflowId}
editingWorkflowId={workflowFlyoutRename.editingId}
editingValue={workflowFlyoutRename.value}
editInputRef={workflowFlyoutRename.inputRef}
isRenamingWorkflow={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onWorkflowContextMenu={handleCollapsedWorkflowContextMenu}
onWorkflowMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onWorkflowMoreClick={handleCollapsedWorkflowMoreClick}
/>
{(workflowsByFolder.root || []).map((workflow) => (
<DropdownMenuItem key={workflow.id} asChild>
<Link href={`/workspace/${workspaceId}/w/${workflow.id}`}>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: workflow.color,
borderColor: `${workflow.color}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === workflowId}
isEditing={workflow.id === workflowFlyoutRename.editingId}
editValue={workflowFlyoutRename.value}
inputRef={workflowFlyoutRename.inputRef}
isRenaming={workflowFlyoutRename.isSaving}
onEditValueChange={workflowFlyoutRename.setValue}
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
onEditBlur={() => void workflowFlyoutRename.saveRename()}
onContextMenu={handleCollapsedWorkflowContextMenu}
onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
onMoreClick={handleCollapsedWorkflowMoreClick}
/>
))}
</>
)}
@@ -1417,7 +1551,7 @@ export const Sidebar = memo(function Sidebar() {
</button>
</Tooltip.Trigger>
</DropdownMenuTrigger>
{showCollapsedContent && (
{showCollapsedTooltips && (
<Tooltip.Content side='right'>
<p>Help</p>
</Tooltip.Content>
@@ -1448,7 +1582,7 @@ export const Sidebar = memo(function Sidebar() {
key={`${item.id}-${isCollapsed}`}
item={item}
active={false}
showCollapsedContent={showCollapsedContent}
showCollapsedTooltips={showCollapsedTooltips}
onContextMenu={item.href ? handleNavItemContextMenu : undefined}
/>
))}
@@ -1471,9 +1605,17 @@ export const Sidebar = memo(function Sidebar() {
menuRef={taskMenuRef}
onClose={closeTaskContextMenu}
onOpenInNewTab={handleTaskOpenInNewTab}
onMarkAsRead={handleMarkTaskAsRead}
onMarkAsUnread={handleMarkTaskAsUnread}
onRename={handleStartTaskRename}
onDelete={handleDeleteTask}
showOpenInNewTab={!isMultiTaskContextMenu}
showMarkAsRead={!isMultiTaskContextMenu && !!activeTaskContextMenuItem?.isUnread}
showMarkAsUnread={
!isMultiTaskContextMenu &&
!!activeTaskContextMenuItem &&
!activeTaskContextMenuItem.isUnread
}
showRename={!isMultiTaskContextMenu}
showDuplicate={false}
showColorChange={false}
@@ -1481,6 +1623,22 @@ export const Sidebar = memo(function Sidebar() {
disableDelete={!canEdit}
/>
<ContextMenu
isOpen={isCollapsedWorkflowContextMenuOpen}
position={collapsedWorkflowContextMenuPosition}
menuRef={collapsedWorkflowMenuRef}
onClose={closeCollapsedWorkflowContextMenu}
onOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
onRename={handleStartCollapsedWorkflowRename}
onDelete={() => {}}
showOpenInNewTab={true}
showRename={true}
showDuplicate={false}
showColorChange={false}
showDelete={false}
disableRename={!canEdit}
/>
{/* Task Delete Confirmation Modal */}
<DeleteModal
isOpen={isTaskDeleteModalOpen}

View File

@@ -396,6 +396,7 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
title: 'Properties to Return',
type: 'short-input',
placeholder: 'Comma-separated list (e.g., "email,firstname,lastname")',
mode: 'advanced',
condition: {
field: 'operation',
value: [
@@ -415,6 +416,7 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
title: 'Associations',
type: 'short-input',
placeholder: 'Comma-separated object types (e.g., "companies,deals")',
mode: 'advanced',
condition: {
field: 'operation',
value: [
@@ -437,9 +439,10 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
},
{
id: 'limit',
title: 'Limit',
title: 'Results Per Page',
type: 'short-input',
placeholder: 'Max results (list: 100, search: 200)',
mode: 'advanced',
condition: {
field: 'operation',
value: [
@@ -464,9 +467,10 @@ Return ONLY the JSON object with properties - no explanations, no markdown, no e
},
{
id: 'after',
title: 'After (Pagination)',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Pagination cursor from previous response',
placeholder: 'Cursor from previous response paging.next.after',
mode: 'advanced',
condition: {
field: 'operation',
value: [
@@ -714,6 +718,7 @@ Return ONLY the JSON array of filter groups - no explanations, no markdown, no e
type: 'long-input',
placeholder:
'JSON array of sort objects (e.g., [{"propertyName":"createdate","direction":"DESCENDING"}])',
mode: 'advanced',
condition: {
field: 'operation',
value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],
@@ -838,6 +843,7 @@ Return ONLY the JSON array of sort objects - no explanations, no markdown, no ex
title: 'Properties to Return',
type: 'long-input',
placeholder: 'JSON array of properties (e.g., ["email","firstname","lastname"])',
mode: 'advanced',
condition: {
field: 'operation',
value: ['search_contacts', 'search_companies', 'search_deals', 'search_tickets'],

View File

@@ -365,6 +365,29 @@ export function normalizeFileInput(
return files
}
/**
* Block types that are built-in to the platform (as opposed to third-party integrations).
* Used to categorize tools in the tool selection dropdown.
*/
export 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',
])
/**
* Shared wand configuration for the Response Format code subblock.
* Used by Agent and Mothership blocks.

View File

@@ -84,6 +84,7 @@ export { Upload } from './upload'
export { User } from './user'
export { UserPlus } from './user-plus'
export { Users } from './users'
export { Wordmark } from './wordmark'
export { WorkflowX } from './workflow-x'
export { Wrap } from './wrap'
export { Wrench } from './wrench'

View File

@@ -0,0 +1,58 @@
import type { SVGProps } from 'react'
import { useId } from 'react'
/**
* Sim brand wordmark — icon (green) + "Sim" text as a single SVG.
* Use when expanded; use the plain `Sim` icon when collapsed.
* @param props - SVG properties including className, style, etc.
*/
export function Wordmark(props: SVGProps<SVGSVGElement>) {
const gradientId = useId()
return (
<svg
fill='none'
height='22'
viewBox='0 0 71 22'
width='71'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<g transform='scale(.07483)'>
<path
clipRule='evenodd'
d='m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z'
fill='#33c482'
fillRule='evenodd'
/>
<path
d='m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z'
fill='#33c482'
/>
<path
d='m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z'
fill={`url(#${gradientId})`}
fillOpacity='.2'
/>
<defs>
<linearGradient
id={gradientId}
gradientUnits='userSpaceOnUse'
x1='171.406'
x2='245.831'
y1='171.18'
y2='245.428'
>
<stop offset='0' />
<stop offset='1' stopOpacity='0' />
</linearGradient>
</defs>
</g>
<g fill='currentColor'>
<path d='m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z' />
<path d='m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z' />
<path d='m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z' />
</g>
</svg>
)
}

View File

@@ -138,6 +138,28 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) {
})
}
export function useCreateCredentialDraft() {
return useMutation({
mutationFn: async (payload: {
workspaceId: string
providerId: string
displayName: string
description?: string
credentialId?: string
}) => {
const response = await fetch('/api/credentials/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to create credential draft')
}
},
})
}
export function useCreateWorkspaceCredential() {
const queryClient = useQueryClient()
@@ -165,7 +187,7 @@ export function useCreateWorkspaceCredential() {
return response.json()
},
onSuccess: () => {
onSettled: () => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.lists(),
})
@@ -198,7 +220,7 @@ export function useUpdateWorkspaceCredential() {
}
return response.json()
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.detail(variables.credentialId),
})
@@ -223,7 +245,7 @@ export function useDeleteWorkspaceCredential() {
}
return response.json()
},
onSuccess: (_data, credentialId) => {
onSettled: (_data, _error, credentialId) => {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(credentialId) })
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
@@ -269,7 +291,7 @@ export function useUpsertWorkspaceCredentialMember() {
}
return response.json()
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})
@@ -295,7 +317,7 @@ export function useRemoveWorkspaceCredentialMember() {
}
return response.json()
},
onSuccess: (_data, variables) => {
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceCredentialKeys.members(variables.credentialId),
})

View File

@@ -133,7 +133,7 @@ export async function fetchChatHistory(
chatId: string,
signal?: AbortSignal
): Promise<TaskChatHistory> {
const response = await fetch(`/api/copilot/chat?chatId=${chatId}`, { signal })
const response = await fetch(`/api/mothership/chats/${chatId}`, { signal })
if (!response.ok) {
throw new Error('Failed to load chat')
@@ -164,10 +164,8 @@ export function useChatHistory(chatId: string | undefined) {
}
async function deleteTask(chatId: string): Promise<void> {
const response = await fetch('/api/copilot/chat/delete', {
const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId }),
})
if (!response.ok) {
throw new Error('Failed to delete task')
@@ -207,10 +205,10 @@ export function useDeleteTasks(workspaceId?: string) {
}
async function renameTask({ chatId, title }: { chatId: string; title: string }): Promise<void> {
const response = await fetch('/api/copilot/chat/rename', {
const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId, title }),
body: JSON.stringify({ title }),
})
if (!response.ok) {
throw new Error('Failed to rename task')
@@ -382,16 +380,27 @@ export function useRemoveChatResource(chatId?: string) {
}
async function markTaskRead(chatId: string): Promise<void> {
const response = await fetch('/api/mothership/chats/read', {
method: 'POST',
const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatId }),
body: JSON.stringify({ isUnread: false }),
})
if (!response.ok) {
throw new Error('Failed to mark task as read')
}
}
async function markTaskUnread(chatId: string): Promise<void> {
const response = await fetch(`/api/mothership/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isUnread: true }),
})
if (!response.ok) {
throw new Error('Failed to mark task as unread')
}
}
/**
* Marks a task as read with optimistic update.
*/
@@ -420,3 +429,32 @@ export function useMarkTaskRead(workspaceId?: string) {
},
})
}
/**
* Marks a task as unread with optimistic update.
*/
export function useMarkTaskUnread(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: markTaskUnread,
onMutate: async (chatId) => {
await queryClient.cancelQueries({ queryKey: taskKeys.list(workspaceId) })
const previousTasks = queryClient.getQueryData<TaskMetadata[]>(taskKeys.list(workspaceId))
queryClient.setQueryData<TaskMetadata[]>(taskKeys.list(workspaceId), (old) =>
old?.map((task) => (task.id === chatId ? { ...task, isUnread: true } : task))
)
return { previousTasks }
},
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.list(workspaceId), context.previousTasks)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId) })
},
})
}

View File

@@ -0,0 +1,37 @@
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(.07483)">
<path
clip-rule="evenodd"
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
fill="#33c482"
fill-rule="evenodd"
/>
<path
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
fill="#33c482"
/>
<path
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
fill="url(#sim-wordmark-gradient)"
fill-opacity=".2"
/>
<defs>
<linearGradient
id="sim-wordmark-gradient"
gradientUnits="userSpaceOnUse"
x1="171.406"
x2="245.831"
y1="171.18"
y2="245.428"
>
<stop offset="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</g>
<g fill="#111827">
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,37 @@
<svg fill="none" height="22" viewBox="0 0 71 22" width="71" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(.07483)">
<path
clip-rule="evenodd"
d="m142.793 124.175c0 4.75-1.88 9.312-5.216 12.671l-.478.481c-3.334 3.369-7.863 5.252-12.58 5.252h-106.7127c-9.82776 0-17.8063 8.026-17.8063 17.924v115.407c0 9.898 7.97854 17.924 17.8063 17.924h114.5767c9.828 0 17.796-8.026 17.796-17.924v-108.052c0-4.405 1.735-8.632 4.83-11.749 3.086-3.108 7.283-4.856 11.657-4.856h108.5c9.828 0 17.796-8.024 17.796-17.923v-115.4069c0-9.89798-7.968-17.9231-17.796-17.9231h-114.578c-9.827 0-17.795 8.02512-17.795 17.9231zm34.771-99.6079h80.617c5.744 0 10.389 4.6874 10.389 10.463v81.1939c0 5.774-4.645 10.463-10.389 10.463h-80.617c-5.734 0-10.389-4.689-10.389-10.463v-81.1939c0-5.7756 4.655-10.463 10.389-10.463z"
fill="#33c482"
fill-rule="evenodd"
/>
<path
d="m275.293 171.578h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.75c0 10.402 8.373 18.834 18.7 18.834h85.187c10.328 0 18.701-8.432 18.701-18.834v-84.75c0-10.402-8.373-18.834-18.701-18.834z"
fill="#33c482"
/>
<path
d="m275.293 171.18h-85.187c-10.327 0-18.7 8.432-18.7 18.834v84.749c0 10.402 8.373 18.833 18.7 18.833h85.187c10.328 0 18.701-8.431 18.701-18.833v-84.749c0-10.402-8.373-18.834-18.701-18.834z"
fill="url(#sim-wordmark-gradient)"
fill-opacity=".2"
/>
<defs>
<linearGradient
id="sim-wordmark-gradient"
gradientUnits="userSpaceOnUse"
x1="171.406"
x2="245.831"
y1="171.18"
y2="245.428"
>
<stop offset="0" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
</defs>
</g>
<g fill="#ffffff">
<path d="m31.5718 15.845h2.5865c0 .7141.2586 1.2835.7759 1.7081.5173.4053 1.2166.608 2.0979.608.958 0 1.6956-.1834 2.2129-.5501.5173-.386.776-.8975.776-1.5344 0-.4632-.1437-.8492-.4311-1.158-.2682-.3088-.7664-.5597-1.4944-.7527l-2.4716-.579c-1.2453-.3088-2.1745-.7817-2.7876-1.4186-.594-.6369-.8909-1.4765-.8909-2.51873 0-.86852.2203-1.62124.661-2.25815.4598-.63692 1.0825-1.12908 1.868-1.47648.8047-.34741 1.7243-.52112 2.7589-.52112s1.9255.18336 2.6727.55007c.7664.3667 1.3603.87817 1.7818 1.53438.4407.65622.6706 1.43788.6898 2.345h-2.5865c-.0192-.73341-.2587-1.30278-.7185-1.70809-.4598-.4053-1.1017-.60796-1.9255-.60796-.843 0-1.4944.18336-1.9542.55006-.4599.36671-.6898.86852-.6898 1.50544 0 .94568.6898 1.59228 2.0692 1.93968l2.4716.608c1.1878.2702 2.0787.7141 2.6727 1.3317.5939.5983.8909 1.4186.8909 2.4608 0 .8878-.2395 1.6695-.7185 2.345-.479.6562-1.14 1.1677-1.983 1.5344-.8238.3474-1.8009.5211-2.9313.5211-1.6477 0-2.9601-.4053-3.9372-1.2159-.9772-.8106-1.4657-1.8915-1.4657-3.2425z" />
<path d="m44.5096 19.956v-14.15687c1.0772.39383 1.5521.39383 2.7014 0v14.15687zm1.322-15.09268c-.479 0-.9005-.1737-1.2645-.52111-.3449-.36671-.5173-.79132-.5173-1.27383 0-.50181.1724-.92642.5173-1.27383.364-.34741.7855-.52111 1.2645-.52111.4981 0 .9196.1737 1.2645.52111s.5173.77202.5173 1.27383c0 .48251-.1724.90712-.5173 1.27383-.3449.34741-.7664.52111-1.2645.52111z" />
<path d="m51.976 19.956h-2.7014v-14.15687h2.4141v2.38865c.2873-.79131.843-1.46223 1.6093-1.98334.7855-.54041 1.7339-.81062 2.8452-.81062 1.2453 0 2.2799.33776 3.1038 1.01328.8238.67551 1.3603 1.57298 1.6093 2.69241h-.4885c.1916-1.11943.7184-2.0169 1.5806-2.69241.8622-.67552 1.9255-1.01328 3.19-1.01328 1.6094 0 2.8739.47286 3.7935 1.41858.9197.94573 1.3795 2.23886 1.3795 3.8794v9.2642h-2.644v-8.5983c0-1.1195-.2874-1.97834-.8621-2.57665-.5557-.61761-1.3125-.92642-2.2704-.92642-.6706 0-1.2645.1544-1.7818.46321-.4982.28951-.8909.71412-1.1783 1.27383-.2874.55973-.4311 1.21593-.4311 1.96863v8.3957h-2.6727v-8.6273c0-1.1194-.2778-1.96864-.8334-2.54765-.5556-.59831-1.3124-.89747-2.2704-.89747-.6706 0-1.2645.1544-1.7818.46321-.4981.28951-.8909.71412-1.1783 1.27383-.2874.54038-.4311 1.18698-.4311 1.93968z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB