From 7583c8fbf4dc62a5276dd2e5c64e25ef31ffacf6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 25 Mar 2026 20:06:15 -0700 Subject: [PATCH] feat(misc): skills import, MCP modal, workmark, dispatch modals, collapsed tasks and workflows manipulation, README (#3777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: skills import, MCP modal updates, wordmark icon, tool-input improvements - Add skills import functionality (route + components + utils) - Update MCP deploy modal - Add Wordmark emcn icon + logo SVG assets - Improve tool-input component - Update README branding to new wordmark - Add ban-spam-accounts admin script * fix: resolve build error and audit findings from simplify review - Add BUILT_IN_TOOL_TYPES export to blocks/utils.ts (was removed from tool-input.tsx but never added to the new import target — caused build error "Export BUILT_IN_TOOL_TYPES doesn't exist in target module") - Export Wordmark from emcn icons barrel (index.ts) - Derive isDragging from dragCounter in skill-import.tsx instead of maintaining redundant state that could desync - Replace manual AbortController/setTimeout with AbortSignal.timeout() in skills import API route (Node 17.3+ supported, cleaner no-cleanup) - Use useId() for SVG gradient ID in wordmark.tsx to prevent duplicate ID collisions if rendered multiple times on the same page * fix(scripts): fix docs mismatch and N+1 query in ban-spam-accounts - Fix comment: default pattern is @vapu.xyz, not @sharebot.net - Replace per-user stats loop with a single aggregated JOIN query * feat: wire wordmark into sidebar, fix credential selector modal dispatch - Show Wordmark (icon + text) in the expanded sidebar instead of the bare Sim icon; collapsed state keeps the small Sim icon unchanged - Untrack scripts/ban-spam-accounts.ts (gitignored; one-off script) - Credential selector: open OAuthRequiredModal inline instead of navigating to Settings → Integrations (matches MCP/tool-input pattern) - Credential selector: update billing import from getSubscriptionAccessState to getSubscriptionStatus; drop writePendingCredentialCreateRequest and useSettingsNavigation dependencies * feat(misc): misc UX/UI improvements * more random fixes * more random fixes * fix: address PR review findings from cursor bugbot - settings-sidebar: use getSubscriptionAccessState instead of getSubscriptionStatus so billingBlocked and status validity are checked; add requiresMax gating so max-plan-only nav items (inbox) are hidden for lower-tier users - credential-selector: same getSubscriptionAccessState migration for credential sets visibility check - mothership chats PATCH: change else if to if for isUnread so both title and isUnread can be updated in a single request - skills import: check Content-Length header before reading response body to avoid loading oversized files into memory * fix(skills): add ZIP file size guard before extraction Checks file.size > 5 MB before calling extractSkillFromZip to prevent zip bombs from exhausting browser memory at the client-side upload path. * feat(settings-sidebar): show locked upsell items with plan badge Sim Mailer (requiresMax) and Email Polling (requiresTeam) now always appear in the settings sidebar when billing is enabled and the deployment is hosted. If the user lacks the required plan they see a small MAX / TEAM badge next to the label and are taken to the page which already contains the upgrade prompt. Enterprise (Access Control, SSO) and Team management stay hard-hidden for lower tiers. Admin/superuser items stay truly hidden. * fix(settings-sidebar): remove flex-1 from label span to fix text centering * feat(settings-sidebar): remove team gate from email polling, keep only mailer max gate * feat(subscription): billing details layout and Enterprise card improvements - Move Enterprise plan card into the plan grid (auto-fit columns) instead of a separate standalone section below billing details - Refactor billing details section: remove outer border/background, separate each row with top border + padding for cleaner separation - Update button variants: Add Credits → active, Invoices → active * fix(mothership): prevent lastSeenAt conflict when both title and isUnread are patched together Co-Authored-By: Claude Sonnet 4.6 * fix(sidebar): prevent double-save race in flyout inline rename on Enter+blur Co-Authored-By: Claude Sonnet 4.6 * fix(skills): normalize CRLF line endings before parsing SKILL.md frontmatter Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 63 +-- apps/sim/app/_styles/globals.css | 12 + apps/sim/app/api/copilot/chat/rename/route.ts | 65 --- .../api/mothership/chats/[chatId]/route.ts | 217 ++++++++ .../app/api/mothership/chats/read/route.ts | 43 -- apps/sim/app/api/skills/import/route.ts | 107 ++++ .../components/conversation-list-item.tsx | 16 +- .../mothership-chat/mothership-chat.tsx | 2 +- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../integrations/integrations-manager.tsx | 475 +++++++++++------- .../skills/components/skill-import.tsx | 283 +++++++++++ .../skills/components/skill-modal.tsx | 227 +++++---- .../skills/components/utils.test.ts | 191 +++++++ .../components/skills/components/utils.ts | 111 ++++ .../credit-balance/credit-balance.tsx | 2 +- .../components/subscription/subscription.tsx | 57 +-- .../[workspaceId]/settings/navigation.ts | 4 +- .../deploy-modal/components/mcp/mcp.tsx | 42 +- .../components/connect-credential-modal.tsx | 211 ++++++++ .../components/oauth-required-modal.tsx | 2 +- .../credential-selector.tsx | 72 +-- .../components/tools/credential-selector.tsx | 77 ++- .../components/tool-input/tool-input.tsx | 41 +- .../collapsed-sidebar-menu.tsx | 335 +++++++++++- .../w/components/sidebar/components/index.ts | 2 + .../settings-sidebar/settings-sidebar.tsx | 69 +-- .../components/context-menu/context-menu.tsx | 72 ++- .../w/components/sidebar/hooks/index.ts | 1 + .../sidebar/hooks/use-flyout-inline-rename.ts | 101 ++++ .../sidebar/hooks/use-hover-menu.ts | 63 ++- .../w/components/sidebar/sidebar.tsx | 410 ++++++++++----- apps/sim/blocks/blocks/hubspot.ts | 12 +- apps/sim/blocks/utils.ts | 23 + apps/sim/components/emcn/icons/index.ts | 1 + apps/sim/components/emcn/icons/wordmark.tsx | 58 +++ apps/sim/hooks/queries/credentials.ts | 32 +- apps/sim/hooks/queries/tasks.ts | 56 ++- apps/sim/public/logo/wordmark-dark.svg | 37 ++ apps/sim/public/logo/wordmark.svg | 37 ++ 39 files changed, 2824 insertions(+), 807 deletions(-) delete mode 100644 apps/sim/app/api/copilot/chat/rename/route.ts create mode 100644 apps/sim/app/api/mothership/chats/[chatId]/route.ts delete mode 100644 apps/sim/app/api/mothership/chats/read/route.ts create mode 100644 apps/sim/app/api/skills/import/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/skill-import.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/skills/components/utils.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-flyout-inline-rename.ts create mode 100644 apps/sim/components/emcn/icons/wordmark.tsx create mode 100644 apps/sim/public/logo/wordmark-dark.svg create mode 100644 apps/sim/public/logo/wordmark.svg diff --git a/README.md b/README.md index 17e2ad1ae5..6738087611 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@

- Sim Logo + + + + Sim Logo +

The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.

- Sim.ai + Sim.ai Discord Twitter - Documentation + Documentation

@@ -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) -Sim.ai +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 6512d7212f..b8bd485ee7 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 3dedbea5cb..0000000000 --- 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 0000000000..b51b24a4ac --- /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 e75ffd28d3..0000000000 --- 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 0000000000..9cbc6e3229 --- /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 9c3c10fd67..af9bc4f612 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 abc9e7272b..237d08d9ba 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 ee8eaf6126..0c6b7d6acd 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} - + {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 +
+ )} +
+
+
+ + + + + ) : ( + <> + +
+ + + Connect{' '} + {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)} + +
+
+ + {(createError || existingOAuthDisplayName) && ( +
+ {createError && ( + + {createError} + + )} + {existingOAuthDisplayName && ( + + An integration named "{existingOAuthDisplayName.displayName}" already exists. + + )} +
)} - {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 - } +
+
+
+ {selectedOAuthService && + createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })} +
+
+

+ Connect your {selectedOAuthService?.name} account +

+

+ Grant access to use {selectedOAuthService?.name} in your workflows +

+
+
+ + {createDisplayScopes.length > 0 && ( +
+
+

+ Permissions requested +

+
+
    + {createDisplayScopes.map((scope) => ( +
  • +
    + +
    + + {getScopeDescription(scope)} + +
  • + ))} +
+
+ )} + +
+ + setCreateDisplayName(event.target.value)} + placeholder='Integration name' + autoComplete='off' + data-lpignore='true' + className='mt-[6px]' + autoFocus + /> +
+
+ +