mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Merge branch 'staging' of github.com:simstudioai/sim into staging
This commit is contained in:
63
README.md
63
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
217
apps/sim/app/api/mothership/chats/[chatId]/route.ts
Normal file
217
apps/sim/app/api/mothership/chats/[chatId]/route.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
107
apps/sim/app/api/skills/import/route.ts
Normal file
107
apps/sim/app/api/skills/import/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%]',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 “{searchTerm}”
|
||||
</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}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
58
apps/sim/components/emcn/icons/wordmark.tsx
Normal file
58
apps/sim/components/emcn/icons/wordmark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
37
apps/sim/public/logo/wordmark-dark.svg
Normal file
37
apps/sim/public/logo/wordmark-dark.svg
Normal 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 |
37
apps/sim/public/logo/wordmark.svg
Normal file
37
apps/sim/public/logo/wordmark.svg
Normal 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 |
Reference in New Issue
Block a user