diff --git a/README.md b/README.md
index 17e2ad1ae50..6738087611d 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,20 @@
-
+
+
+
+
+
The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.
-
+
-
+
@@ -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)
-
+
### 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
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 6512d7212f1..b8bd485ee75 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -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;
}
diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts
deleted file mode 100644
index 3dedbea5cba..00000000000
--- a/apps/sim/app/api/copilot/chat/rename/route.ts
+++ /dev/null
@@ -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 })
- }
-}
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
new file mode 100644
index 00000000000..b51b24a4ace
--- /dev/null
+++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts
@@ -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 }>
+ 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 = {}
+
+ 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')
+ }
+}
diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts
deleted file mode 100644
index e75ffd28d36..00000000000
--- a/apps/sim/app/api/mothership/chats/read/route.ts
+++ /dev/null
@@ -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')
- }
-}
diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts
new file mode 100644
index 00000000000..9cbc6e32290
--- /dev/null
+++ b/apps/sim/app/api/skills/import/route.ts
@@ -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 })
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
index 9c3c10fd675..af9bc4f612f 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
@@ -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({
{isActive && (
-
+
)}
{!isActive && isUnread && (
-
+
)}
{title}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index abc9e7272bd..237d08d9ba5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -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%]',
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index ee8eaf6126c..0c6b7d6acd5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -299,7 +299,7 @@ export function Home({ chatId }: HomeProps = {}) {
if (!hasMessages && !chatId) {
return (
-
+
(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(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 = (
- Connect Integration
-
- {(createError || existingOAuthDisplayName) && (
-
- {createError && (
-
- {createError}
-
- )}
- {existingOAuthDisplayName && (
-
- An integration named "{existingOAuthDisplayName.displayName}" already exists.
-
- )}
-
- )}
-
-
-
-
-
-
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 (
-
- {config &&
- createElement(config.icon, {
- className: 'h-[14px] w-[14px] flex-shrink-0',
- })}
- {label}
-
- )
- })()
- : undefined
- }
+ {createStep === 1 ? (
+ <>
+ Connect Integration
+
+
+
+
+ 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
/>
+
+ {filteredServices.map((service) => {
+ const config = getServiceConfigByProviderId(service.value)
+ return (
+
+ )
+ })}
+ {filteredServices.length === 0 && (
+
+ No services found
+
+ )}
+
-
-
-
setCreateDisplayName(event.target.value)}
- placeholder='Integration name'
- autoComplete='off'
- data-lpignore='true'
- className='mt-[6px]'
- />
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ Connect{' '}
+ {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
+
-
-
-
-
-
-
-
+
+
+
+
+
+ >
+ )}
)
- const oauthRequiredModalJsx = showCreateOAuthRequiredModal && createOAuthProviderId && (
- 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() {
{createModalJsx}
- {oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
{unsavedChangesAlertJsx}
>
@@ -1124,17 +1203,12 @@ export function IntegrationsManager() {
- ) : !hasCredentials ? (
-
- Click "Connect" above to get started
-
) : (
{sortedCredentials.map((credential) => {
const serviceConfig = credential.providerId
? getServiceConfigByProviderId(credential.providerId)
: null
-
return (
@@ -1169,18 +1243,53 @@ export function IntegrationsManager() {
)
})}
+
{showNoResults && (
No integrations found matching “{searchTerm}”
)}
+
+ {filteredAvailableIntegrations.length > 0 && (
+
+
+ Available integrations
+
+ {filteredAvailableIntegrations.map((service) => {
+ const serviceConfig = getServiceConfigByProviderId(service.providerId)
+ const isConnected = connectedProviderIds.has(service.providerId)
+ return (
+
+
+ {serviceConfig && (
+
+ {createElement(serviceConfig.icon, { className: 'h-4 w-4' })}
+
+ )}
+
{service.name}
+
+
+
+ )
+ })}
+
+ )}
)}
{createModalJsx}
- {oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx
new file mode 100644
index 00000000000..4e4347b3f23
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx
@@ -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
(null)
+
+ const [dragCounter, setDragCounter] = useState(0)
+ const isDragging = dragCounter > 0
+ const [fileState, setFileState] = useState('idle')
+ const [fileError, setFileError] = useState('')
+
+ const [githubUrl, setGithubUrl] = useState('')
+ const [githubState, setGithubState] = useState('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) => {
+ 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 (
+
+ {/* File drop zone */}
+
+
+
+ {fileError &&
{fileError}
}
+
+
+
+
+ {/* GitHub URL */}
+
+
+
+ {
+ setGithubUrl(e.target.value)
+ if (githubError) setGithubError('')
+ }}
+ className='flex-1'
+ disabled={githubState === 'loading'}
+ />
+
+
+ {githubError &&
{githubError}
}
+
+
+
+
+ {/* Paste content */}
+
+
+
+
+ )
+}
+
+function Divider() {
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
index 8a0d001f412..678a80a3a13 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal.tsx
@@ -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({})
const [saving, setSaving] = useState(false)
+ const [activeTab, setActiveTab] = useState('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 = (
+
+
+
+
{
+ setName(e.target.value)
+ if (errors.name || errors.general)
+ setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
+ }}
+ />
+ {errors.name ? (
+
{errors.name}
+ ) : (
+
+ Lowercase letters, numbers, and hyphens (e.g. my-skill)
+
+ )}
+
+
+
+
+
{
+ setDescription(e.target.value)
+ if (errors.description || errors.general)
+ setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
+ }}
+ maxLength={1024}
+ />
+ {errors.description && (
+
{errors.description}
+ )}
+
+
+
+
+
+
+ {errors.general &&
{errors.general}
}
+
+ )
+
+ const footer = (
+
+ {isEditing && onDelete ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+
return (
- {initialValues ? 'Edit Skill' : 'Create Skill'}
-
-
-
-
-
{
- setName(e.target.value)
- if (errors.name || errors.general)
- setErrors((prev) => ({ ...prev, name: undefined, general: undefined }))
- }}
- />
- {errors.name ? (
-
{errors.name}
- ) : (
-
- Lowercase letters, numbers, and hyphens (e.g. my-skill)
-
- )}
-
-
-
-
-
{
- setDescription(e.target.value)
- if (errors.description || errors.general)
- setErrors((prev) => ({ ...prev, description: undefined, general: undefined }))
- }}
- maxLength={1024}
- />
- {errors.description && (
-
{errors.description}
- )}
-
-
-
-
-
-
- {errors.general && (
-
{errors.general}
- )}
-
-
-
- {initialValues && onDelete ? (
-
- ) : (
-
- )}
-
-
-
-
-
+ {isEditing ? (
+ <>
+ Edit Skill
+ {createForm}
+ {footer}
+ >
+ ) : (
+ <>
+ Add Skill
+ setActiveTab(v as TabValue)}
+ className='flex min-h-0 flex-1 flex-col'
+ >
+
+ Create
+ Import
+
+
+ {createForm}
+
+
+
+
+
+ {activeTab === 'create' && footer}
+ >
+ )}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts
new file mode 100644
index 00000000000..e6cc61a40d3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts
@@ -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: \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): Promise {
+ 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')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts
new file mode 100644
index 00000000000..b8a7236924a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts
@@ -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 {
+ 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
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
index 22106be41cd..aeb05dd4c20 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/components/credit-balance/credit-balance.tsx
@@ -103,7 +103,7 @@ export function CreditBalance({
{canPurchase && (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
index 75f75889bd3..ac36b354880 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/subscription/subscription.tsx
@@ -711,7 +711,7 @@ export function Subscription() {
const showProCard = !isOnMaxTier
return (
-
+
{showProCard && (
+ {hasEnterprise && (
+
window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
+ />
+ )}
)
})()}
@@ -924,24 +933,26 @@ export function Subscription() {
{/* Billing details section */}
{(subscription.isPaid || (!isLoading && isTeamAdmin)) && (
-
+
{subscription.isPaid && permissions.canViewUsageInfo && (
-
refetchSubscription()}
- />
+
+ refetchSubscription()}
+ />
+
)}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
-
+
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
@@ -950,16 +961,18 @@ export function Subscription() {
)}
{subscription.isPaid && permissions.canViewUsageInfo && (
-
+
+
+
)}
{subscription.isPaid &&
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
-
+
{
@@ -995,7 +1008,7 @@ export function Subscription() {
)}
{!isLoading && isTeamAdmin && (
-
+
@@ -1040,18 +1053,6 @@ export function Subscription() {
)}
)}
-
- {/* Enterprise */}
- {hasEnterprise && (
-
window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')}
- inlineButton
- />
- )}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index 0eabb85e3aa..1e564ed0598 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -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,
},
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
index 0a95d9fd6be..1fe195150e8 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx
@@ -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 (
-
-
- Create an MCP Server in Settings → MCP Servers first.
-
-
navigateToSettings({ section: 'workflow-mcp-servers' })}
- >
- Create MCP Server
-
-
+ <>
+
+
+ Create an MCP Server in Settings → MCP Servers first.
+
+
setShowMcpModal(true)}>
+ Create MCP Server
+
+
+
{
+ await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
+ >
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx
new file mode 100644
index 00000000000..91eeb4d325c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx
@@ -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(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 (
+ !open && handleClose()}>
+
+ Connect {providerName}
+
+
+
+
+
+
+ Connect your {providerName} account
+
+
+ Grant access to use {providerName} in your workflow
+
+
+
+
+ {displayScopes.length > 0 && (
+
+
+
+ Permissions requested
+
+
+
+
+ )}
+
+
+
+ {
+ 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]'
+ />
+
+
+ {error &&
{error}
}
+
+
+
+
+ Cancel
+
+
+ {isPending ? 'Connecting...' : 'Connect'}
+
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
index acb70f0d49d..4ba510e5b92 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx
@@ -153,7 +153,7 @@ export function OAuthRequiredModal({
Permissions requested
-
+
{displayScopes.map((scope) => (
-
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
index 6db7465bc9e..733c9f1ad3b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx
@@ -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(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({
)}
+ {showConnectModal && (
+ setShowConnectModal(false)}
+ provider={provider}
+ serviceId={serviceId}
+ workspaceId={workspaceId}
+ workflowId={activeWorkflowId || ''}
+ credentialCount={credentials.length}
+ />
+ )}
+
{showOAuthModal && (
{
@@ -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(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({
)}
+ {showConnectModal && (
+
setShowConnectModal(false)}
+ provider={provider}
+ serviceId={serviceId}
+ workspaceId={workspaceId}
+ workflowId={effectiveWorkflowId || ''}
+ credentialCount={credentials.length}
+ />
+ )}
+
{showOAuthModal && (
(null)
const [draggedIndex, setDraggedIndex] = useState(null)
const [dragOverIndex, setDragOverIndex] = useState(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
}
/>
+
+ {
+ await createMcpServer.mutateAsync({ workspaceId, config: { ...config, enabled: true } })
+ }}
+ workspaceId={workspaceId}
+ availableEnvVars={availableEnvVars}
+ allowedMcpDomains={allowedMcpDomains}
+ />
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
index ff61e2ed52b..b9014e3b363 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx
@@ -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
- 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
+ isRenaming?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onContextMenu?: (e: ReactMouseEvent, taskId: string) => void
+ onMorePointerDown?: () => void
+ onMoreClick?: (e: ReactMouseEvent, taskId: string) => void
+}
+
+interface CollapsedWorkflowFlyoutItemProps {
+ workflow: WorkflowMetadata
+ href: string
+ isCurrentRoute?: boolean
+ isEditing?: boolean
+ editValue?: string
+ inputRef?: React.RefObject
+ isRenaming?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
+ onMorePointerDown?: () => void
+ onMoreClick?: (e: ReactMouseEvent, 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) => void
+}) {
+ return (
+
+
+
+ )
+}
+
+function TaskStatusIcon({
+ isActive,
+ isUnread,
+ hideStatusOnHover = false,
+}: {
+ isActive?: boolean
+ isUnread?: boolean
+ hideStatusOnHover?: boolean
+}) {
+ return (
+
+
+ {isActive && (
+
+ )}
+ {!isActive && isUnread && (
+
+ )}
+
+ )
+}
+
+function WorkflowColorSwatch({ color }: { color: string }) {
+ return (
+
+ )
}
export function CollapsedSidebarMenu({
icon,
hover,
- onClick,
ariaLabel,
children,
className,
+ primaryAction,
}: CollapsedSidebarMenuProps) {
return (
@@ -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}
+ {primaryAction && (
+ <>
+
+
+ {primaryAction.label}
+
+
+ >
+ )}
{children}
@@ -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 (
+
+
+ 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'
+ />
+
+ )
+ }
+
+ return (
+
+ onContextMenu(e, task.id) : undefined
+ }
+ >
+
+
+ {showActions && (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ onMoreClick?.(e, task.id)
+ }}
+ />
+ )}
+
+ )
+}
+
+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 (
+
+
+ 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'
+ />
+
+ )
+ }
+
+ return (
+
+ onContextMenu(e, workflow) : undefined}
+ >
+
+ {workflow.name}
+
+ {showActions && (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ onMoreClick?.(e, workflow)
+ }}
+ />
+ )}
+
+ )
+}
+
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
+ currentWorkflowId,
+ editingWorkflowId,
+ editingValue,
+ editInputRef,
+ isRenamingWorkflow,
+ onEditValueChange,
+ onEditKeyDown,
+ onEditBlur,
+ onWorkflowContextMenu,
+ onWorkflowMorePointerDown,
+ onWorkflowMoreClick,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record
workspaceId: string
+ currentWorkflowId?: string
+ editingWorkflowId?: string | null
+ editingValue?: string
+ editInputRef?: React.RefObject
+ isRenamingWorkflow?: boolean
+ onEditValueChange?: (value: string) => void
+ onEditKeyDown?: (e: React.KeyboardEvent) => void
+ onEditBlur?: () => void
+ onWorkflowContextMenu?: (e: ReactMouseEvent, workflow: WorkflowMetadata) => void
+ onWorkflowMorePointerDown?: () => void
+ onWorkflowMoreClick?: (e: ReactMouseEvent, 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) => (
-
-
-
- {workflow.name}
-
-
+
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
index f122ee5de67..eb7970f3f3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
index db86ce8e9c2..f18c961aa34 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx
@@ -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({
Back
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
Back
@@ -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 = (
<>
-
+
{item.label}
+ {isLocked && (
+
+ Max
+
+ )}
>
)
@@ -280,11 +289,9 @@ export function SettingsSidebar({
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 (
{element}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{item.label}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
index ae179d5d79f..49e0caf5339 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx
@@ -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
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
)}
- {hasNavigationSection && (hasEditSection || hasCopySection) && }
+ {hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
+
+ )}
+
+ {showMarkAsRead && onMarkAsRead && (
+ {
+ onMarkAsRead()
+ onClose()
+ }}
+ >
+
+ Mark as read
+
+ )}
+ {showMarkAsUnread && onMarkAsUnread && (
+ {
+ onMarkAsUnread()
+ onClose()
+ }}
+ >
+
+ Mark as unread
+
+ )}
+ {hasStatusSection && (hasEditSection || hasCopySection) && }
{showRename && onRename && (
)}
- {(hasNavigationSection || hasEditSection || hasCopySection) && }
+ {(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
+ (showLeave || showDelete) && }
{showLeave && onLeave && (
)}
- {
- onDelete()
- onClose()
- }}
- >
-
- Delete
-
+ {showDelete && (
+ {
+ onDelete()
+ onClose()
+ }}
+ >
+
+ Delete
+
+ )}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
index bdffa08fdf4..39646bc1295 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts
@@ -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'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts
new file mode 100644
index 00000000000..a492918d789
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts
@@ -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
+}
+
+export function useFlyoutInlineRename({ itemType, onSave }: UseFlyoutInlineRenameProps) {
+ const [editingTarget, setEditingTarget] = useState(null)
+ const [value, setValue] = useState('')
+ const [isSaving, setIsSaving] = useState(false)
+ const inputRef = useRef(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) => {
+ 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,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
index e48fc4c25da..9b494b80481 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu.ts
@@ -12,6 +12,8 @@ const preventAutoFocus = (e: Event) => e.preventDefault()
export function useHoverMenu() {
const [isOpen, setIsOpen] = useState(false)
const closeTimerRef = useRef | 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 }
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index d6726e23da4..8bdcf041264 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -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({
)}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{task.name}
@@ -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 (
{element}
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
{item.label}
@@ -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(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, 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(null)
- const [renameValue, setRenameValue] = useState('')
- const tasksHover = useHoverMenu()
- const workflowsHover = useHoverMenu()
- const renameInputRef = useRef(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])
-
- 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])
+ tasksHover.setLocked(true)
+ taskFlyoutRename.startRename({ id: taskId, name: task.name })
+ }, [taskFlyoutRename, tasks, tasksHover])
+
+ 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() {
{/* Top bar: Logo + Collapse toggle */}
-
-
- {showCollapsedContent ? (
+
+
+ {brand.logoUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
{brand.logoUrl ? (
- ) : (
-
- {brand.logoUrl ? (
-
- ) : (
-
- )}
-
+
+ {isCollapsed && (
+
+ Expand sidebar
+
)}
-
- {showCollapsedContent && (
-
- Expand sidebar
-
- )}
-
+
+
) : (
<>
@@ -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() {
}
hover={tasksHover}
- onClick={() => navigateToPage(`/workspace/${workspaceId}/home`)}
ariaLabel='Tasks'
className='mt-[6px]'
+ primaryAction={{
+ label: 'New task',
+ onSelect: () => navigateToPage(`/workspace/${workspaceId}/home`),
+ }}
>
{tasksLoading ? (
@@ -1180,15 +1291,21 @@ export const Sidebar = memo(function Sidebar() {
) : (
tasks.map((task) => (
-
-
-
-
-
+ void taskFlyoutRename.saveRename()}
+ onContextMenu={handleTaskContextMenu}
+ onMorePointerDown={handleTaskMorePointerDown}
+ onMoreClick={handleTaskMoreClick}
+ />
))
)}
@@ -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() {
>
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'
/>
@@ -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 ? (
@@ -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) => (
-
-
-
- {workflow.name}
-
-
+ void workflowFlyoutRename.saveRename()}
+ onContextMenu={handleCollapsedWorkflowContextMenu}
+ onMorePointerDown={handleCollapsedWorkflowMorePointerDown}
+ onMoreClick={handleCollapsedWorkflowMoreClick}
+ />
))}
>
)}
@@ -1417,7 +1551,7 @@ export const Sidebar = memo(function Sidebar() {
- {showCollapsedContent && (
+ {showCollapsedTooltips && (
Help
@@ -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}
/>
+ {}}
+ showOpenInNewTab={true}
+ showRename={true}
+ showDuplicate={false}
+ showColorChange={false}
+ showDelete={false}
+ disableRename={!canEdit}
+ />
+
{/* Task Delete Confirmation Modal */}
) {
+ const gradientId = useId()
+
+ return (
+
+ )
+}
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index c668309abf0..44c0a6e68ed 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -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),
})
diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts
index 6e071b60588..e667393f588 100644
--- a/apps/sim/hooks/queries/tasks.ts
+++ b/apps/sim/hooks/queries/tasks.ts
@@ -133,7 +133,7 @@ export async function fetchChatHistory(
chatId: string,
signal?: AbortSignal
): Promise {
- 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 {
- 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 {
- 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 {
- 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 {
+ 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(taskKeys.list(workspaceId))
+
+ queryClient.setQueryData(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) })
+ },
+ })
+}
diff --git a/apps/sim/public/logo/wordmark-dark.svg b/apps/sim/public/logo/wordmark-dark.svg
new file mode 100644
index 00000000000..8d35b8159b6
--- /dev/null
+++ b/apps/sim/public/logo/wordmark-dark.svg
@@ -0,0 +1,37 @@
+
diff --git a/apps/sim/public/logo/wordmark.svg b/apps/sim/public/logo/wordmark.svg
new file mode 100644
index 00000000000..85b9625cbaf
--- /dev/null
+++ b/apps/sim/public/logo/wordmark.svg
@@ -0,0 +1,37 @@
+