From c5cc6ce26cd0594f688e3a6f4dbb61422b6dead9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 16:23:24 -0700 Subject: [PATCH] feat(chat): hide the Chat module when NEXT_PUBLIC_CHAT_DISABLED is set (#6137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(chat): gate the UI on NEXT_PUBLIC_CHAT_DISABLED, not an opt-in flag CHAT_ENABLED made Chat opt-in, so every existing deployment that already had COPILOT_API_KEY would have lost the module until it set a new variable. Invert to an opt-out so nothing changes for them. That also collapses the twin. The only reason the flag needed a server/client pair was that it projected a secret; NEXT_PUBLIC_CHAT_DISABLED is not one, so getEnv resolves the same value from process.env on the server and window.__ENV in the browser. Gone with it: the FLAG_TWINS entry and its doctor sync check, the two-variable wizard write, and the boot-time throw, whose contradiction (flag on, key absent) can no longer be expressed. Presentation and capability are now separate concerns. NEXT_PUBLIC_CHAT_DISABLED decides whether the surfaces render; COPILOT_API_KEY decides whether the work can run, and gates the paths that need it — the Sim Chat block, prompt-job claims, and inbox access — each failing on its own terms. The wizard writes the opt-out when you skip the chat key, which is the case this started from: a fresh self-host that never configured Chat. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * feat(setup): prompt for the chat key in k8s mode The dev and compose flows minted a chat key and wrote the Chat opt-out alongside it; k8s did neither, so a cluster install with no COPILOT_API_KEY in its Helm values rendered a Chat module that rejects every message. Prompt with the same flow and feed both values into `app.env`, which the chart already renders as arbitrary container env. Reading the previous release's key matters here in a way it does not for the file-based modes: `helm upgrade` without `--reuse-values` keeps only what this document carries, so a key the user elects to keep has to be re-supplied or it is silently dropped. Splits the release-values read from the secret-reuse check so both the key and the secrets come from one `helm get values` call, and carries the mothership override across for the same mint-here-validate-there reason the other modes document. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(setup): write app-behavior flags to every env file the app can start from The wizard wrote the Chat opt-out only to the env file its own mode owns, so choosing compose put it in the root `.env` while `bun run dev` reads `apps/sim/.env` and never saw it. Skipping the chat key appeared to do nothing. Mirror values that change how the app behaves — as opposed to where it connects — across both targets. Connection settings deliberately do not go through this: DATABASE_URL and friends differ between the compose stack and a local dev run, which is why this takes an explicit set of values rather than the whole batch. The mirrored file is written even when absent, since missing is exactly the case that stranded the flag, but with seeding suppressed so a compose run leaves a one-line apps/sim/.env instead of a full .env.example for a stack the user is not running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(compose): forward NEXT_PUBLIC_CHAT_DISABLED to the app container The wizard wrote the flag into the root .env, but compose only passes through variables the service's `environment` block names — and that block listed COPILOT_API_KEY without its companion. Skipping the chat key on a Docker install therefore did nothing: the value sat in .env and never reached the container. Add the passthrough to all four compose files. Reverts the previous commit's mirroring into apps/sim/.env, which treated the symptom — each mode writes only the env file it owns, and that file is now wired correctly. k8s needs no equivalent: its values flow into `app.env`, which the chart renders key by key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(chat): resolve the landing route without blocking on the database Server-resolving the first workflow meant a session lookup, an access check and a query had to finish before anything rendered. A slow or unreachable database left the user on a blank page under a populated sidebar — worse than the instant redirect it replaced, and with no signal that anything was wrong. Redirect straight to `/w` instead and let it pick from the workflow list the layout already prefetches, so the choice costs no round trip and cannot hang. Repoints the sidebar's primary action rather than hiding it: the slot that offered "New chat" now offers "New workflow" and creates one, since with Chat off there is no composer to open but the intent is the same. Sends the CLI key handoff to signup rather than login. It is reached from a terminal — usually the setup wizard standing up a fresh self-host — where the visitor has no account yet. Both auth pages cross-link carrying the callback, so a returning user is one click from login with their destination intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * improvement(chat): address cleanup-pass findings on the Chat gate Effects: the panel's auto-select effect read the copilot chat list while the list query was deliberately skipped, took "empty" for "deleted in another tab", and cleared the user's selection — latching a ref that stopped it ever being restored. Guarded on the same condition as the handoff listener. Memo: `/w` filtered workflows through a useMemo whose array dependency was a fresh `[]` on every render while the query had no data — the exact window the page exists for — so it memoized nothing and re-fired the redirect effect. Keyed on the workflow id instead. Same unstable-default problem on the sidebar's chat list, where it invalidated five downstream memos; given a stable empty constant. Callback: `handleCreateWorkflow` listed the whole mutation object in its deps, which TanStack recreates every render. Harmless until this branch wired it into the top nav, where it defeated `memo(SidebarNavItem)`. React Query: Recently Deleted still fetched archived chats unconditionally and offered restores into routes that now 404. Also surfaces an error state on `/w` — it is the landing route now, so a failed list fetch would otherwise spin forever behind a log line — fixes a spinner using a token undefined in dark mode, and trims comments that restated code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(chat): gate workflow creation on write access, pin the key in schedule tests The zero-workflow landing offered "Create workflow" to every member. Creation navigates optimistically, so a read-only member was sent to a workflow the server had already refused to create, with the failure never surfaced. Gate both entry points — the empty state and the sidebar's "New workflow" row — on the same `canEdit` check the rest of the sidebar uses, and tell read-only members who can make one instead of offering an action that cannot succeed. The schedule-execution tests only passed locally because vitest loads the developer's own `.env`, which supplied COPILOT_API_KEY; CI has none, so the prompt-job claim guard skipped the claims those cases assert on. Pin the key through the env mock so the suite states its own preconditions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha * fix(setup): name both variables in the chat-key failure hint The caller writes the Chat opt-out whenever the prompt returns no key, so the hint's "or set COPILOT_API_KEY yourself" restored capability while leaving the module hidden — the one path where following setup's own advice does not work. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012ErcRgvi7VQBeKDQ3MBMha --------- Co-authored-by: Claude Opus 5 (1M context) --- .devcontainer/docker-compose.yml | 1 + .../self-hosting/environment-variables.mdx | 3 +- apps/sim/.env.example | 4 + apps/sim/app/api/mothership/events/route.ts | 5 + .../app/api/schedules/execute/route.test.ts | 10 +- apps/sim/app/api/schedules/execute/route.ts | 14 +- apps/sim/app/cli/auth/page.tsx | 10 +- 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 | 12 +- .../app/workspace/[workspaceId]/prefetch.ts | 21 +- .../[workspaceId]/scheduled-tasks/page.tsx | 6 + .../recently-deleted/recently-deleted.tsx | 8 +- .../[workspaceId]/upgrade/upgrade.tsx | 4 +- .../w/[workflowId]/components/panel/panel.tsx | 41 ++- .../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-workflow-operations.ts | 8 +- .../sidebar/hooks/use-workspace-management.ts | 2 +- .../w/components/sidebar/sidebar.tsx | 280 ++++++++++-------- .../w/hooks/use-delete-selection.ts | 2 +- .../w/hooks/use-delete-workflow.ts | 2 +- .../app/workspace/[workspaceId]/w/page.tsx | 95 ++++-- apps/sim/app/workspace/page.tsx | 4 +- .../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 | 10 +- apps/sim/lib/core/config/env-flags.ts | 17 ++ apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/invitations/core.test.ts | 4 +- apps/sim/lib/invitations/core.ts | 2 +- apps/sim/lib/invitations/send.ts | 2 +- apps/sim/stores/panel/store.ts | 6 +- apps/sim/stores/terminal/console/store.ts | 11 +- docker-compose.local.yml | 1 + docker-compose.ollama.yml | 1 + docker-compose.prod.yml | 1 + package.json | 2 +- packages/testing/src/mocks/env-flags.mock.ts | 7 +- scripts/setup/cli-auth.ts | 2 +- scripts/setup/modes/compose.ts | 2 + scripts/setup/modes/dev.ts | 2 + scripts/setup/modes/k8s.ts | 44 ++- scripts/setup/steps.ts | 20 +- 54 files changed, 582 insertions(+), 230 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f3482d665d..f3b23b10b5 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your_auth_secret_here} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_encryption_key_here} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} 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 7e8f86e958..416779ba00 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,8 @@ 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. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run | +| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key | | `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 3c2dcf8f22..db17741099 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -21,6 +21,10 @@ 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) +# COPILOT_API_KEY= # Mint one at https://sim.ai. Without it the Sim Chat block, prompt jobs, and Inbox cannot run +# NEXT_PUBLIC_CHAT_DISABLED=true # Hides the Chat module: the workspace lands on your first workflow, and the chats list, scheduled tasks, and editor Chat panel are absent. Chat is shown when unset; `bun run setup` sets this for you if you skip the chat key + # 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 5509358254..c942c82566 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.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index d738fb326a..45053f9d53 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -9,6 +9,8 @@ import { requestUtilsMockFns, resetDbChainMock, resetEnvFlagsMock, + resetEnvMock, + setEnv, setEnvFlags, } from '@sim/testing' import { type NextRequest, NextResponse } from 'next/server' @@ -275,7 +277,10 @@ function createMockRequest(): NextRequest { } as NextRequest } -afterAll(resetEnvFlagsMock) +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { @@ -290,6 +295,9 @@ describe('Scheduled Workflow Execution API Route', () => { dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true }) + // Prompt-job claims are skipped without the mothership credential; pin it so + // these cases do not depend on whether the runner happens to have a .env. + setEnv({ COPILOT_API_KEY: 'test-api-key' }) mockShouldExecuteInline.mockReturnValue(false) mockEnqueue.mockReset() mockEnqueue.mockResolvedValue('job-id-1') diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index debf5680af..de14972d70 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' @@ -1245,7 +1246,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 b8bae2ff0a..3f60d94d8d 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 a1e1febf0f..b7a6cc4ea9 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 { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag' @@ -15,6 +17,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 1558d9d1ca..929a673c99 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 @@ -7,6 +7,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { blockTypeToIconMap, type Integration, @@ -157,11 +158,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration Add to Sim ) - ) : ( + ) : isChatEnabled ? ( Add to Sim - )} + ) : null}
{oauthService && ( @@ -243,7 +244,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 3dd9a4a9d8..5a353e7c0f 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 43dbfbaea3..db69e38864 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`) + redirect(`/workspace/${workspaceId}/${isChatEnabled ? 'home' : 'w'}`) } diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 809cae8768..e4c372eb60 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' @@ -73,14 +74,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', 'workflow'), 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 38da2e2294..b078ff176d 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]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index feb46c11b9..df8930fe4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -9,6 +9,7 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' +import { isChatEnabled } from '@/lib/core/config/env-flags' import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components' import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' @@ -226,7 +227,12 @@ export function RecentlyDeleted() { const tableFoldersQuery = useFolders(workspaceId, { scope: 'archived', resourceType: 'table' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived') - const chatsQuery = useMothershipChats(workspaceId, { scope: 'archived' }) + // Restoring a chat navigates to a route that 404s with Chat off, and this + // query's loading/error state feeds the whole panel's. + const chatsQuery = useMothershipChats(workspaceId, { + scope: 'archived', + enabled: isChatEnabled, + }) const restoreWorkflow = useRestoreWorkflow() const restoreFolder = useRestoreFolder() diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 547008947c..d752241b55 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -69,7 +69,7 @@ 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, so there is no plan to pick here. @@ -77,7 +77,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { // state — page.tsx resolves those before this ever mounts. useEffect(() => { 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 1a997731fd..5b16f42ee9 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) @@ -278,7 +294,10 @@ export const Panel = memo(function Panel() { // chat was deleted in another tab). const autoSelectAttemptedForRef = useRef>(new Set()) useEffect(() => { - if (!activeWorkflowId) return + // The list query is skipped when the tab is unavailable, so an empty list + // there means "not fetched", not "deleted elsewhere" — clearing on it would + // discard the selection and latch the ref against ever restoring it. + if (!activeWorkflowId || !isCopilotTabAvailable) return if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) { setCopilotChatId(undefined) @@ -290,7 +309,7 @@ export const Panel = memo(function Panel() { if (copilotChatList.length === 0) return autoSelectAttemptedForRef.current.add(activeWorkflowId) setCopilotChatId(copilotChatList[0].id) - }, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId]) + }, [copilotChatList, copilotChatId, activeWorkflowId, isCopilotTabAvailable, setCopilotChatId]) useEffect(() => { posthogRef.current = posthog @@ -456,7 +475,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 +493,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 +786,7 @@ export const Panel = memo(function Panel() { {/* Tabs */}
- {!permissionConfig.hideCopilot && ( + {isCopilotTabAvailable && ( - )} - - )} -
- )} -
+ + return ( + + ) + })} + {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 bb22fb2fa0..2a126ea4df 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 0e9c5d82cc..9db4fa5a16 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 de85beccd5..301548f655 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -1,52 +1,111 @@ 'use client' import { useEffect } from 'react' +import { Chip } from '@sim/emcn' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { ReactFlowProvider } from 'reactflow' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' 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 { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + + // An id rather than the filtered array: `data` defaults to a fresh `[]` while + // the query has no data, so an array dependency would re-fire this on every + // render — exactly during the load this page exists to cover. + const firstWorkflowId = workflows.find((w) => w.workspaceId === workspaceId)?.id + 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}`) + if (firstWorkflowId) { + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) } - }, [isLoading, isPlaceholderData, workflows, workspaceId, router, isError]) + }, [isResolving, isError, firstWorkflowId, 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 && !firstWorkflowId + const canCreate = !permissionsLoading && canEdit - // Always show loading state until redirect happens - // There should always be a default workflow, so we never show "no workflows found" return (
-
+ {isError ? ( + // This is the landing route now, so a failed list fetch would + // otherwise spin forever with nothing but a log line. +
+
+

Couldn't load workflows

+

Check your connection and try again.

+
+ router.refresh()}> + Retry + +
+ ) : isEmpty ? ( +
+
+

No workflows yet

+

+ {canCreate + ? 'Create one to start building.' + : 'Ask a workspace admin to create one.'} +

+
+ {/* The create mutation navigates optimistically, so offering it + without write access would strand a read-only member on a + workflow the server declined to create. */} + {canCreate && ( + + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + + )} +
+ ) : ( + + )}
diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 5db8905baa..0f9b725ccc 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -132,7 +132,7 @@ export default function WorkspacePage() { const destinationFor = (id: string) => redirectTarget === 'upgrade' ? buildUpgradeHref(id, isUpgradeReason(rawReason) ? rawReason : undefined) - : `/workspace/${id}/home` + : `/workspace/${id}` const { workspaces, lastActiveWorkspaceId, creationPolicy } = data @@ -253,7 +253,7 @@ async function handleWorkflowRedirect( } catch (error) { logger.error('Error fetching workflow for redirect:', error) } - router.replace(`/workspace/${fallbackWorkspaceId}/home`) + router.replace(`/workspace/${fallbackWorkspaceId}`) } async function handleNoWorkspaces( diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 364491017b..69098fae14 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 { resetEnvMock, setEnv } 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 the mothership credential. + setEnv({ COPILOT_API_KEY: 'test-copilot-key' }) block = { id: 'mothership-block-1', @@ -146,6 +149,7 @@ describe('MothershipBlockHandler', () => { vi.useRealTimers() vi.clearAllMocks() vi.unstubAllGlobals() + resetEnvMock() }) function createNdjsonResponse(events: unknown[]): Response { @@ -222,6 +226,15 @@ describe('MothershipBlockHandler', () => { }) }) + it('rejects execution before the internal request when COPILOT_API_KEY is unset', async () => { + setEnv({ COPILOT_API_KEY: undefined }) + + await expect( + handler.execute(context, block, { prompt: 'Hello from workflow' }) + ).rejects.toThrow('COPILOT_API_KEY is not configured') + 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 866446b276..7a082b731e 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 { env } from '@/lib/core/config/env' 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 { + // Without the key the mothership rejects every request, so fail with + // something the workflow author can act on instead of a bare 401. + if (!env.COPILOT_API_KEY) { + throw new Error('COPILOT_API_KEY is not configured, 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 fe49bcb828..0110e67a53 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 d4306a28b3..5aa70b7cd5 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -24,6 +24,7 @@ import { hasUsableSubscriptionAccess, USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' +import { env } from '@/lib/core/config/env' import { isAccessControlEnabled, isBillingEnabled, @@ -622,7 +623,13 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise * the workspace's organization, or its billed account for personal workspaces, * is on a Max or enterprise plan. * - * Returns true if: + * Always false without `COPILOT_API_KEY` — inbox tasks are executed by the + * mothership and answered 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 @@ -630,6 +637,7 @@ async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise */ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { + if (!env.COPILOT_API_KEY) return false if (isInboxEnabled) return true if (!isBillingEnabled) return true return await hasMaxTierWorkspaceAccess(workspaceId) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 484cbfc582..93d12e032b 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -59,6 +59,23 @@ export const isCopilotBillingAttributionV1Enabled = isTruthy( */ export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) +/** + * Are the Chat module's surfaces shown. On by default, so a deployment that + * already has `COPILOT_API_KEY` keeps Chat without setting anything; the setup + * wizard writes the opt-out when you skip the key. + * + * This governs presentation only. Whether Chat can actually reach the mothership + * is a separate question answered by `COPILOT_API_KEY`, which gates the paths + * that need it (the Sim Chat block, prompt-job claims, inbox execution). Keeping + * them separate is what lets this be a single variable: the secret key could + * never be read in the browser, but `NEXT_PUBLIC_CHAT_DISABLED` can — no twin to + * keep in sync. + * + * Read at module scope or inline during render only. Resolving it through + * `useState`/`useEffect` would render chat surfaces before removing them. + */ +export const isChatEnabled = !isTruthy(getEnv('NEXT_PUBLIC_CHAT_DISABLED')) + /** * Holds tools the catalog marks `requiresApproval` — shell commands, workflow * runs, sandboxed code, deployments, integration calls — behind an explicit diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a06773654b..9d16e13ab4 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -605,6 +605,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_DISABLED: z.boolean().optional(), // Hide the Chat module (Chat is shown when unset) NEXT_PUBLIC_SANDBOXES_ENABLED: z.boolean().optional(), // Enable custom sandboxes on self-hosted 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 @@ -649,6 +650,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_DISABLED: process.env.NEXT_PUBLIC_CHAT_DISABLED, NEXT_PUBLIC_SANDBOXES_ENABLED: process.env.NEXT_PUBLIC_SANDBOXES_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, diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index b90d2669a8..ec014758f1 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -1131,7 +1131,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } expect(mockAttachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith( expect.anything(), @@ -1580,7 +1580,7 @@ describe('acceptInvitation', () => { expect(result.success).toBe(true) if (result.success) { - expect(result.redirectPath).toBe('/workspace/workspace-1/home') + expect(result.redirectPath).toBe('/workspace/workspace-1') } }) diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 13be762010..bb5f7eea62 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -1328,7 +1328,7 @@ async function acceptLockedInvitation( effects.membershipAlreadyExists = membershipAlreadyExists const redirectPath = - acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}/home` : '/workspace' + acceptedWorkspaceIds.length > 0 ? `/workspace/${acceptedWorkspaceIds[0]}` : '/workspace' return { success: true, diff --git a/apps/sim/lib/invitations/send.ts b/apps/sim/lib/invitations/send.ts index a49cb8c21c..ae6565b3d1 100644 --- a/apps/sim/lib/invitations/send.ts +++ b/apps/sim/lib/invitations/send.ts @@ -677,7 +677,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/stores/panel/store.ts b/apps/sim/stores/panel/store.ts index c8257f355c..80d0c618f4 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 c21cf67b47..101fc8cc9d 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/docker-compose.local.yml b/docker-compose.local.yml index 78e2f59159..1a4d19df80 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -23,6 +23,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 43f5cdcbff..e425cb3aa6 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -19,6 +19,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-sim_auth_secret_$(openssl rand -hex 16)} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-$(openssl rand -hex 32)} - COPILOT_API_KEY=${COPILOT_API_KEY} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=http://ollama:11434 - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fa187e0aa4..363422c301 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,7 @@ services: - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - NEXT_PUBLIC_CHAT_DISABLED=${NEXT_PUBLIC_CHAT_DISABLED:-} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} diff --git a/package.json b/package.json index fec0621c54..e6baf99638 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", "setup": "bun install && bun run scripts/setup/index.ts setup", - "sim": "bun run scripts/setup/index.ts", + "sim": "bun install && bun run scripts/setup/index.ts", "doctor": "bun run scripts/setup/index.ts doctor", "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index d62fd10271..1b413fc52e 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -3,8 +3,9 @@ import { vi } from 'vitest' /** * Mutable value-export state for the shared `@/lib/core/config/env-flags` mock. * Defaults mirror the real module evaluated under the vitest environment - * (NODE_ENV=test, no feature env vars set): only `isTest` and - * `isEmailPasswordEnabled` are true. + * (NODE_ENV=test, no feature env vars set): only `isTest`, + * `isEmailPasswordEnabled`, and `isChatEnabled` are true — the last because it + * is an opt-out flag, on unless `NEXT_PUBLIC_CHAT_DISABLED` is set. */ export interface EnvFlagsMockState { isProd: boolean @@ -13,6 +14,7 @@ export interface EnvFlagsMockState { isHosted: boolean isCopilotBillingAttributionV1Enabled: boolean isCopilotBillingProtocolRequired: boolean + isChatEnabled: boolean isCopilotToolPermissionsEnabled: boolean isBillingEnabled: boolean isEmailVerificationEnabled: boolean @@ -58,6 +60,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isHosted: false, isCopilotBillingAttributionV1Enabled: false, isCopilotBillingProtocolRequired: false, + isChatEnabled: true, isCopilotToolPermissionsEnabled: false, isBillingEnabled: false, isEmailVerificationEnabled: false, diff --git a/scripts/setup/cli-auth.ts b/scripts/setup/cli-auth.ts index 9ffba88255..c9f3dfec9e 100644 --- a/scripts/setup/cli-auth.ts +++ b/scripts/setup/cli-auth.ts @@ -76,7 +76,7 @@ export async function browserKeyFlow(origin: string): Promise { 'Confirm this code in your browser' ) p.log.info( - `Opening your browser — sign in and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` + `Opening your browser — create your account (or sign in) and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}` ) openBrowser(authUrl) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 6e07706f48..711cbe77ff 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 d0545e064e..5364c16589 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/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 59d31effd7..c76e1c6ba8 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -6,6 +6,7 @@ import { generateSecret, ROOT } from '../env-files.ts' import { SetupError } from '../errors.ts' import { waitFor } from '../probes.ts' import * as p from '../prompter.ts' +import { chatFlagValues, mothershipOverride, promptCopilotKey } from '../steps.ts' import { glyph, theme } from '../theme.ts' const APP_URL = 'http://localhost:3000' @@ -276,15 +277,28 @@ async function helmInstall( } } -function existingReleaseSecrets(context: string): Record | null { +interface ReleaseValues { + app?: { env?: Record } + postgresql?: { auth?: { password?: string } } +} + +function existingReleaseValues(context: string): ReleaseValues | null { const scope = ['--kube-context', context, '-n', NAMESPACE] const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' }) if (status.status !== 0) return null - const values = JSON.parse( + return JSON.parse( run('helm', ['get', 'values', RELEASE, ...scope, '-o', 'json'], 'helm get values failed') - ) as { app?: { env?: Record }; postgresql?: { auth?: { password?: string } } } - const env = values.app?.env ?? {} - const password = values.postgresql?.auth?.password + ) as ReleaseValues +} + +/** + * The previous release's secrets, or `null` when any are missing — a partial set + * cannot be reused, since regenerating only some of them invalidates sessions + * and stored credentials encrypted under the originals. + */ +function reusableSecrets(values: ReleaseValues | null): Record | null { + const env = values?.app?.env ?? {} + const password = values?.postgresql?.auth?.password if ( !env.BETTER_AUTH_SECRET || !env.ENCRYPTION_KEY || @@ -323,7 +337,8 @@ export async function runK8sMode(detection: Detection): Promise { // credentials to an unintended cluster. const context = await ensureLocalContext(detection) - const reused = existingReleaseSecrets(context) + const releaseValues = existingReleaseValues(context) + const reused = reusableSecrets(releaseValues) const secrets = reused ?? { BETTER_AUTH_SECRET: generateSecret(), ENCRYPTION_KEY: generateSecret(), @@ -333,6 +348,21 @@ export async function runK8sMode(detection: Detection): Promise { } if (reused) p.log.step('Reusing secrets from the existing release') + // Before the key is minted: a half-set override mints against one environment + // and validates against the other, and warning afterwards is too late — the + // bad key is already deployed. + const overrides = mothershipOverride() + const copilotKey = await promptCopilotKey(releaseValues?.app?.env?.COPILOT_API_KEY) + + // `helm upgrade` without `--reuse-values` keeps only what this document + // carries, so a key the user chose to keep has to be re-supplied here. + const appEnv: Record = { + ...secrets, + ...overrides, + ...(copilotKey ? { COPILOT_API_KEY: copilotKey } : {}), + ...chatFlagValues(copilotKey), + } + const spin = p.spinner() spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…') try { @@ -355,7 +385,7 @@ export async function runK8sMode(detection: Detection): Promise { '--timeout', '15m', ], - secretValues(secrets), + secretValues(appEnv), context, spin ) diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 457c830675..1d3edf6afb 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -57,17 +57,33 @@ export async function promptCopilotKey(existing?: string): Promise