From 0c6e4308b786a4655d7f12e8a8029add5fbace6c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 18:36:44 -0700 Subject: [PATCH 1/9] feat(chat): hide the Chat module when CHAT_ENABLED is unset A self-hosted deployment that skipped the chat key still rendered the full mothership Chat UI, landing on the composer and 401ing on every message. Gate it behind a CHAT_ENABLED / NEXT_PUBLIC_CHAT_ENABLED twin, written by the setup wizard alongside COPILOT_API_KEY and validated by the existing FLAG_TWINS doctor check. The flag resolves at module scope on both render passes, so no chat surface renders then disappears. With Chat off the workspace lands on its first workflow (resolved server-side, behind the cached host-context check so no workflow id leaks to non-members), and the chats list, scheduled tasks, editor Chat panel, and chat CTAs are absent. Routes are gated rather than deleted: /home redirects because it is baked into delivered invitation emails and the accept contract. Also fixes two bugs the gate exposed: a persisted activeTab of 'copilot' left the workflow panel blank from first paint, and the panel's handoff listener claimed MOTHERSHIP_SEND_MESSAGE events outside its own gate, silently swallowing "Fix in Chat" messages. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha --- .../self-hosting/environment-variables.mdx | 4 +- apps/sim/.env.example | 9 + apps/sim/app/api/mothership/events/route.ts | 5 + apps/sim/app/api/schedules/execute/route.ts | 14 +- apps/sim/app/layout.tsx | 13 +- .../[workspaceId]/chat/[chatId]/layout.tsx | 5 + .../workspace/[workspaceId]/home/layout.tsx | 21 +- .../app/workspace/[workspaceId]/home/page.tsx | 8 + .../[block]/integration-block-detail.tsx | 9 +- .../showcase-with-explore.tsx | 25 +- .../components/log-details/log-details.tsx | 3 +- .../app/workspace/[workspaceId]/not-found.tsx | 2 +- apps/sim/app/workspace/[workspaceId]/page.tsx | 35 ++- .../app/workspace/[workspaceId]/prefetch.ts | 21 +- .../[workspaceId]/scheduled-tasks/page.tsx | 6 + .../[workspaceId]/upgrade/upgrade.tsx | 6 +- .../w/[workflowId]/components/panel/panel.tsx | 34 ++- .../log-row-context-menu.tsx | 3 +- .../workflow-block/workflow-block.tsx | 3 +- .../components/search-modal/search-modal.tsx | 19 +- .../settings-sidebar/settings-sidebar.tsx | 2 +- .../workspace-header/workspace-header.tsx | 4 +- .../sidebar/hooks/use-workspace-management.ts | 2 +- .../w/components/sidebar/sidebar.tsx | 243 +++++++++--------- .../w/hooks/use-delete-selection.ts | 2 +- .../w/hooks/use-delete-workflow.ts | 2 +- .../app/workspace/[workspaceId]/w/page.tsx | 65 +++-- apps/sim/app/workspace/page.tsx | 18 +- apps/sim/bootstrap.ts | 14 + .../mothership/mothership-handler.test.ts | 13 + .../handlers/mothership/mothership-handler.ts | 7 + apps/sim/hooks/use-mothership-chat-events.ts | 7 +- apps/sim/lib/billing/core/subscription.ts | 9 +- apps/sim/lib/core/config/env-flags.ts | 23 +- apps/sim/lib/core/config/env.ts | 3 + apps/sim/lib/invitations/core.ts | 2 +- apps/sim/lib/invitations/send.ts | 2 +- apps/sim/lib/workflows/queries.ts | 17 ++ apps/sim/stores/panel/store.ts | 6 +- apps/sim/stores/terminal/console/store.ts | 11 +- packages/testing/src/mocks/env-flags.mock.ts | 2 + scripts/setup/modes/compose.ts | 2 + scripts/setup/modes/dev.ts | 2 + scripts/setup/steps.ts | 13 +- scripts/setup/twins.ts | 1 + 45 files changed, 517 insertions(+), 200 deletions(-) diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 0e084dfc858..41540ac50e2 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -64,7 +64,9 @@ import { Callout } from 'fumadocs-ui/components/callout' | Variable | Description | |----------|-------------| | `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` | -| `COPILOT_API_KEY` | API key for copilot features | +| `COPILOT_API_KEY` | API key for Chat. Required whenever `CHAT_ENABLED` is set — the app refuses to start without it | +| `CHAT_ENABLED` | Shows the Chat module. Leave unset and the workspace lands on your first workflow, with no chats list, scheduled tasks, editor Chat panel, or Sim Chat block | +| `NEXT_PUBLIC_CHAT_ENABLED` | Browser twin of `CHAT_ENABLED`. Set both together (`bun run setup` does) | | `ADMIN_API_KEY` | Admin API key for GitOps operations | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 795a002a6ad..89e33ceb2b4 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -21,6 +21,15 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. +# Chat (Optional) +# Leave these unset and the Chat module is hidden entirely: the workspace lands +# on your first workflow, and the chats list, scheduled tasks, workflow-editor +# Chat panel, and Sim Chat block are all absent. `bun run setup` writes all three +# together; set them by hand only if you skip the wizard. +# COPILOT_API_KEY= # Mint one at https://sim.ai — required whenever CHAT_ENABLED is true (the app refuses to boot otherwise) +# CHAT_ENABLED=true # Server-side gate +# NEXT_PUBLIC_CHAT_ENABLED=true # Browser twin — must match CHAT_ENABLED + # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 5509358254d..c942c825665 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server' import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { chatPubSub } from '@/lib/copilot/chat-status' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' @@ -37,6 +38,10 @@ const mothershipEventsHandler = createWorkspaceSSE({ }) export const GET = withRouteHandler((request: NextRequest) => { + // Closes streams held by tabs that were open when Chat was turned off; the + // client hook already declines to open new ones. + if (!isChatEnabled) return new Response(null, { status: 404 }) + const validation = mothershipEventsQuerySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams.entries()) ) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 13f87274810..faf261ed6bd 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -17,6 +17,7 @@ import { } from '@/lib/billing/core/billing-attribution' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { JOB_STATUS, type Job } from '@/lib/core/async-jobs/types' +import { env } from '@/lib/core/config/env' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { runDetached } from '@/lib/core/utils/background' @@ -1212,7 +1213,18 @@ export async function runScheduleTick(requestId: string): Promise{children} } diff --git a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx index b8bae2ff0af..3f60d94d8d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/layout.tsx @@ -1,6 +1,25 @@ +import { redirect } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { inter } from '@/app/_styles/fonts/inter/inter' -export default function HomeLayout({ children }: { children: React.ReactNode }) { +/** + * Redirects rather than 404s when Chat is disabled: this path is baked into + * already-delivered invitation emails and into the invitation-accept API + * contract, so it has to keep resolving. `/workspace/{id}` re-resolves the + * landing route server-side, so the visitor lands on a workflow instead. + */ +export default async function HomeLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ workspaceId: string }> +}) { + if (!isChatEnabled) { + const { workspaceId } = await params + redirect(`/workspace/${workspaceId}`) + } + return (
{children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index 13595d65398..45987cb73f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,7 +1,9 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { Home } from './home' @@ -14,6 +16,12 @@ export const metadata: Metadata = { export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) { const { workspaceId } = await params + // The layout redirects too, but pages and layouts resolve concurrently — without + // this the prefetch below still fires on its way out. + if (!isChatEnabled) { + redirect(`/workspace/${workspaceId}`) + } + const queryClient = getQueryClient() const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index b11854646f4..0582fbe0b4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { blockTypeToIconMap, type Integration, @@ -150,11 +151,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Add to Sim ) - ) : ( + ) : isChatEnabled ? ( Add to Sim - )} + ) : null}
{oauthService && ( @@ -236,7 +237,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration /> )} - {matchingTemplates.length > 0 && ( + {/* Every template hands its prompt to Chat, so the section has no + destination without it. */} + {isChatEnabled && matchingTemplates.length > 0 && ( - { - storeCuratedPrompt(prompt) - router.push(`/workspace/${workspaceId}/home`) - }} - className='absolute right-0 bottom-0 mx-0' - > - Explore in chat - + {isChatEnabled && ( + { + storeCuratedPrompt(prompt) + router.push(`/workspace/${workspaceId}/home`) + }} + className='absolute right-0 bottom-0 mx-0' + > + Explore in chat + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 3dd9a4a9d87..5a353e7c0f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -31,6 +31,7 @@ import { createPortal } from 'react-dom' import type { WorkflowLogRow } from '@/lib/api/contracts/logs' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -397,7 +398,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP * mothership-triggered logs are excluded — `isLikelyExecution` already encodes * "has an executionId and isn't a mothership run". */ - const canTroubleshoot = log.status === 'failed' && isLikelyExecution + const canTroubleshoot = isChatEnabled && log.status === 'failed' && isLikelyExecution /** * Hands the failed run to Chat. When a chat is already mounted (e.g. the run diff --git a/apps/sim/app/workspace/[workspaceId]/not-found.tsx b/apps/sim/app/workspace/[workspaceId]/not-found.tsx index 43dbfbaea36..db69e38864d 100644 --- a/apps/sim/app/workspace/[workspaceId]/not-found.tsx +++ b/apps/sim/app/workspace/[workspaceId]/not-found.tsx @@ -10,7 +10,7 @@ import { ErrorShell } from '@/app/workspace/[workspaceId]/components' export default function WorkspaceNotFound() { const router = useRouter() const { workspaceId } = useParams<{ workspaceId?: string }>() - const homeHref = workspaceId ? `/workspace/${workspaceId}/home` : '/' + const homeHref = workspaceId ? `/workspace/${workspaceId}` : '/' return ( }) { const { workspaceId } = await params - redirect(`/workspace/${workspaceId}/home`) + + if (isChatEnabled) { + redirect(`/workspace/${workspaceId}/home`) + } + + const session = await getSession() + if (!session?.user) { + redirect('/login') + } + + const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) + if (!hostContext) { + // The layout renders WorkspaceAccessDenied for this case. + return null + } + + const workflowId = await getFirstWorkflowIdForWorkspace(workspaceId) + redirect(workflowId ? `/workspace/${workspaceId}/w/${workflowId}` : `/workspace/${workspaceId}/w`) } diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index f8e2d3c3afe..7aefa827677 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { listFoldersForWorkspace } from '@/lib/folders/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' @@ -74,14 +75,18 @@ export async function prefetchWorkspaceSidebar( }, staleTime: WORKFLOW_LIST_STALE_TIME, }), - queryClient.prefetchQuery({ - queryKey: mothershipChatKeys.list(workspaceId, 'active'), - queryFn: async () => { - const data = await listMothershipChats(userId, workspaceId) - return data.map(mapChat) - }, - staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, - }), + ...(isChatEnabled + ? [ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await listMothershipChats(userId, workspaceId) + return data.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + ] + : []), queryClient.prefetchQuery({ queryKey: folderKeys.list(workspaceId, 'active'), queryFn: async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx index 38da2e2294d..b078ff176db 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { isChatEnabled } from '@/lib/core/config/env-flags' import ScheduledTasksLoading from '@/app/workspace/[workspaceId]/scheduled-tasks/loading' import { ScheduledTasks } from './scheduled-tasks' @@ -14,6 +16,10 @@ export const metadata: Metadata = { * so a suspend never shows a blank frame. */ export default function ScheduledTasksPage() { + // The calendar only surfaces mothership prompt jobs, so with Chat off there is + // nothing this page could ever show. + if (!isChatEnabled) notFound() + return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index ce9e0d95ce7..872231a1ebb 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -69,18 +69,18 @@ export function Upgrade({ workspaceId }: UpgradeProps) { const canManageBilling = canManageWorkspaceBilling(hostContext, session?.user?.id) const handleBack = useCallback(() => { - router.replace(origin ?? `/workspace/${workspaceId}/home`) + router.replace(origin ?? `/workspace/${workspaceId}`) }, [origin, router, workspaceId]) // Enterprise manages billing out-of-band, and self-hosted deployments with // billing disabled have no plans to surface — redirect to home in both cases. useEffect(() => { if (!isBillingEnabled) { - router.replace(`/workspace/${workspaceId}/home`) + router.replace(`/workspace/${workspaceId}`) return } if (canManageBilling && !state.isLoading && state.subscription.isEnterprise) { - router.replace(`/workspace/${workspaceId}/home`) + router.replace(`/workspace/${workspaceId}`) } }, [canManageBilling, state.isLoading, state.subscription.isEnterprise, router, workspaceId]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 1a997731fd8..4f6c122a054 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -40,6 +40,7 @@ import { import { getWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -122,7 +123,12 @@ export const Panel = memo(function Panel() { const panelRef = useRef(null) const fileInputRef = useRef(null) - const { activeTab, setActiveTab, _hasHydrated, setHasHydrated } = usePanelStore( + const { + activeTab: storedActiveTab, + setActiveTab, + _hasHydrated, + setHasHydrated, + } = usePanelStore( useShallow((state) => ({ activeTab: state.activeTab, setActiveTab: state.setActiveTab, @@ -146,6 +152,16 @@ export const Panel = memo(function Panel() { // Hooks const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() + + /** + * The Chat tab is hidden when the deployment has Chat off, or when the user's + * permission group hides it. Tab bodies stay mounted and are toggled with + * `hidden`, so a persisted `activeTab: 'copilot'` would hide all three and + * paint an empty panel — resolve it to the toolbar instead. + */ + const isCopilotTabAvailable = isChatEnabled && !permissionConfig.hideCopilot + const activeTab: PanelTab = + storedActiveTab === 'copilot' && !isCopilotTabAvailable ? 'toolbar' : storedActiveTab const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) const duplicateWorkflowMutation = useDuplicateWorkflowMutation() const { data: workflows = {} } = useWorkflowMap(workspaceId) @@ -257,7 +273,7 @@ export const Panel = memo(function Panel() { ) const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats( - activeWorkflowId ?? undefined + isCopilotTabAvailable ? (activeWorkflowId ?? undefined) : undefined ) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) @@ -456,7 +472,15 @@ export const Panel = memo(function Panel() { setHasHydrated(true) }, [setHasHydrated]) + /** + * Only claims handoffs while the Chat tab can actually receive them. The + * handler's `preventDefault()` is what tells `sendMothershipMessage` a host + * consumed the message, so listening with the tab hidden would swallow it and + * skip the caller's own fallback. + */ useEffect(() => { + if (!isCopilotTabAvailable) return + const handler = (e: Event) => { const detail = (e as CustomEvent).detail if (!detail?.message) return @@ -466,7 +490,7 @@ export const Panel = memo(function Panel() { } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) - }, [setActiveTab, copilotSendMessage]) + }, [isCopilotTabAvailable, setActiveTab, copilotSendMessage]) useEffect(() => { if (activeTab !== 'copilot') return @@ -759,7 +783,7 @@ export const Panel = memo(function Panel() { {/* Tabs */}
- {!permissionConfig.hideCopilot && ( + {isCopilotTabAvailable && ( - )} - - )} -
- )} -
+ })} + {chats.length > 5 && ( + + )} + + )} + + )} + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index bb22fb2fa01..2a126ea4df5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -119,7 +119,7 @@ export function useDeleteSelection({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 0e9c5d82cc5..9db4fa5a163 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -98,7 +98,7 @@ export function useDeleteWorkflow({ if (nextWorkflowId) { router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) } else { - router.push(`/workspace/${workspaceId}/home`) + router.push(`/workspace/${workspaceId}`) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index de85beccd5c..5dad9752bdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -1,52 +1,83 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' +import { Chip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { ReactFlowProvider } from 'reactflow' import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkflows } from '@/hooks/queries/workflows' const logger = createLogger('WorkflowsPage') +function Spinner() { + return ( +
+ ) +} + export default function WorkflowsPage() { const router = useRouter() const params = useParams() const workspaceId = params.workspaceId as string const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) + const { handleCreateWorkflow, isCreatingWorkflow } = useWorkflowOperations({ workspaceId }) + + const workspaceWorkflows = useMemo( + () => workflows.filter((w) => w.workspaceId === workspaceId), + [workflows, workspaceId] + ) + const isResolving = isLoading || isPlaceholderData useEffect(() => { - if (isLoading || isPlaceholderData) return + if (isResolving) return if (isError) { logger.error('Failed to load workflows for workspace') return } - const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - if (workspaceWorkflows.length > 0) { router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) } - }, [isLoading, isPlaceholderData, workflows, workspaceId, router, isError]) + }, [isResolving, isError, workspaceWorkflows, workspaceId, router]) + + /** + * A workspace can legitimately reach zero workflows — deleting the last one, + * archiving them all, or creating a workspace with `skipDefaultWorkflow`. This + * is the terminal state for those paths now that the chat composer is no + * longer a landing option, so it has to offer a way out rather than spin. + */ + const isEmpty = !isResolving && !isError && workspaceWorkflows.length === 0 - // Always show loading state until redirect happens - // There should always be a default workflow, so we never show "no workflows found" return (
-
+ {isEmpty ? ( +
+
+

No workflows yet

+

Create one to start building.

+
+ + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + +
+ ) : ( + + )}
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 6137b205d19..934a1d7268e 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -100,7 +100,7 @@ export default function WorkspacePage() { } logger.info(`Redirecting to workspace: ${targetWorkspace.id}`) - router.replace(`/workspace/${targetWorkspace.id}/home`) + router.replace(workspaceLandingPath(targetWorkspace.id)) }, [session, isSessionPending, sessionError, isWorkspacesLoading, workspacesError, data, router]) const failedToLoad = @@ -151,6 +151,18 @@ export default function WorkspacePage() { ) } +/** + * The workspace landing path. `/workspace/{id}` resolves server-side to either + * the chat composer or the workspace's first workflow, so callers never hardcode + * a module route. The query string is carried across because OAuth and billing + * flows return to `/workspace?...` and their signals (`billing=updated`, + * `trello_connected`, `error=...`) are consumed after this hop. + */ +function workspaceLandingPath(workspaceId: string): string { + const search = typeof window === 'undefined' ? '' : window.location.search + return `/workspace/${workspaceId}${search}` +} + async function handleWorkflowRedirect( workflowId: string, fallbackWorkspaceId: string, @@ -169,7 +181,7 @@ async function handleWorkflowRedirect( } catch (error) { logger.error('Error fetching workflow for redirect:', error) } - router.replace(`/workspace/${fallbackWorkspaceId}/home`) + router.replace(workspaceLandingPath(fallbackWorkspaceId)) } async function handleNoWorkspaces( @@ -193,7 +205,7 @@ async function handleNoWorkspaces( }) if (data.workspace?.id) { logger.info(`Created default workspace: ${data.workspace.id}`) - router.replace(`/workspace/${data.workspace.id}/home`) + router.replace(workspaceLandingPath(data.workspace.id)) return } logger.error('Failed to create default workspace') diff --git a/apps/sim/bootstrap.ts b/apps/sim/bootstrap.ts index bc2e92b882c..fcc41261cee 100644 --- a/apps/sim/bootstrap.ts +++ b/apps/sim/bootstrap.ts @@ -6,6 +6,20 @@ import { loadRuntimeSecrets } from '@sim/runtime-secrets' await loadRuntimeSecrets() + +/** + * Chat cannot reach the mothership without `COPILOT_API_KEY`, so serving the + * module with the key missing yields a UI where every message 401s. Fail the + * deploy instead. Mirrors `isTruthy` from `lib/core/config/env.ts`, inlined + * because this file is bundled separately for the container entrypoint and must + * not pull the Next-only env module into its graph. + */ +const chatEnabled = process.env.CHAT_ENABLED?.toLowerCase() +if ((chatEnabled === 'true' || chatEnabled === '1') && !process.env.COPILOT_API_KEY) { + throw new Error( + 'CHAT_ENABLED is set without COPILOT_API_KEY — Chat would render against a backend that rejects every request. Set COPILOT_API_KEY, or unset CHAT_ENABLED and NEXT_PUBLIC_CHAT_ENABLED.' + ) +} // `server.js` is the Next standalone build artifact, a sibling of this file in // the image; it does not exist at type-check time, so the specifier is held in a // variable to keep it out of static module resolution. diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 364491017b3..1c4083ba828 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -1,5 +1,6 @@ import '@sim/testing/mocks/executor' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BlockType } from '@/executor/constants' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' @@ -114,6 +115,8 @@ describe('MothershipBlockHandler', () => { mockIsRedisCancellationEnabled.mockReset() mockIsRedisCancellationEnabled.mockReturnValue(false) mockReadUserFileContent.mockReset() + // The handler refuses to run without Chat; the shared mock defaults it off. + setEnvFlags({ isChatEnabled: true }) block = { id: 'mothership-block-1', @@ -146,6 +149,7 @@ describe('MothershipBlockHandler', () => { vi.useRealTimers() vi.clearAllMocks() vi.unstubAllGlobals() + resetEnvFlagsMock() }) function createNdjsonResponse(events: unknown[]): Response { @@ -222,6 +226,15 @@ describe('MothershipBlockHandler', () => { }) }) + it('rejects execution before the internal request when Chat is disabled', async () => { + setEnvFlags({ isChatEnabled: false }) + + await expect( + handler.execute(context, block, { prompt: 'Hello from workflow' }) + ).rejects.toThrow('Chat is disabled on this deployment') + expect(fetchMock).not.toHaveBeenCalled() + }) + it('rejects execution before the internal request when billing attribution is missing', async () => { context.metadata.billingAttribution = undefined diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 866446b2761..cf422494fa7 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -5,6 +5,7 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { readUserFileContent } from '@/lib/execution/payloads/materialization.server' import { @@ -337,6 +338,12 @@ export class MothershipBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { + // Reaches the same backend as the Chat module, so with Chat off the request + // can only come back 401. Fail with something the workflow author can act on. + if (!isChatEnabled) { + throw new Error('Chat is disabled on this deployment, so the Sim Chat block cannot run') + } + const prompt = inputs.prompt if (!prompt || typeof prompt !== 'string') { throw new Error('Prompt input is required') diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index fe49bcb8283..0110e67a535 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' const logger = createLogger('MothershipChatEvents') @@ -125,12 +126,16 @@ export function handleMothershipChatStatusEvent( /** * Subscribes to chat status SSE events and invalidates chat caches on changes. * The SSE event name remains `task_status` for wire compatibility. + * + * No-ops when Chat is disabled — this is mounted from the persistent sidebar, so + * without the guard every session would hold an open connection to an endpoint + * that cannot serve it. */ export function useMothershipChatEvents(workspaceId: string | undefined) { const queryClient = useQueryClient() useEffect(() => { - if (!workspaceId) return + if (!workspaceId || !isChatEnabled) return const eventSource = new EventSource( `/api/mothership/events?workspaceId=${encodeURIComponent(workspaceId)}` diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 5ab950c0eb8..16277125189 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -27,6 +27,7 @@ import { import { isAccessControlEnabled, isBillingEnabled, + isChatEnabled, isHosted, isInboxEnabled, isSsoEnabled, @@ -522,7 +523,12 @@ function isInboxEntitledPlan(plan: string): boolean { * the workspace's organization, or its billed account for personal workspaces, * is on a Max or enterprise plan. * - * Returns true if: + * Always false when Chat is disabled — inbox tasks run through the mothership + * and answer with a link to the resulting chat, so neither half works without it. + * That check comes first because the `!isBillingEnabled` shortcut below would + * otherwise hand every self-hosted deployment a broken Inbox. + * + * Otherwise returns true if: * - INBOX_ENABLED env var is set (self-hosted override), OR * - billing is disabled, OR * - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR @@ -530,6 +536,7 @@ function isInboxEntitledPlan(plan: string): boolean { */ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { + if (!isChatEnabled) return false if (isInboxEnabled) return true if (!isBillingEnabled) return true diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 23960cea260..efc77949e80 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -45,6 +45,23 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Is the Chat module exposed. + * + * Server code reads `CHAT_ENABLED`; client evaluation reads the + * `NEXT_PUBLIC_CHAT_ENABLED` twin via `window.__ENV`, so deployments must set + * both together (the setup wizard writes the pair). Chat needs + * `COPILOT_API_KEY` to reach the mothership at all — `bootstrap.ts` throws when + * this flag is on without it, so the two can never disagree at runtime. + * + * Read at module scope or inline during render only. Resolving it through + * `useState`/`useEffect` would render chat surfaces before removing them. + */ +export const isChatEnabled = + typeof window === 'undefined' + ? isTruthy(env.CHAT_ENABLED) + : isTruthy(getEnv('NEXT_PUBLIC_CHAT_ENABLED')) + /** * Is billing enforcement enabled. * @@ -207,8 +224,12 @@ export const isOrganizationsEnabled = /** * Is inbox (Sim Mailer) enabled via env var override * This bypasses hosted requirements for self-hosted deployments + * + * Requires Chat: an inbound message is executed by the mothership and answered + * with a link to the resulting chat, so with Chat off every inbox task fails and + * mails the recipient a dead link. */ -export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) +export const isInboxEnabled = isTruthy(env.INBOX_ENABLED) && isChatEnabled /** * Is whitelabeling enabled via env var override diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a97d6854e33..31ea1f02e4c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -51,6 +51,7 @@ export const env = createEnv({ // Copilot COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication + CHAT_ENABLED: z.boolean().optional(), // Expose the Chat module (requires COPILOT_API_KEY); set the NEXT_PUBLIC_ twin together /** Enables attributed-v1 only after compatible Copilot instances are deployed. */ COPILOT_BILLING_ATTRIBUTION_V1_ENABLED: z.boolean().optional(), /** Rejects markerless old-Go billing traffic only when explicitly enabled. */ @@ -585,6 +586,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted + NEXT_PUBLIC_CHAT_ENABLED: z.boolean().optional(), // Client twin of CHAT_ENABLED — set both together. Read via getEnv(), never env.* NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget }, @@ -627,6 +629,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS, NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, + NEXT_PUBLIC_CHAT_ENABLED: process.env.NEXT_PUBLIC_CHAT_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index a743765f9f4..3364825592b 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -600,7 +600,7 @@ async function acceptLockedInvitation( const redirectPath = inv.kind === 'workspace' && acceptedWorkspaceIds.length > 0 - ? `/workspace/${acceptedWorkspaceIds[0]}/home` + ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace' return { diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index 4ca449cfb5a..4f0ef7a397f 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -318,7 +318,7 @@ export interface SendWorkspaceAddedEmailInput { export async function sendWorkspaceAddedEmail( input: SendWorkspaceAddedEmailInput ): Promise { - const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home` + const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}` const emailHtml = await renderWorkspaceAddedEmail( input.inviterName, input.workspaceName, diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 4659f0938f9..9c739e6723c 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -64,6 +64,23 @@ function scopeCondition( return and(base, isNull(workflow.archivedAt)) } +/** + * The workspace's first active workflow in list order, or `null` when it has + * none. Shares {@link orderByClause} with {@link listWorkflowsForUser} so the + * workspace landing route resolves to the same workflow the sidebar lists + * first. Performs no auth checks — callers enforce workspace access before + * invoking. + */ +export async function getFirstWorkflowIdForWorkspace(workspaceId: string): Promise { + const [row] = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + .orderBy(...orderByClause) + .limit(1) + return row?.id ?? null +} + /** * Lists workflows visible to a user as the contract wire shape, shared by the * `GET /api/workflows` route and the workspace sidebar prefetch. Performs no auth diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index c8257f355c7..80d0c618f4b 100644 --- a/apps/sim/stores/panel/store.ts +++ b/apps/sim/stores/panel/store.ts @@ -1,12 +1,14 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { PANEL_WIDTH } from '@/stores/constants' import type { PanelState, PanelTab } from '@/stores/panel/types' /** - * Default panel tab + * Default panel tab. Falls back to the toolbar when Chat is disabled, since the + * copilot tab is not rendered then and would leave the panel body empty. */ -const DEFAULT_TAB: PanelTab = 'copilot' +const DEFAULT_TAB: PanelTab = isChatEnabled ? 'copilot' : 'toolbar' export const usePanelStore = create()( persist( diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index c21cf67b472..101fc8cc9d5 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -8,6 +8,7 @@ import { type AgentStreamToolTerminalStatus, settleRunningToolCallList, } from '@/components/agent-stream/tool-call-lifecycle' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' @@ -310,10 +311,12 @@ const notifyBlockError = ({ toast.error(displayName, { description: errorMessage, - action: { - label: 'Fix in Chat', - onClick: () => sendMothershipMessage(copilotMessage), - }, + action: isChatEnabled + ? { + label: 'Fix in Chat', + onClick: () => sendMothershipMessage(copilotMessage), + } + : undefined, }) } catch (notificationError) { logger.error('Failed to create block error notification', { diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 69b721e7b8c..1fb92ac60f9 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -13,6 +13,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isChatEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean isAuthDisabled: boolean @@ -54,6 +55,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isChatEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 6e07706f48d..711cbe77ffd 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -7,6 +7,7 @@ import { ensurePortsFree } from '../ports.ts' import { httpHealth, waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -112,6 +113,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) if (!quick) { const storage = await promptStorage(root.vars, true) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index d0545e064ee..5364c165891 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -9,6 +9,7 @@ import { pgProbe } from '../probes.ts' import * as p from '../prompter.ts' import { ensureRedis, resolveRedis } from '../redis.ts' import { + chatFlagValues, collectSecrets, mothershipOverride, promptCopilotKey, @@ -124,6 +125,7 @@ export async function runDevMode( Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey + Object.assign(values, chatFlagValues(copilotKey)) Object.assign(values, await promptLlmKeys(detection, !quick)) // Redis is set up in every mode, quick included. Storage falls back to diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index ab4c27fafe8..64a2e5ee475 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -57,7 +57,7 @@ export async function promptCopilotKey(existing?: string): Promise